GroveEngine/modules/UIModule/Core/UILayout.cpp
StillHammer 579cadeae8 feat: Complete UIModule Phase 7 - ScrollPanel & Tooltips
This commit implements Phase 7 of the UIModule, adding advanced features
that make the UI system production-ready.

## Phase 7.1 - UIScrollPanel

New scrollable container widget with:
- Vertical and horizontal scrolling (configurable)
- Mouse wheel support with smooth scrolling
- Drag-to-scroll functionality (drag content or scrollbar)
- Interactive scrollbar with proportional thumb
- Automatic content size calculation
- Visibility culling for performance
- Full styling support (colors, borders, scrollbar)

Files added:
- modules/UIModule/Widgets/UIScrollPanel.h
- modules/UIModule/Widgets/UIScrollPanel.cpp
- modules/UIModule/Core/UIContext.h (added mouseWheelDelta)
- modules/UIModule/UIModule.cpp (mouse wheel event routing)

## Phase 7.2 - Tooltips

Smart tooltip system with:
- Hover delay (500ms default)
- Automatic positioning with edge avoidance
- Semi-transparent background with border
- Per-widget tooltip text via JSON
- Tooltip property on all UIWidget types
- Renders on top of all UI elements

Files added:
- modules/UIModule/Core/UITooltip.h
- modules/UIModule/Core/UITooltip.cpp
- modules/UIModule/Core/UIWidget.h (added tooltip property)
- modules/UIModule/Core/UITree.cpp (tooltip parsing)

## Tests

Added comprehensive visual tests:
- test_28_ui_scroll.cpp - ScrollPanel with 35+ items
- test_29_ui_advanced.cpp - Tooltips on various widgets
- assets/ui/test_scroll.json - ScrollPanel layout
- assets/ui/test_tooltips.json - Tooltips layout

## Documentation

- docs/UI_MODULE_PHASE7_COMPLETE.md - Complete Phase 7 docs
- docs/PROMPT_UI_MODULE_PHASE6.md - Phase 6 & 7 prompt
- Updated CMakeLists.txt for new files and tests

## UIModule Status

UIModule is now feature-complete with:
 9 widget types (Panel, Label, Button, Image, Slider, Checkbox,
   ProgressBar, TextInput, ScrollPanel)
 Flexible layout system (vertical, horizontal, stack, absolute)
 Theme and style system
 Complete event system
 Tooltips with smart positioning
 Hot-reload support
 Comprehensive tests (Phases 1-7)

🚀 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 07:13:13 +08:00

384 lines
13 KiB
C++

#include "UILayout.h"
#include "UIWidget.h"
#include <algorithm>
#include <cmath>
namespace grove {
// =============================================================================
// Measurement (Bottom-Up)
// =============================================================================
LayoutMeasurement UILayout::measure(UIWidget* widget) {
if (!widget) {
return {0.0f, 0.0f};
}
LayoutMeasurement result;
// Choose measurement algorithm based on layout mode
switch (widget->layoutProps.mode) {
case LayoutMode::Vertical:
result = measureVertical(widget);
break;
case LayoutMode::Horizontal:
result = measureHorizontal(widget);
break;
case LayoutMode::Stack:
result = measureStack(widget);
break;
case LayoutMode::Absolute:
default:
// For absolute layout, use explicit size or measure children
result.preferredWidth = widget->width;
result.preferredHeight = widget->height;
// If size is 0, measure children and use their bounds
if (result.preferredWidth == 0.0f || result.preferredHeight == 0.0f) {
float maxX = 0.0f, maxY = 0.0f;
for (auto& child : widget->children) {
if (child->visible) {
auto childMeasure = measure(child.get());
maxX = std::max(maxX, child->x + childMeasure.preferredWidth);
maxY = std::max(maxY, child->y + childMeasure.preferredHeight);
}
}
if (result.preferredWidth == 0.0f) result.preferredWidth = maxX;
if (result.preferredHeight == 0.0f) result.preferredHeight = maxY;
}
break;
}
// Add padding
result.preferredWidth += widget->layoutProps.getTotalPaddingX();
result.preferredHeight += widget->layoutProps.getTotalPaddingY();
// Apply min/max constraints
result.preferredWidth = clampSize(result.preferredWidth,
widget->layoutProps.minWidth,
widget->layoutProps.maxWidth);
result.preferredHeight = clampSize(result.preferredHeight,
widget->layoutProps.minHeight,
widget->layoutProps.maxHeight);
// If explicit size is set, use it
if (widget->width > 0) result.preferredWidth = widget->width;
if (widget->height > 0) result.preferredHeight = widget->height;
return result;
}
LayoutMeasurement UILayout::measureVertical(UIWidget* widget) {
LayoutMeasurement result{0.0f, 0.0f};
bool hasVisibleChild = false;
for (auto& child : widget->children) {
if (!child->visible) continue;
auto childMeasure = measure(child.get());
result.preferredWidth = std::max(result.preferredWidth, childMeasure.preferredWidth);
result.preferredHeight += childMeasure.preferredHeight;
if (hasVisibleChild) {
result.preferredHeight += widget->layoutProps.spacing;
}
hasVisibleChild = true;
}
return result;
}
LayoutMeasurement UILayout::measureHorizontal(UIWidget* widget) {
LayoutMeasurement result{0.0f, 0.0f};
bool hasVisibleChild = false;
for (auto& child : widget->children) {
if (!child->visible) continue;
auto childMeasure = measure(child.get());
result.preferredWidth += childMeasure.preferredWidth;
result.preferredHeight = std::max(result.preferredHeight, childMeasure.preferredHeight);
if (hasVisibleChild) {
result.preferredWidth += widget->layoutProps.spacing;
}
hasVisibleChild = true;
}
return result;
}
LayoutMeasurement UILayout::measureStack(UIWidget* widget) {
LayoutMeasurement result{0.0f, 0.0f};
for (auto& child : widget->children) {
if (!child->visible) continue;
auto childMeasure = measure(child.get());
result.preferredWidth = std::max(result.preferredWidth, childMeasure.preferredWidth);
result.preferredHeight = std::max(result.preferredHeight, childMeasure.preferredHeight);
}
return result;
}
// =============================================================================
// Layout (Top-Down)
// =============================================================================
void UILayout::layout(UIWidget* widget, float availableWidth, float availableHeight) {
if (!widget) return;
// Apply size constraints
widget->width = clampSize(availableWidth, widget->layoutProps.minWidth, widget->layoutProps.maxWidth);
widget->height = clampSize(availableHeight, widget->layoutProps.minHeight, widget->layoutProps.maxHeight);
// Calculate content area (available space minus padding)
float contentWidth = widget->width - widget->layoutProps.getTotalPaddingX();
float contentHeight = widget->height - widget->layoutProps.getTotalPaddingY();
// Layout children based on mode
switch (widget->layoutProps.mode) {
case LayoutMode::Vertical:
layoutVertical(widget, contentWidth, contentHeight);
break;
case LayoutMode::Horizontal:
layoutHorizontal(widget, contentWidth, contentHeight);
break;
case LayoutMode::Stack:
layoutStack(widget, contentWidth, contentHeight);
break;
case LayoutMode::Absolute:
default:
// For absolute layout, just layout children with their preferred sizes
for (auto& child : widget->children) {
if (!child->visible) continue;
auto childMeasure = measure(child.get());
layout(child.get(), childMeasure.preferredWidth, childMeasure.preferredHeight);
}
break;
}
}
void UILayout::layoutVertical(UIWidget* widget, float availableWidth, float availableHeight) {
// Count visible children and calculate flex total
int visibleCount = 0;
float totalFlex = 0.0f;
float fixedHeight = 0.0f;
for (auto& child : widget->children) {
if (!child->visible) continue;
visibleCount++;
totalFlex += child->layoutProps.flex;
if (child->layoutProps.flex == 0.0f) {
auto childMeasure = measure(child.get());
fixedHeight += childMeasure.preferredHeight;
}
}
if (visibleCount == 0) return;
// Calculate spacing height
float totalSpacing = (visibleCount - 1) * widget->layoutProps.spacing;
float remainingHeight = availableHeight - fixedHeight - totalSpacing;
// First pass: assign sizes
std::vector<float> childHeights;
for (auto& child : widget->children) {
if (!child->visible) {
childHeights.push_back(0.0f);
continue;
}
float childHeight;
if (child->layoutProps.flex > 0.0f && totalFlex > 0.0f) {
childHeight = (child->layoutProps.flex / totalFlex) * remainingHeight;
} else {
auto childMeasure = measure(child.get());
childHeight = childMeasure.preferredHeight;
}
childHeights.push_back(childHeight);
}
// Second pass: position children
float offsetY = widget->layoutProps.getTopPadding();
for (size_t i = 0; i < widget->children.size(); i++) {
auto& child = widget->children[i];
if (!child->visible) continue;
float childHeight = childHeights[i];
float childWidth;
// Handle alignment
if (widget->layoutProps.align == Alignment::Stretch) {
childWidth = availableWidth;
} else {
auto childMeasure = measure(child.get());
childWidth = childMeasure.preferredWidth;
}
// Position based on alignment
float childX = widget->layoutProps.getLeftPadding();
switch (widget->layoutProps.align) {
case Alignment::Center:
childX += (availableWidth - childWidth) * 0.5f;
break;
case Alignment::End:
childX += availableWidth - childWidth;
break;
default:
break;
}
child->x = childX;
child->y = offsetY;
layout(child.get(), childWidth, childHeight);
offsetY += childHeight + widget->layoutProps.spacing;
}
}
void UILayout::layoutHorizontal(UIWidget* widget, float availableWidth, float availableHeight) {
// Count visible children and calculate flex total
int visibleCount = 0;
float totalFlex = 0.0f;
float fixedWidth = 0.0f;
for (auto& child : widget->children) {
if (!child->visible) continue;
visibleCount++;
totalFlex += child->layoutProps.flex;
if (child->layoutProps.flex == 0.0f) {
auto childMeasure = measure(child.get());
fixedWidth += childMeasure.preferredWidth;
}
}
if (visibleCount == 0) return;
// Calculate spacing width
float totalSpacing = (visibleCount - 1) * widget->layoutProps.spacing;
float remainingWidth = availableWidth - fixedWidth - totalSpacing;
// First pass: assign sizes
std::vector<float> childWidths;
for (auto& child : widget->children) {
if (!child->visible) {
childWidths.push_back(0.0f);
continue;
}
float childWidth;
if (child->layoutProps.flex > 0.0f && totalFlex > 0.0f) {
childWidth = (child->layoutProps.flex / totalFlex) * remainingWidth;
} else {
auto childMeasure = measure(child.get());
childWidth = childMeasure.preferredWidth;
}
childWidths.push_back(childWidth);
}
// Second pass: position children
float offsetX = widget->layoutProps.getLeftPadding();
for (size_t i = 0; i < widget->children.size(); i++) {
auto& child = widget->children[i];
if (!child->visible) continue;
float childWidth = childWidths[i];
float childHeight;
// Handle alignment
if (widget->layoutProps.align == Alignment::Stretch) {
childHeight = availableHeight;
} else {
auto childMeasure = measure(child.get());
childHeight = childMeasure.preferredHeight;
}
// Position based on alignment
float childY = widget->layoutProps.getTopPadding();
switch (widget->layoutProps.align) {
case Alignment::Center:
childY += (availableHeight - childHeight) * 0.5f;
break;
case Alignment::End:
childY += availableHeight - childHeight;
break;
default:
break;
}
child->x = offsetX;
child->y = childY;
layout(child.get(), childWidth, childHeight);
offsetX += childWidth + widget->layoutProps.spacing;
}
}
void UILayout::layoutStack(UIWidget* widget, float availableWidth, float availableHeight) {
float offsetX = widget->layoutProps.getLeftPadding();
float offsetY = widget->layoutProps.getTopPadding();
for (auto& child : widget->children) {
if (!child->visible) continue;
float childWidth, childHeight;
// Handle alignment
if (widget->layoutProps.align == Alignment::Stretch) {
childWidth = availableWidth;
childHeight = availableHeight;
} else {
auto childMeasure = measure(child.get());
childWidth = childMeasure.preferredWidth;
childHeight = childMeasure.preferredHeight;
}
// Position based on alignment
float childX = offsetX;
float childY = offsetY;
switch (widget->layoutProps.align) {
case Alignment::Center:
childX += (availableWidth - childWidth) * 0.5f;
childY += (availableHeight - childHeight) * 0.5f;
break;
case Alignment::End:
childX += availableWidth - childWidth;
childY += availableHeight - childHeight;
break;
default:
break;
}
child->x = childX;
child->y = childY;
layout(child.get(), childWidth, childHeight);
}
}
// =============================================================================
// Utilities
// =============================================================================
float UILayout::clampSize(float size, float minSize, float maxSize) {
if (minSize > 0.0f) {
size = std::max(size, minSize);
}
if (maxSize > 0.0f) {
size = std::min(size, maxSize);
}
return size;
}
} // namespace grove