Compare commits
No commits in common. "0441a9d6487fad774e97053944da6a0a3cdbc4bb" and "a106c78bc8d9e444efe7032e34e05089c2438e5f" have entirely different histories.
0441a9d648
...
a106c78bc8
14
CLAUDE.md
14
CLAUDE.md
@ -12,12 +12,7 @@ GroveEngine is a C++17 hot-reload module system for game engines. It supports dy
|
||||
**Module-specific:**
|
||||
- **[BgfxRenderer README](modules/BgfxRenderer/README.md)** - 2D rendering module (sprites, text, tilemap, particles)
|
||||
- **[InputModule README](modules/InputModule/README.md)** - Input handling (mouse, keyboard, gamepad)
|
||||
- **[UIModule README](modules/UIModule/README.md)** - User interface system 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
|
||||
- **[UIModule README](modules/UIModule/README.md)** - User interface system (buttons, panels, scrolling, tooltips)
|
||||
- **[UI Rendering](docs/UI_RENDERING.md)** - Retained mode rendering architecture
|
||||
|
||||
## Available Modules
|
||||
@ -42,10 +37,6 @@ cmake --build build -j4
|
||||
# Run all tests (23+ tests)
|
||||
cd build && ctest --output-on-failure
|
||||
|
||||
# Run visual tests (IMPORTANT: always run from project root for correct asset paths)
|
||||
./build/tests/test_ui_showcase # UI showcase with all widgets
|
||||
./build/tests/test_renderer_showcase # Renderer showcase (sprites, text, particles)
|
||||
|
||||
# Build with ThreadSanitizer
|
||||
cmake -DGROVE_ENABLE_TSAN=ON -B build-tsan
|
||||
cmake --build build-tsan -j4
|
||||
@ -109,8 +100,7 @@ std::lock_guard lock2(mutex2); // DEADLOCK RISK
|
||||
### UIModule
|
||||
- **UIRenderer**: Publishes render commands to BgfxRenderer via IIO (layer 1000+)
|
||||
- **Widgets**: UIButton, UIPanel, UILabel, UICheckbox, UISlider, UITextInput, UIProgressBar, UIImage, UIScrollPanel, UITooltip
|
||||
- **IIO Topics**: Consumes `input:*`, `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
|
||||
- **IIO Topics**: Consumes `input:*`, publishes `ui:click`, `ui:action`, `ui:value_changed`, etc.
|
||||
|
||||
### InputModule
|
||||
- **Backends**: SDLBackend (mouse, keyboard, gamepad Phase 2)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
@ -1,367 +0,0 @@
|
||||
# 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.
|
||||
@ -1,125 +0,0 @@
|
||||
# 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));
|
||||
}
|
||||
```
|
||||
@ -1,376 +0,0 @@
|
||||
# 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",
|
||||
...
|
||||
}
|
||||
```
|
||||
@ -59,7 +59,7 @@ void SpritePass::setup(rhi::IRHIDevice& device) {
|
||||
// Create texture sampler uniform (must match shader: s_texColor)
|
||||
m_textureSampler = device.createUniform("s_texColor", 1);
|
||||
|
||||
// Create default white 4x4 texture (restored to white)
|
||||
// Create default white 4x4 texture (used when no texture is bound)
|
||||
// Some drivers have issues with 1x1 textures
|
||||
uint32_t whitePixels[16];
|
||||
for (int i = 0; i < 16; ++i) whitePixels[i] = 0xFFFFFFFF; // RGBA white
|
||||
@ -98,14 +98,15 @@ void SpritePass::flushBatch(rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd,
|
||||
void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi::RHICommandBuffer& cmd) {
|
||||
if (frame.spriteCount == 0) return;
|
||||
|
||||
// Prepare render state (will be set before each batch)
|
||||
// Set render state ONCE (like TextPass does)
|
||||
rhi::RenderState state;
|
||||
state.blend = rhi::BlendMode::Alpha;
|
||||
state.cull = rhi::CullMode::None;
|
||||
state.depthTest = false;
|
||||
state.depthWrite = false;
|
||||
cmd.setState(state);
|
||||
|
||||
// Sort sprites by layer first (for correct draw order), then by texture (for batching)
|
||||
// Sort sprites by layer for correct draw order
|
||||
m_sortedIndices.clear();
|
||||
m_sortedIndices.reserve(frame.spriteCount);
|
||||
for (size_t i = 0; i < frame.spriteCount; ++i) {
|
||||
@ -113,133 +114,27 @@ void SpritePass::execute(const FramePacket& frame, rhi::IRHIDevice& device, rhi:
|
||||
}
|
||||
std::sort(m_sortedIndices.begin(), m_sortedIndices.end(),
|
||||
[&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].textureId < frame.sprites[b].textureId;
|
||||
return frame.sprites[a].layer < frame.sprites[b].layer;
|
||||
});
|
||||
|
||||
// Batch sprites by texture
|
||||
std::vector<SpriteInstance> batchSprites;
|
||||
batchSprites.reserve(frame.spriteCount);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Start new batch if texture changes
|
||||
if (!firstBatch && spriteTexId != currentTextureId) {
|
||||
// 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));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Start new batch
|
||||
batchSprites.clear();
|
||||
}
|
||||
|
||||
batchSprites.push_back(sprite);
|
||||
currentTextureId = spriteTexId;
|
||||
firstBatch = false;
|
||||
// Copy sorted sprites to temporary buffer (like TextPass does with glyphs)
|
||||
std::vector<SpriteInstance> sortedSprites;
|
||||
sortedSprites.reserve(frame.spriteCount);
|
||||
for (uint32_t idx : m_sortedIndices) {
|
||||
sortedSprites.push_back(frame.sprites[idx]);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Update dynamic instance buffer with ALL sprites (like TextPass)
|
||||
device.updateBuffer(m_instanceBuffer, sortedSprites.data(),
|
||||
static_cast<uint32_t>(sortedSprites.size() * sizeof(SpriteInstance)));
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// Set buffers and draw ALL sprites in ONE call (like TextPass)
|
||||
cmd.setVertexBuffer(m_quadVB);
|
||||
cmd.setIndexBuffer(m_quadIB);
|
||||
cmd.setInstanceBuffer(m_instanceBuffer, 0, static_cast<uint32_t>(sortedSprites.size()));
|
||||
cmd.setTexture(0, m_defaultTexture, m_textureSampler);
|
||||
cmd.drawInstanced(6, static_cast<uint32_t>(sortedSprites.size()));
|
||||
cmd.submit(0, m_shader, 0);
|
||||
}
|
||||
|
||||
} // namespace grove
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
#include "../RHI/RHIDevice.h"
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace grove {
|
||||
|
||||
@ -27,19 +26,8 @@ rhi::ShaderHandle ResourceCache::getShader(const std::string& name) const {
|
||||
|
||||
rhi::TextureHandle ResourceCache::getTextureById(uint16_t id) const {
|
||||
std::shared_lock lock(m_mutex);
|
||||
static bool logged = false;
|
||||
if (!logged && id > 0) {
|
||||
spdlog::info("ResourceCache::getTextureById({}) - cache size: {}", id, m_textureById.size());
|
||||
logged = true;
|
||||
}
|
||||
if (id < m_textureById.size()) {
|
||||
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 m_textureById[id];
|
||||
}
|
||||
return rhi::TextureHandle{}; // Invalid handle
|
||||
}
|
||||
@ -53,37 +41,12 @@ uint16_t ResourceCache::getTextureId(const std::string& path) const {
|
||||
return 0; // Invalid ID
|
||||
}
|
||||
|
||||
uint16_t ResourceCache::registerTexture(rhi::TextureHandle handle, const std::string& name) {
|
||||
if (!handle.isValid()) {
|
||||
return 0; // Invalid handle
|
||||
}
|
||||
|
||||
std::unique_lock lock(m_mutex);
|
||||
|
||||
// Assign new ID
|
||||
uint16_t newId = static_cast<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) {
|
||||
// Check if already loaded
|
||||
{
|
||||
std::shared_lock lock(m_mutex);
|
||||
auto it = m_pathToTextureId.find(path);
|
||||
if (it != m_pathToTextureId.end()) {
|
||||
spdlog::info("📋 ResourceCache: Texture '{}' already loaded with ID {}", path, it->second);
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
@ -92,7 +55,6 @@ uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::st
|
||||
auto result = TextureLoader::loadFromFile(device, path);
|
||||
|
||||
if (!result.success) {
|
||||
spdlog::error("❌ ResourceCache: FAILED to load texture '{}': {}", path, result.error);
|
||||
return 0; // Invalid ID
|
||||
}
|
||||
|
||||
@ -120,8 +82,6 @@ uint16_t ResourceCache::loadTextureWithId(rhi::IRHIDevice& device, const std::st
|
||||
m_pathToTextureId[path] = newId;
|
||||
m_textures[path] = result.handle;
|
||||
|
||||
spdlog::info("✅ ResourceCache: Texture '{}' registered with ID {} (handle={})", path, newId, result.handle.id);
|
||||
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,20 +5,16 @@
|
||||
#include <stb/stb_image.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace grove {
|
||||
|
||||
TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, const std::string& path) {
|
||||
LoadResult result;
|
||||
|
||||
spdlog::info("📂 TextureLoader: Loading texture from '{}'", path);
|
||||
|
||||
// Read file into memory
|
||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||
if (!file.is_open()) {
|
||||
result.error = "Failed to open file: " + path;
|
||||
spdlog::error("❌ TextureLoader: FAILED to open file '{}'", path);
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -28,21 +24,10 @@ TextureLoader::LoadResult TextureLoader::loadFromFile(rhi::IRHIDevice& device, c
|
||||
std::vector<uint8_t> buffer(static_cast<size_t>(size));
|
||||
if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
|
||||
result.error = "Failed to read file: " + path;
|
||||
spdlog::error("❌ TextureLoader: FAILED to read file '{}'", path);
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
return loadFromMemory(device, buffer.data(), buffer.size());
|
||||
}
|
||||
|
||||
TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device, const uint8_t* data, size_t size) {
|
||||
@ -59,17 +44,6 @@ TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device,
|
||||
return result;
|
||||
}
|
||||
|
||||
// Log decoded image info
|
||||
spdlog::info("TextureLoader: Decoded image {}x{} (original {} channels, converted to RGBA)",
|
||||
width, height, channels);
|
||||
if (width > 0 && height > 0) {
|
||||
spdlog::info(" First pixel: R={}, G={}, B={}, A={}",
|
||||
pixels[0], pixels[1], pixels[2], pixels[3]);
|
||||
spdlog::info(" Last pixel: R={}, G={}, B={}, A={}",
|
||||
pixels[(width*height-1)*4 + 0], pixels[(width*height-1)*4 + 1],
|
||||
pixels[(width*height-1)*4 + 2], pixels[(width*height-1)*4 + 3]);
|
||||
}
|
||||
|
||||
// Create texture via RHI
|
||||
rhi::TextureDesc desc;
|
||||
desc.width = static_cast<uint16_t>(width);
|
||||
@ -83,11 +57,8 @@ TextureLoader::LoadResult TextureLoader::loadFromMemory(rhi::IRHIDevice& device,
|
||||
result.height = desc.height;
|
||||
result.success = result.handle.isValid();
|
||||
|
||||
if (result.success) {
|
||||
spdlog::info("✅ TextureLoader: GPU texture created successfully (handle={})", result.handle.id);
|
||||
} else {
|
||||
if (!result.success) {
|
||||
result.error = "Failed to create GPU texture";
|
||||
spdlog::error("❌ TextureLoader: FAILED to create GPU texture (handle invalid)");
|
||||
}
|
||||
|
||||
// Free stb_image memory
|
||||
|
||||
@ -28,11 +28,9 @@ void SceneCollector::collect(IIO* io, float deltaTime) {
|
||||
// Route message based on topic
|
||||
// Retained mode (new) - sprites
|
||||
if (msg.topic == "render:sprite:add") {
|
||||
spdlog::info("✅ RETAINED MODE: render:sprite:add received");
|
||||
parseSpriteAdd(*msg.data);
|
||||
}
|
||||
else if (msg.topic == "render:sprite:update") {
|
||||
spdlog::info("✅ RETAINED MODE: render:sprite:update received");
|
||||
parseSpriteUpdate(*msg.data);
|
||||
}
|
||||
else if (msg.topic == "render:sprite:remove") {
|
||||
@ -50,7 +48,6 @@ void SceneCollector::collect(IIO* io, float deltaTime) {
|
||||
}
|
||||
// Ephemeral mode (legacy)
|
||||
else if (msg.topic == "render:sprite") {
|
||||
spdlog::info("⚠️ EPHEMERAL MODE: render:sprite received (should not happen in retained mode!)");
|
||||
parseSprite(*msg.data);
|
||||
}
|
||||
else if (msg.topic == "render:sprite:batch") {
|
||||
@ -490,9 +487,6 @@ void SceneCollector::parseSpriteAdd(const IDataNode& data) {
|
||||
sprite.a = static_cast<float>(color & 0xFF) / 255.0f;
|
||||
|
||||
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) {
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
#include "../Widgets/UIButton.h"
|
||||
#include "../Widgets/UISlider.h"
|
||||
#include "../Widgets/UICheckbox.h"
|
||||
#include "../Widgets/UITextInput.h"
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace grove {
|
||||
@ -53,12 +52,6 @@ UIWidget* hitTest(UIWidget* widget, float x, float y) {
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
else if (type == "textinput") {
|
||||
UITextInput* textInput = static_cast<UITextInput*>(widget);
|
||||
if (textInput->containsPoint(x, y)) {
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
@ -142,14 +135,6 @@ UIWidget* dispatchMouseButton(UIWidget* widget, UIContext& ctx, int button, bool
|
||||
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;
|
||||
}
|
||||
|
||||
@ -2,117 +2,439 @@
|
||||
|
||||
Complete UI widget system for GroveEngine with layout, scrolling, tooltips, and automatic input handling.
|
||||
|
||||
## Overview
|
||||
|
||||
UIModule provides a full-featured UI system that integrates with BgfxRenderer for rendering and InputModule for input. All communication happens via IIO topics, ensuring complete decoupling.
|
||||
|
||||
## Features
|
||||
|
||||
- **10 Widget Types**: Button, Label, Panel, Checkbox, Slider, TextInput, ProgressBar, Image, ScrollPanel, Tooltip
|
||||
- **JSON-Based Layouts**: Define UI hierarchies in JSON files
|
||||
- **Automatic Input Handling**: Consumes `input:*` topics from InputModule
|
||||
- **Retained Mode Rendering**: Widgets cache state, reducing IIO traffic by 85%+
|
||||
- **10 Widget Types**: Buttons, Labels, Panels, Checkboxes, Sliders, Text Inputs, Progress Bars, Images, Scroll Panels, Tooltips
|
||||
- **Flexible Layout**: JSON-based UI definition with hierarchical widget trees
|
||||
- **Automatic Input**: Consumes `input:*` topics from InputModule automatically
|
||||
- **Retained Mode Rendering**: Widgets cache render state and only publish IIO messages when visual properties change, reducing message traffic for static UIs
|
||||
- **Layer Management**: UI renders on top of game content (layer 1000+)
|
||||
- **Hot-Reload Support**: Full state preservation across module reloads
|
||||
- **Thread-Safe**: Designed for multi-threaded production architecture
|
||||
|
||||
## Quick Start
|
||||
## Architecture
|
||||
|
||||
```
|
||||
InputModule → IIO (input:mouse:*, input:keyboard:*)
|
||||
↓
|
||||
UIModule
|
||||
(Widget Tree)
|
||||
↓
|
||||
UIRenderer (publishes)
|
||||
↓
|
||||
IIO (render:sprite, render:text)
|
||||
↓
|
||||
BgfxRenderer
|
||||
```
|
||||
|
||||
## Available Widgets
|
||||
|
||||
| Widget | Purpose | Events Published |
|
||||
|--------|---------|------------------|
|
||||
| **UIButton** | Clickable button | `ui:click`, `ui:action` |
|
||||
| **UILabel** | Static text display | - |
|
||||
| **UIPanel** | Container widget | - |
|
||||
| **UICheckbox** | Toggle checkbox | `ui:value_changed` |
|
||||
| **UISlider** | Value slider (horizontal/vertical) | `ui:value_changed` |
|
||||
| **UITextInput** | Text input field | `ui:value_changed`, `ui:text_submitted` |
|
||||
| **UIProgressBar** | Progress indicator | - |
|
||||
| **UIImage** | Sprite/image display | - |
|
||||
| **UIScrollPanel** | Scrollable container | `ui:scroll` |
|
||||
| **UITooltip** | Hover tooltip | - |
|
||||
|
||||
## Configuration
|
||||
|
||||
```cpp
|
||||
JsonDataNode config("config");
|
||||
config.setInt("windowWidth", 1920);
|
||||
config.setInt("windowHeight", 1080);
|
||||
config.setString("layoutFile", "./assets/ui/main_menu.json");
|
||||
config.setInt("baseLayer", 1000); // UI renders above game content
|
||||
|
||||
uiModule->setConfiguration(config, uiIO.get(), nullptr);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Loading UIModule
|
||||
|
||||
```cpp
|
||||
#include <grove/ModuleLoader.h>
|
||||
#include <grove/IntraIOManager.h>
|
||||
|
||||
// Create IIO instances
|
||||
auto& ioManager = IntraIOManager::getInstance();
|
||||
auto uiIO = ioManager.createInstance("ui_module");
|
||||
auto gameIO = ioManager.createInstance("game");
|
||||
auto gameIO = ioManager.createInstance("game_logic");
|
||||
|
||||
// Load UIModule
|
||||
ModuleLoader uiLoader;
|
||||
auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui_module");
|
||||
|
||||
// Configure
|
||||
JsonDataNode config("config");
|
||||
config.setInt("windowWidth", 1920);
|
||||
config.setInt("windowHeight", 1080);
|
||||
config.setString("layoutFile", "./ui/menu.json");
|
||||
config.setInt("baseLayer", 1000);
|
||||
uiModule->setConfiguration(config, uiIO.get(), nullptr);
|
||||
|
||||
// Subscribe to UI events
|
||||
gameIO->subscribe("ui:action");
|
||||
gameIO->subscribe("ui:value_changed");
|
||||
|
||||
// Game loop
|
||||
while(running) {
|
||||
// Handle UI events
|
||||
while (gameIO->hasMessages() > 0) {
|
||||
auto msg = gameIO->pullMessage();
|
||||
if (msg.topic == "ui:action") {
|
||||
std::string action = msg.data->getString("action", "");
|
||||
handleAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
uiModule->process(deltaTime);
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
### Creating UI Layout (JSON)
|
||||
|
||||
`ui/menu.json`:
|
||||
```json
|
||||
{
|
||||
"widgets": [
|
||||
{
|
||||
"type": "panel",
|
||||
"type": "UIPanel",
|
||||
"id": "background",
|
||||
"x": 0, "y": 0,
|
||||
"width": 1920, "height": 1080,
|
||||
"style": {"bgColor": "0x2d3436FF"}
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"color": 2155905279
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"type": "UIButton",
|
||||
"id": "play_button",
|
||||
"x": 860, "y": 500,
|
||||
"width": 200, "height": 60,
|
||||
"text": "Play Game",
|
||||
"onClick": "start_game",
|
||||
"style": {
|
||||
"normal": {"bgColor": "0x0984e3FF"},
|
||||
"hover": {"bgColor": "0x74b9ffFF"}
|
||||
}
|
||||
"x": 860,
|
||||
"y": 500,
|
||||
"width": 200,
|
||||
"height": 60,
|
||||
"text": "Play",
|
||||
"fontSize": 24,
|
||||
"action": "start_game"
|
||||
},
|
||||
{
|
||||
"type": "slider",
|
||||
"type": "UILabel",
|
||||
"id": "title",
|
||||
"x": 760,
|
||||
"y": 300,
|
||||
"width": 400,
|
||||
"height": 100,
|
||||
"text": "My Awesome Game",
|
||||
"fontSize": 48,
|
||||
"color": 4294967295
|
||||
},
|
||||
{
|
||||
"type": "UISlider",
|
||||
"id": "volume_slider",
|
||||
"x": 800, "y": 650,
|
||||
"width": 320, "height": 40,
|
||||
"min": 0.0, "max": 100.0, "value": 75.0
|
||||
"x": 800,
|
||||
"y": 650,
|
||||
"width": 320,
|
||||
"height": 40,
|
||||
"min": 0.0,
|
||||
"max": 100.0,
|
||||
"value": 75.0,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "UICheckbox",
|
||||
"id": "fullscreen_toggle",
|
||||
"x": 800,
|
||||
"y": 720,
|
||||
"width": 30,
|
||||
"height": 30,
|
||||
"checked": false
|
||||
},
|
||||
{
|
||||
"type": "UIScrollPanel",
|
||||
"id": "settings_panel",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 400,
|
||||
"height": 600,
|
||||
"contentHeight": 1200,
|
||||
"scrollbarWidth": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
See [Widget Reference](../../docs/UI_WIDGETS.md) for all widget properties.
|
||||
### Handling UI Events
|
||||
|
||||
## Building
|
||||
```cpp
|
||||
// Subscribe to UI events in your game module
|
||||
gameIO->subscribe("ui:click");
|
||||
gameIO->subscribe("ui:action");
|
||||
gameIO->subscribe("ui:value_changed");
|
||||
|
||||
```bash
|
||||
cmake -DGROVE_BUILD_UI_MODULE=ON -B build
|
||||
cmake --build build -j4
|
||||
// In your game module's process()
|
||||
void GameModule::process(const IDataNode& input) {
|
||||
while (m_io->hasMessages() > 0) {
|
||||
auto msg = m_io->pullMessage();
|
||||
|
||||
if (msg.topic == "ui:action") {
|
||||
std::string action = msg.data->getString("action", "");
|
||||
std::string widgetId = msg.data->getString("widgetId", "");
|
||||
|
||||
if (action == "start_game") {
|
||||
startGame();
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.topic == "ui:value_changed") {
|
||||
std::string widgetId = msg.data->getString("widgetId", "");
|
||||
|
||||
if (widgetId == "volume_slider") {
|
||||
double value = msg.data->getDouble("value", 50.0);
|
||||
setVolume(value);
|
||||
}
|
||||
|
||||
if (widgetId == "fullscreen_toggle") {
|
||||
bool checked = msg.data->getBool("value", false);
|
||||
setFullscreen(checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## IIO Topics
|
||||
|
||||
### Topics Consumed (from InputModule)
|
||||
|
||||
| Topic | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `input:mouse:move` | `{x, y}` | Mouse position |
|
||||
| `input:mouse:button` | `{button, pressed, x, y}` | Mouse click |
|
||||
| `input:mouse:wheel` | `{delta}` | Mouse wheel |
|
||||
| `input:keyboard:key` | `{scancode, pressed, ...}` | Key event |
|
||||
| `input:keyboard:text` | `{text}` | Text input (for UITextInput) |
|
||||
|
||||
### Topics Published (UI Events)
|
||||
|
||||
| Topic | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `ui:click` | `{widgetId, x, y}` | Widget clicked |
|
||||
| `ui:action` | `{widgetId, action}` | Button action triggered |
|
||||
| `ui:value_changed` | `{widgetId, value}` | Slider/checkbox/input changed |
|
||||
| `ui:text_submitted` | `{widgetId, text}` | Text input submitted (Enter) |
|
||||
| `ui:hover` | `{widgetId, enter}` | Mouse entered/left widget |
|
||||
| `ui:scroll` | `{widgetId, scrollX, scrollY}` | Scroll panel scrolled |
|
||||
|
||||
### Topics Published (Rendering)
|
||||
|
||||
**Retained Mode (current):**
|
||||
|
||||
| Topic | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `render:sprite:add` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Register new sprite |
|
||||
| `render:sprite:update` | `{renderId, x, y, scaleX, scaleY, color, textureId, layer}` | Update existing sprite |
|
||||
| `render:sprite:remove` | `{renderId}` | Unregister sprite |
|
||||
| `render:text:add` | `{renderId, x, y, text, fontSize, color, layer}` | Register new text |
|
||||
| `render:text:update` | `{renderId, x, y, text, fontSize, color, layer}` | Update existing text |
|
||||
| `render:text:remove` | `{renderId}` | Unregister text |
|
||||
|
||||
**Immediate Mode (legacy, still supported):**
|
||||
|
||||
| Topic | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `render:sprite` | `{x, y, w, h, color, layer, ...}` | Ephemeral sprite (1 frame) |
|
||||
| `render:text` | `{x, y, text, fontSize, color, layer}` | Ephemeral text (1 frame) |
|
||||
|
||||
See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for details on retained mode rendering.
|
||||
|
||||
## Widget Properties Reference
|
||||
|
||||
### UIButton
|
||||
```json
|
||||
{
|
||||
"type": "UIButton",
|
||||
"id": "my_button",
|
||||
"x": 100, "y": 100,
|
||||
"width": 200, "height": 50,
|
||||
"text": "Click Me",
|
||||
"fontSize": 24,
|
||||
"textColor": 4294967295,
|
||||
"bgColor": 3435973836,
|
||||
"hoverColor": 4286611711,
|
||||
"action": "button_clicked"
|
||||
}
|
||||
```
|
||||
|
||||
### UILabel
|
||||
```json
|
||||
{
|
||||
"type": "UILabel",
|
||||
"id": "my_label",
|
||||
"x": 100, "y": 100,
|
||||
"width": 300, "height": 50,
|
||||
"text": "Hello World",
|
||||
"fontSize": 32,
|
||||
"color": 4294967295
|
||||
}
|
||||
```
|
||||
|
||||
### UIPanel
|
||||
```json
|
||||
{
|
||||
"type": "UIPanel",
|
||||
"id": "my_panel",
|
||||
"x": 0, "y": 0,
|
||||
"width": 400, "height": 300,
|
||||
"color": 2155905279
|
||||
}
|
||||
```
|
||||
|
||||
### UISlider
|
||||
```json
|
||||
{
|
||||
"type": "UISlider",
|
||||
"id": "volume",
|
||||
"x": 100, "y": 100,
|
||||
"width": 300, "height": 30,
|
||||
"min": 0.0,
|
||||
"max": 100.0,
|
||||
"value": 50.0,
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
```
|
||||
|
||||
### UICheckbox
|
||||
```json
|
||||
{
|
||||
"type": "UICheckbox",
|
||||
"id": "enable_vsync",
|
||||
"x": 100, "y": 100,
|
||||
"width": 30, "height": 30,
|
||||
"checked": true
|
||||
}
|
||||
```
|
||||
|
||||
### UITextInput
|
||||
```json
|
||||
{
|
||||
"type": "UITextInput",
|
||||
"id": "player_name",
|
||||
"x": 100, "y": 100,
|
||||
"width": 300, "height": 40,
|
||||
"text": "",
|
||||
"placeholder": "Enter name...",
|
||||
"fontSize": 20,
|
||||
"maxLength": 32
|
||||
}
|
||||
```
|
||||
|
||||
### UIProgressBar
|
||||
```json
|
||||
{
|
||||
"type": "UIProgressBar",
|
||||
"id": "loading",
|
||||
"x": 100, "y": 100,
|
||||
"width": 400, "height": 30,
|
||||
"value": 0.65,
|
||||
"bgColor": 2155905279,
|
||||
"fillColor": 4278255360
|
||||
}
|
||||
```
|
||||
|
||||
### UIImage
|
||||
```json
|
||||
{
|
||||
"type": "UIImage",
|
||||
"id": "logo",
|
||||
"x": 100, "y": 100,
|
||||
"width": 200, "height": 200,
|
||||
"textureId": 5
|
||||
}
|
||||
```
|
||||
|
||||
### UIScrollPanel
|
||||
```json
|
||||
{
|
||||
"type": "UIScrollPanel",
|
||||
"id": "inventory",
|
||||
"x": 100, "y": 100,
|
||||
"width": 400, "height": 600,
|
||||
"contentHeight": 1200,
|
||||
"scrollY": 0.0,
|
||||
"scrollbarWidth": 20,
|
||||
"bgColor": 2155905279
|
||||
}
|
||||
```
|
||||
|
||||
### UITooltip
|
||||
```json
|
||||
{
|
||||
"type": "UITooltip",
|
||||
"id": "help_tooltip",
|
||||
"x": 100, "y": 100,
|
||||
"width": 200, "height": 80,
|
||||
"text": "This is a helpful tooltip",
|
||||
"fontSize": 16,
|
||||
"visible": false
|
||||
}
|
||||
```
|
||||
|
||||
## Layer Management
|
||||
|
||||
UIModule uses **layer-based rendering** to ensure UI elements render correctly:
|
||||
|
||||
- **Game sprites**: Layer 0-999
|
||||
- **UI elements**: Layer 1000+ (default baseLayer)
|
||||
- **Tooltips**: Automatically use highest layer
|
||||
|
||||
Configure base layer in UIModule configuration:
|
||||
```cpp
|
||||
config.setInt("baseLayer", 1000);
|
||||
```
|
||||
|
||||
## Hot-Reload Support
|
||||
|
||||
UIModule fully supports hot-reload with state preservation:
|
||||
|
||||
### State Preserved
|
||||
- All widget properties (position, size, colors)
|
||||
- Widget states (button hover, slider values, checkbox checked)
|
||||
- Scroll positions
|
||||
- Text input content
|
||||
|
||||
### State Not Preserved
|
||||
- Transient animation states
|
||||
- Mouse hover states (recalculated on next mouse move)
|
||||
|
||||
## Rendering Modes
|
||||
|
||||
UIModule uses **retained mode rendering** to optimize IIO message traffic. Widgets register render entries once and only publish updates when visual properties change.
|
||||
|
||||
### Retained Mode
|
||||
|
||||
Widgets cache their render state and compare against previous values each frame. Only changed properties trigger IIO messages.
|
||||
|
||||
**Message Reduction:**
|
||||
- Static UI (20 widgets, 0 changes/frame): 100% reduction (0 messages after initial registration)
|
||||
- Mostly static UI (20 widgets, 3 changes/frame): 85% reduction (3 messages vs 20)
|
||||
- Fully dynamic UI (20 widgets, 20 changes/frame): 0% reduction (retained mode has comparison overhead)
|
||||
|
||||
**Topics:** `render:sprite:add/update/remove`, `render:text:add/update/remove`
|
||||
|
||||
### Immediate Mode (Legacy)
|
||||
|
||||
Widgets publish render commands every frame regardless of changes. Still supported for compatibility and ephemeral content (debug overlays, particles).
|
||||
|
||||
**Topics:** `render:sprite`, `render:text`
|
||||
|
||||
See [UI Rendering Documentation](../../docs/UI_RENDERING.md) for implementation details and migration guide.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Target**: < 1ms per frame for UI updates
|
||||
- **Retained mode**: Reduces IIO traffic by 85%+ for typical UIs (static menus, HUDs)
|
||||
- **Event filtering**: Only processes mouse events within widget bounds
|
||||
- **Layout caching**: Widget tree built once from JSON, not every frame
|
||||
|
||||
## Testing
|
||||
|
||||
### Visual Test
|
||||
```bash
|
||||
# Visual showcase (run from project root for correct asset paths)
|
||||
./build/tests/test_ui_showcase
|
||||
cmake -DGROVE_BUILD_UI_MODULE=ON -B build
|
||||
cmake --build build --target test_ui_widgets
|
||||
./build/tests/test_ui_widgets
|
||||
```
|
||||
|
||||
# Integration test
|
||||
### Integration Test (with InputModule + BgfxRenderer)
|
||||
```bash
|
||||
cmake -DGROVE_BUILD_BGFX_RENDERER=ON -DGROVE_BUILD_UI_MODULE=ON -DGROVE_BUILD_INPUT_MODULE=ON -B build
|
||||
cmake --build build
|
||||
cd build && ctest -R IT_014 --output-on-failure
|
||||
```
|
||||
|
||||
@ -120,30 +442,29 @@ cd build && ctest -R IT_014 --output-on-failure
|
||||
|
||||
- **GroveEngine Core**: IModule, IIO, IDataNode
|
||||
- **BgfxRenderer**: For rendering (via IIO, not direct dependency)
|
||||
- **InputModule**: For input (via IIO, not direct dependency)
|
||||
- **InputModule**: For input handling (via IIO, not direct dependency)
|
||||
- **nlohmann/json**: JSON parsing
|
||||
- **spdlog**: Logging
|
||||
|
||||
## 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
|
||||
## Files
|
||||
|
||||
```
|
||||
modules/UIModule/
|
||||
├── README.md # This file
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── UIModule.h/.cpp # Main module
|
||||
├── UIModule.h # Main module
|
||||
├── UIModule.cpp
|
||||
├── Core/
|
||||
│ ├── UIContext.h/.cpp # Global UI state
|
||||
│ ├── UILayout.h/.cpp # Layout management
|
||||
│ ├── UITooltip.h/.cpp # Tooltip system
|
||||
│ ├── UITree.h/.cpp # Widget hierarchy
|
||||
│ ├── UIContext.h # Global UI state
|
||||
│ ├── UIContext.cpp
|
||||
│ ├── UILayout.h # Layout management
|
||||
│ ├── UILayout.cpp
|
||||
│ ├── UIStyle.h # Widget styling
|
||||
│ ├── UIStyle.cpp
|
||||
│ ├── UITooltip.h # Tooltip system
|
||||
│ ├── UITooltip.cpp
|
||||
│ ├── UITree.h # Widget hierarchy
|
||||
│ ├── UITree.cpp
|
||||
│ └── UIWidget.h # Base widget interface
|
||||
├── Widgets/
|
||||
│ ├── UIButton.h/.cpp
|
||||
@ -156,7 +477,45 @@ modules/UIModule/
|
||||
│ ├── UIImage.h/.cpp
|
||||
│ └── UIScrollPanel.h/.cpp
|
||||
└── Rendering/
|
||||
├── UIRenderer.h/.cpp # Publishes render commands
|
||||
├── UIRenderer.h # Publishes render commands
|
||||
└── UIRenderer.cpp
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
- ✅ **Phase 1**: Core widgets (Button, Label, Panel)
|
||||
- ✅ **Phase 2**: Input widgets (Checkbox, Slider, TextInput)
|
||||
- ✅ **Phase 3**: Advanced widgets (ProgressBar, Image)
|
||||
- ✅ **Phase 4-5**: Layout system and styling
|
||||
- ✅ **Phase 6**: Interactive demo
|
||||
- ✅ **Phase 7**: ScrollPanel + Tooltips
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding a Custom Widget
|
||||
|
||||
1. Create `Widgets/MyCustomWidget.h/.cpp`
|
||||
2. Inherit from `UIWidget` base class
|
||||
3. Implement `render()`, `handleInput()`, and event handlers
|
||||
4. Add to `UILayout::createWidget()` factory
|
||||
5. Use in JSON layouts with `"type": "MyCustomWidget"`
|
||||
|
||||
Example:
|
||||
```cpp
|
||||
class MyCustomWidget : public UIWidget {
|
||||
public:
|
||||
void render(UIRenderer& renderer) override {
|
||||
// Publish render commands via renderer
|
||||
renderer.drawRect(m_x, m_y, m_width, m_height, m_color);
|
||||
}
|
||||
|
||||
void onMouseDown(int button, double x, double y) override {
|
||||
// Handle click
|
||||
auto event = std::make_unique<JsonDataNode>("event");
|
||||
event->setString("widgetId", m_id);
|
||||
m_io->publish("ui:custom_event", std::move(event));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@ -155,20 +155,12 @@ 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) {
|
||||
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");
|
||||
sprite->setInt("renderId", static_cast<int>(renderId));
|
||||
sprite->setDouble("x", static_cast<double>(x + w * 0.5f));
|
||||
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
||||
sprite->setDouble("scaleX", static_cast<double>(w));
|
||||
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("textureId", textureId);
|
||||
sprite->setInt("layer", layer);
|
||||
@ -182,11 +174,6 @@ void UIRenderer::publishSpriteUpdate(uint32_t renderId, float x, float y, float
|
||||
sprite->setDouble("y", static_cast<double>(y + h * 0.5f));
|
||||
sprite->setDouble("scaleX", static_cast<double>(w));
|
||||
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("textureId", textureId);
|
||||
sprite->setInt("layer", layer);
|
||||
|
||||
@ -9,12 +9,10 @@
|
||||
#include "Widgets/UICheckbox.h"
|
||||
#include "Widgets/UITextInput.h"
|
||||
#include "Widgets/UIScrollPanel.h"
|
||||
#include "Widgets/UILabel.h"
|
||||
|
||||
#include <grove/JsonDataNode.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <spdlog/sinks/stdout_color_sinks.h>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
@ -82,7 +80,6 @@ void UIModule::setConfiguration(const IDataNode& config, IIO* io, ITaskScheduler
|
||||
m_io->subscribe("ui:load"); // Load new layout
|
||||
m_io->subscribe("ui:set_value"); // Set widget value
|
||||
m_io->subscribe("ui:set_visible"); // Show/hide widget
|
||||
m_io->subscribe("ui:set_text"); // Set widget text (for labels)
|
||||
}
|
||||
|
||||
m_logger->info("UIModule initialized");
|
||||
@ -150,36 +147,6 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,9 +208,6 @@ void UIModule::updateUI(float deltaTime) {
|
||||
// Publish type-specific events
|
||||
std::string widgetType = clickedWidget->getType();
|
||||
|
||||
m_logger->info("🖱️ Widget clicked: id='{}', type='{}', mousePressed={}",
|
||||
clickedWidget->id, widgetType, m_context->mousePressed);
|
||||
|
||||
// Handle focus for text inputs
|
||||
if (widgetType == "textinput" && m_context->mousePressed) {
|
||||
UITextInput* textInput = static_cast<UITextInput*>(clickedWidget);
|
||||
@ -290,13 +254,6 @@ void UIModule::updateUI(float deltaTime) {
|
||||
valueEvent->setDouble("value", slider->getValue());
|
||||
valueEvent->setDouble("min", slider->minValue);
|
||||
valueEvent->setDouble("max", slider->maxValue);
|
||||
|
||||
// Add timestamp for latency measurement
|
||||
auto now = std::chrono::high_resolution_clock::now();
|
||||
auto micros = std::chrono::duration_cast<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));
|
||||
|
||||
// Publish onChange action if specified
|
||||
|
||||
@ -44,21 +44,11 @@ void UIButton::render(UIRenderer& renderer) {
|
||||
|
||||
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
|
||||
int bgLayer = renderer.nextLayer();
|
||||
|
||||
// Render background (texture or solid color)
|
||||
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);
|
||||
} else {
|
||||
renderer.updateRect(m_renderId, absX, absY, width, height, style.bgColor, bgLayer);
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
#include "../Rendering/UIRenderer.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace grove {
|
||||
|
||||
@ -64,14 +63,6 @@ void UITextInput::render(UIRenderer& renderer) {
|
||||
// Render border
|
||||
int borderLayer = renderer.nextLayer();
|
||||
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,
|
||||
width, style.borderWidth, borderColor, borderLayer);
|
||||
|
||||
@ -208,7 +199,6 @@ void UITextInput::gainFocus() {
|
||||
isFocused = true;
|
||||
cursorBlinkTimer = 0.0f;
|
||||
cursorVisible = true;
|
||||
spdlog::info("🎯 UITextInput '{}' gainFocus() called - isFocused={}", id, isFocused);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,10 +13,10 @@ IntraIOManager::IntraIOManager() {
|
||||
logger = stillhammer::createDomainLogger("IntraIOManager", "io", config);
|
||||
logger->info("🌐🔗 IntraIOManager created - Central message router initialized");
|
||||
|
||||
// Start batch flush thread for low-latency message delivery
|
||||
batchThreadRunning = true;
|
||||
batchThread = std::thread(&IntraIOManager::batchFlushLoop, this);
|
||||
logger->info("✅ Batch flush thread started for push-based message delivery");
|
||||
// TEMPORARY: Disable batch thread to debug Windows crash
|
||||
batchThreadRunning = false;
|
||||
// batchThread = std::thread(&IntraIOManager::batchFlushLoop, this);
|
||||
logger->info("⚠️ Batch flush thread DISABLED (debugging Windows crash)");
|
||||
}
|
||||
|
||||
IntraIOManager::~IntraIOManager() {
|
||||
|
||||
@ -1451,180 +1451,3 @@ if(WIN32 AND GROVE_BUILD_BGFX_RENDERER AND GROVE_BUILD_UI_MODULE AND SDL2_AVAILA
|
||||
)
|
||||
message(STATUS "Single button test 'test_single_button' enabled")
|
||||
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()
|
||||
|
||||
@ -1,211 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -25,7 +25,6 @@
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
|
||||
#include "BgfxRendererModule.h"
|
||||
#include "UIModule.h"
|
||||
@ -38,20 +37,20 @@
|
||||
|
||||
using namespace grove;
|
||||
|
||||
// Create the UI layout JSON - MINIMAL VERSION WITH ONLY TEXTURED BUTTONS
|
||||
// Create the UI layout JSON - must be a single root widget with children
|
||||
static nlohmann::json createUILayout() {
|
||||
nlohmann::json root;
|
||||
|
||||
// Root panel (TRANSPARENT like test_button_with_png!)
|
||||
// Root panel (full screen background)
|
||||
root["type"] = "panel";
|
||||
root["id"] = "root";
|
||||
root["x"] = 0;
|
||||
root["y"] = 0;
|
||||
root["width"] = 1024;
|
||||
root["height"] = 768;
|
||||
root["style"] = {{"bgColor", "0x00000000"}}; // TRANSPARENT!
|
||||
root["style"] = {{"bgColor", "0x1a1a2eFF"}};
|
||||
|
||||
// Children array - ONLY TEXTURED BUTTONS
|
||||
// Children array (like test_single_button)
|
||||
nlohmann::json children = nlohmann::json::array();
|
||||
|
||||
// Title label
|
||||
@ -152,78 +151,6 @@ 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 ===
|
||||
children.push_back({
|
||||
{"type", "panel"},
|
||||
@ -424,7 +351,7 @@ public:
|
||||
m_inputIO = m_inputIOPtr.get();
|
||||
m_gameIO = m_gameIOPtr.get();
|
||||
|
||||
// Create and configure BgfxRenderer with textures
|
||||
// Create and configure BgfxRenderer
|
||||
m_renderer = std::make_unique<BgfxRendererModule>();
|
||||
{
|
||||
JsonDataNode config("config");
|
||||
@ -432,15 +359,10 @@ public:
|
||||
static_cast<double>(reinterpret_cast<uintptr_t>(wmi.info.win.window)));
|
||||
config.setInt("windowWidth", 1024);
|
||||
config.setInt("windowHeight", 768);
|
||||
// config.setString("backend", "d3d11"); // LET BGFX CHOOSE LIKE test_button_with_png!
|
||||
config.setString("backend", "d3d11");
|
||||
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_logger->info("✓ Loaded 3 textures for sprite buttons (IDs: 1, 2, 3)");
|
||||
|
||||
// Create and configure UIModule with inline layout
|
||||
m_uiModule = std::make_unique<UIModule>();
|
||||
@ -495,24 +417,14 @@ public:
|
||||
m_gameIO->publish("input:mouse:wheel", std::move(msg));
|
||||
}
|
||||
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");
|
||||
msg->setInt("keyCode", keyCode);
|
||||
msg->setBool("pressed", true);
|
||||
msg->setInt("char", 0);
|
||||
m_gameIO->publish("input:keyboard", std::move(msg));
|
||||
}
|
||||
auto msg = std::make_unique<JsonDataNode>("key");
|
||||
msg->setInt("keyCode", e.key.keysym.sym);
|
||||
msg->setInt("scancode", e.key.keysym.scancode);
|
||||
msg->setBool("pressed", true);
|
||||
msg->setInt("char", 0);
|
||||
m_gameIO->publish("input:keyboard", std::move(msg));
|
||||
}
|
||||
else if (e.type == SDL_TEXTINPUT) {
|
||||
// Printable characters come here
|
||||
auto msg = std::make_unique<JsonDataNode>("key");
|
||||
msg->setInt("keyCode", 0);
|
||||
msg->setBool("pressed", true);
|
||||
@ -588,15 +500,6 @@ private:
|
||||
else if (action == "action_danger") {
|
||||
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") {
|
||||
std::string widgetId = msg.data->getString("widgetId", "");
|
||||
@ -607,38 +510,10 @@ private:
|
||||
std::to_string(static_cast<int>(y)) + ")";
|
||||
}
|
||||
else if (msg.topic == "ui:value_changed") {
|
||||
// Timestamp on receive
|
||||
auto now = std::chrono::high_resolution_clock::now();
|
||||
auto micros = std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()).count();
|
||||
|
||||
std::string widgetId = msg.data->getString("widgetId", "");
|
||||
if (widgetId == "volume_slider") {
|
||||
double value = msg.data->getDouble("value", 0);
|
||||
logEntry = "Volume: " + std::to_string(static_cast<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) {
|
||||
bool checked = msg.data->getBool("checked", false);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user