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>
263 lines
10 KiB
C++
263 lines
10 KiB
C++
#include "UIStyle.h"
|
|
#include <grove/IDataNode.h>
|
|
#include <spdlog/spdlog.h>
|
|
|
|
namespace grove {
|
|
|
|
// Static member initialization
|
|
WidgetStyle UITheme::s_emptyStyle;
|
|
|
|
// =============================================================================
|
|
// WidgetStyle
|
|
// =============================================================================
|
|
|
|
void WidgetStyle::merge(const WidgetStyle& other) {
|
|
if (other.hasBgColor()) bgColor = other.bgColor;
|
|
if (other.hasTextColor()) textColor = other.textColor;
|
|
if (other.hasBorderColor()) borderColor = other.borderColor;
|
|
if (other.hasAccentColor()) accentColor = other.accentColor;
|
|
if (other.hasFontSize()) fontSize = other.fontSize;
|
|
if (other.hasPadding()) padding = other.padding;
|
|
if (other.hasMargin()) margin = other.margin;
|
|
if (other.hasBorderWidth()) borderWidth = other.borderWidth;
|
|
if (other.hasBorderRadius()) borderRadius = other.borderRadius;
|
|
if (other.handleSize >= 0.0f) handleSize = other.handleSize;
|
|
if (other.boxSize >= 0.0f) boxSize = other.boxSize;
|
|
if (other.spacing >= 0.0f) spacing = other.spacing;
|
|
}
|
|
|
|
void WidgetStyle::parseFromJson(const IDataNode& styleData) {
|
|
// Parse colors (hex strings)
|
|
auto parseColor = [](const IDataNode& node, const std::string& key) -> uint32_t {
|
|
std::string colorStr = node.getString(key, "");
|
|
if (colorStr.size() >= 2 && (colorStr.substr(0, 2) == "0x" || colorStr.substr(0, 2) == "0X")) {
|
|
return static_cast<uint32_t>(std::stoul(colorStr, nullptr, 16));
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
bgColor = parseColor(styleData, "bgColor");
|
|
textColor = parseColor(styleData, "textColor");
|
|
borderColor = parseColor(styleData, "borderColor");
|
|
accentColor = parseColor(styleData, "accentColor");
|
|
|
|
// Parse sizes
|
|
fontSize = static_cast<float>(styleData.getDouble("fontSize", -1.0));
|
|
padding = static_cast<float>(styleData.getDouble("padding", -1.0));
|
|
margin = static_cast<float>(styleData.getDouble("margin", -1.0));
|
|
borderWidth = static_cast<float>(styleData.getDouble("borderWidth", -1.0));
|
|
borderRadius = static_cast<float>(styleData.getDouble("borderRadius", -1.0));
|
|
handleSize = static_cast<float>(styleData.getDouble("handleSize", -1.0));
|
|
boxSize = static_cast<float>(styleData.getDouble("boxSize", -1.0));
|
|
spacing = static_cast<float>(styleData.getDouble("spacing", -1.0));
|
|
}
|
|
|
|
// =============================================================================
|
|
// UITheme
|
|
// =============================================================================
|
|
|
|
void UITheme::setColor(const std::string& name, uint32_t color) {
|
|
m_colors[name] = color;
|
|
}
|
|
|
|
uint32_t UITheme::getColor(const std::string& name) const {
|
|
auto it = m_colors.find(name);
|
|
return (it != m_colors.end()) ? it->second : 0;
|
|
}
|
|
|
|
uint32_t UITheme::resolveColor(const std::string& colorRef) const {
|
|
// Check if it's a color reference (starts with $)
|
|
if (!colorRef.empty() && colorRef[0] == '$') {
|
|
std::string colorName = colorRef.substr(1);
|
|
return getColor(colorName);
|
|
}
|
|
|
|
// Otherwise parse as hex color
|
|
if (colorRef.size() >= 2 && (colorRef.substr(0, 2) == "0x" || colorRef.substr(0, 2) == "0X")) {
|
|
return static_cast<uint32_t>(std::stoul(colorRef, nullptr, 16));
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
void UITheme::setWidgetStyle(const std::string& widgetType, const WidgetStyle& style) {
|
|
m_widgetStyles[widgetType] = style;
|
|
}
|
|
|
|
void UITheme::setWidgetVariantStyle(const std::string& widgetType, const std::string& variant, const WidgetStyle& style) {
|
|
m_variantStyles[makeVariantKey(widgetType, variant)] = style;
|
|
}
|
|
|
|
const WidgetStyle& UITheme::getWidgetStyle(const std::string& widgetType) const {
|
|
auto it = m_widgetStyles.find(widgetType);
|
|
return (it != m_widgetStyles.end()) ? it->second : s_emptyStyle;
|
|
}
|
|
|
|
const WidgetStyle& UITheme::getWidgetVariantStyle(const std::string& widgetType, const std::string& variant) const {
|
|
auto it = m_variantStyles.find(makeVariantKey(widgetType, variant));
|
|
return (it != m_variantStyles.end()) ? it->second : s_emptyStyle;
|
|
}
|
|
|
|
bool UITheme::loadFromJson(const IDataNode& themeData) {
|
|
m_name = themeData.getString("name", "unnamed");
|
|
|
|
// Load color palette
|
|
auto& mutableTheme = const_cast<IDataNode&>(themeData);
|
|
if (auto* colorsNode = mutableTheme.getChildReadOnly("colors")) {
|
|
auto colorNames = colorsNode->getChildNames();
|
|
for (const auto& colorName : colorNames) {
|
|
if (auto* colorNode = colorsNode->getChildReadOnly(colorName)) {
|
|
std::string colorStr = colorNode->getString("", "");
|
|
if (colorStr.empty()) {
|
|
// Try as direct value
|
|
colorStr = colorsNode->getString(colorName, "");
|
|
}
|
|
uint32_t color = resolveColor(colorStr);
|
|
if (color != 0) {
|
|
setColor(colorName, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load widget styles
|
|
auto widgetTypes = {"panel", "label", "button", "image", "slider", "checkbox", "progressbar"};
|
|
for (const auto& widgetType : widgetTypes) {
|
|
if (auto* widgetStyleNode = mutableTheme.getChildReadOnly(widgetType)) {
|
|
WidgetStyle style;
|
|
style.parseFromJson(*widgetStyleNode);
|
|
|
|
// Resolve color references
|
|
if (style.hasBgColor() == false) {
|
|
std::string bgColorRef = widgetStyleNode->getString("bgColor", "");
|
|
if (!bgColorRef.empty()) {
|
|
style.bgColor = resolveColor(bgColorRef);
|
|
}
|
|
}
|
|
if (style.hasTextColor() == false) {
|
|
std::string textColorRef = widgetStyleNode->getString("textColor", "");
|
|
if (!textColorRef.empty()) {
|
|
style.textColor = resolveColor(textColorRef);
|
|
}
|
|
}
|
|
if (style.hasAccentColor() == false) {
|
|
std::string accentColorRef = widgetStyleNode->getString("accentColor", "");
|
|
if (!accentColorRef.empty()) {
|
|
style.accentColor = resolveColor(accentColorRef);
|
|
}
|
|
}
|
|
|
|
setWidgetStyle(widgetType, style);
|
|
|
|
// Load variant styles (hover, pressed, etc.)
|
|
auto variants = {"normal", "hover", "pressed", "disabled", "checked", "unchecked"};
|
|
for (const auto& variant : variants) {
|
|
if (auto* variantNode = widgetStyleNode->getChildReadOnly(variant)) {
|
|
WidgetStyle variantStyle;
|
|
variantStyle.parseFromJson(*variantNode);
|
|
|
|
// Resolve color references for variant
|
|
if (variantStyle.hasBgColor() == false) {
|
|
std::string bgColorRef = variantNode->getString("bgColor", "");
|
|
if (!bgColorRef.empty()) {
|
|
variantStyle.bgColor = resolveColor(bgColorRef);
|
|
}
|
|
}
|
|
|
|
setWidgetVariantStyle(widgetType, variant, variantStyle);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
spdlog::info("Theme '{}' loaded with {} colors and {} widget styles",
|
|
m_name, m_colors.size(), m_widgetStyles.size());
|
|
|
|
return true;
|
|
}
|
|
|
|
std::string UITheme::makeVariantKey(const std::string& widgetType, const std::string& variant) {
|
|
return widgetType + ":" + variant;
|
|
}
|
|
|
|
// =============================================================================
|
|
// UIStyleManager
|
|
// =============================================================================
|
|
|
|
void UIStyleManager::setTheme(std::unique_ptr<UITheme> theme) {
|
|
m_currentTheme = std::move(theme);
|
|
}
|
|
|
|
WidgetStyle UIStyleManager::resolveStyle(const std::string& widgetType, const WidgetStyle& inlineStyle) const {
|
|
// Start with default
|
|
WidgetStyle resolved = getDefaultStyle(widgetType);
|
|
|
|
// Apply theme style if available
|
|
if (m_currentTheme) {
|
|
resolved.merge(m_currentTheme->getWidgetStyle(widgetType));
|
|
}
|
|
|
|
// Apply inline style (highest priority)
|
|
resolved.merge(inlineStyle);
|
|
|
|
return resolved;
|
|
}
|
|
|
|
WidgetStyle UIStyleManager::resolveVariantStyle(const std::string& widgetType, const std::string& variant, const WidgetStyle& inlineStyle) const {
|
|
// Start with base widget style
|
|
WidgetStyle resolved = resolveStyle(widgetType, WidgetStyle());
|
|
|
|
// Apply theme variant style
|
|
if (m_currentTheme) {
|
|
resolved.merge(m_currentTheme->getWidgetVariantStyle(widgetType, variant));
|
|
}
|
|
|
|
// Apply inline variant style
|
|
resolved.merge(inlineStyle);
|
|
|
|
return resolved;
|
|
}
|
|
|
|
WidgetStyle UIStyleManager::getDefaultStyle(const std::string& widgetType) const {
|
|
WidgetStyle style;
|
|
|
|
// Set some sensible defaults per widget type
|
|
if (widgetType == "panel") {
|
|
style.bgColor = 0x333333FF;
|
|
style.padding = 10.0f;
|
|
}
|
|
else if (widgetType == "label") {
|
|
style.textColor = 0xFFFFFFFF;
|
|
style.fontSize = 16.0f;
|
|
}
|
|
else if (widgetType == "button") {
|
|
style.bgColor = 0x444444FF;
|
|
style.textColor = 0xFFFFFFFF;
|
|
style.fontSize = 16.0f;
|
|
style.padding = 10.0f;
|
|
}
|
|
else if (widgetType == "slider") {
|
|
style.bgColor = 0x34495eFF; // track
|
|
style.accentColor = 0x3498dbFF; // fill
|
|
style.handleSize = 16.0f;
|
|
}
|
|
else if (widgetType == "checkbox") {
|
|
style.bgColor = 0x34495eFF; // box
|
|
style.accentColor = 0x2ecc71FF; // check
|
|
style.textColor = 0xFFFFFFFF;
|
|
style.fontSize = 16.0f;
|
|
style.boxSize = 24.0f;
|
|
style.spacing = 8.0f;
|
|
}
|
|
else if (widgetType == "progressbar") {
|
|
style.bgColor = 0x34495eFF;
|
|
style.accentColor = 0x2ecc71FF; // fill
|
|
style.textColor = 0xFFFFFFFF;
|
|
style.fontSize = 14.0f;
|
|
}
|
|
|
|
return style;
|
|
}
|
|
|
|
} // namespace grove
|