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>
384 lines
13 KiB
C++
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
|