diff --git a/main/WidgetConfig.cpp b/main/WidgetConfig.cpp index 2f54c85..f4d0fbe 100644 --- a/main/WidgetConfig.cpp +++ b/main/WidgetConfig.cpp @@ -19,6 +19,8 @@ void WidgetConfig::serialize(uint8_t* buf) const { memcpy(&buf[pos], text, MAX_TEXT_LEN); pos += MAX_TEXT_LEN; buf[pos++] = knxAddress & 0xFF; buf[pos++] = (knxAddress >> 8) & 0xFF; buf[pos++] = fontSize; + buf[pos++] = textAlign; + buf[pos++] = isContainer ? 1 : 0; buf[pos++] = textColor.r; buf[pos++] = textColor.g; buf[pos++] = textColor.b; buf[pos++] = bgColor.r; buf[pos++] = bgColor.g; buf[pos++] = bgColor.b; @@ -66,6 +68,8 @@ void WidgetConfig::deserialize(const uint8_t* buf) { text[MAX_TEXT_LEN - 1] = '\0'; knxAddress = buf[pos] | (buf[pos+1] << 8); pos += 2; fontSize = buf[pos++]; + textAlign = buf[pos++]; + isContainer = buf[pos++] != 0; textColor.r = buf[pos++]; textColor.g = buf[pos++]; textColor.b = buf[pos++]; bgColor.r = buf[pos++]; bgColor.g = buf[pos++]; bgColor.b = buf[pos++]; @@ -109,6 +113,8 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c cfg.textSource = TextSource::STATIC; strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1); cfg.fontSize = 1; // 18pt + cfg.textAlign = static_cast(TextAlign::LEFT); + cfg.isContainer = false; cfg.textColor = {255, 255, 255}; cfg.bgColor = {0, 0, 0}; cfg.bgOpacity = 0; @@ -144,6 +150,8 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y, cfg.textSource = TextSource::STATIC; strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1); cfg.fontSize = 1; + cfg.textAlign = static_cast(TextAlign::CENTER); + cfg.isContainer = true; cfg.textColor = {255, 255, 255}; cfg.bgColor = {33, 150, 243}; // Blue cfg.bgOpacity = 255; diff --git a/main/WidgetConfig.hpp b/main/WidgetConfig.hpp index 946301f..efc53c1 100644 --- a/main/WidgetConfig.hpp +++ b/main/WidgetConfig.hpp @@ -46,6 +46,12 @@ enum class TextSource : uint8_t { KNX_DPT_TEXT = 4, // KNX Text (DPT 16.000) }; +enum class TextAlign : uint8_t { + LEFT = 0, + CENTER = 1, + RIGHT = 2, +}; + // Color as RGB888 struct Color { uint8_t r, g, b; @@ -87,6 +93,8 @@ struct WidgetConfig { char text[MAX_TEXT_LEN]; // Static text or format string uint16_t knxAddress; // KNX group address (if textSource != STATIC) uint8_t fontSize; // Font size index (0=14, 1=18, 2=22, 3=28, 4=36, 5=48) + uint8_t textAlign; // TextAlign: 0=left, 1=center, 2=right + bool isContainer; // For buttons: use as container (no internal label/icon) // Colors Color textColor; @@ -113,7 +121,7 @@ struct WidgetConfig { int8_t parentId; // ID of parent widget (-1 = root/screen) // Serialization size (fixed for NVS storage) - static constexpr size_t SERIALIZED_SIZE = 77; + static constexpr size_t SERIALIZED_SIZE = 78; void serialize(uint8_t* buf) const; void deserialize(const uint8_t* buf); diff --git a/main/WidgetManager.cpp b/main/WidgetManager.cpp index 0ca9610..ef2e451 100644 --- a/main/WidgetManager.cpp +++ b/main/WidgetManager.cpp @@ -69,6 +69,61 @@ static void latin1_to_utf8(const char* src, size_t src_len, char* dst, size_t ds dst[di] = '\0'; } +static WidgetConfig makeButtonLabelChild(const WidgetConfig& button) { + WidgetConfig label = WidgetConfig::createLabel(0, 0, 0, button.text); + label.parentId = button.id; + if (button.width > 0) label.width = button.width; + if (button.height > 0) label.height = button.height; + label.fontSize = button.fontSize; + label.textAlign = button.textAlign; + label.textColor = button.textColor; + label.textSource = TextSource::STATIC; + label.bgOpacity = 0; + label.borderRadius = 0; + label.shadow.enabled = false; + // Preserve existing icon config if any + label.iconCodepoint = button.iconCodepoint; + label.iconPosition = button.iconPosition; + label.iconSize = button.iconSize; + label.iconGap = button.iconGap; + if (label.text[0] == '\0') { + strncpy(label.text, "Button", MAX_TEXT_LEN - 1); + label.text[MAX_TEXT_LEN - 1] = '\0'; + } + return label; +} + +static void ensureButtonLabels(ScreenConfig& screen) { + bool hasLabelChild[MAX_WIDGETS] = {}; + for (uint8_t i = 0; i < screen.widgetCount; i++) { + const WidgetConfig& w = screen.widgets[i]; + if (w.type == WidgetType::LABEL && w.parentId >= 0 && w.parentId < MAX_WIDGETS) { + hasLabelChild[w.parentId] = true; + } + } + + const uint8_t initialCount = screen.widgetCount; + for (uint8_t i = 0; i < initialCount; i++) { + WidgetConfig& w = screen.widgets[i]; + if (w.type != WidgetType::BUTTON) continue; + + w.isContainer = true; + + if (w.id < MAX_WIDGETS && hasLabelChild[w.id]) continue; + + WidgetConfig label = makeButtonLabelChild(w); + int newId = screen.addWidget(label); + if (newId < 0) { + ESP_LOGW(TAG, "No space to add label child for button %d", w.id); + w.isContainer = false; + continue; + } + if (w.id < MAX_WIDGETS) { + hasLabelChild[w.id] = true; + } + } +} + // WidgetManager implementation WidgetManager& WidgetManager::instance() { static WidgetManager inst; @@ -103,6 +158,8 @@ void WidgetManager::createDefaultConfig() { progBtn.bgColor = {200, 50, 50}; // Red screen.addWidget(progBtn); + ensureButtonLabels(screen); + config_.startScreenId = screen.id; config_.standbyEnabled = false; config_.standbyScreenId = 0xFF; @@ -836,6 +893,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { cJSON_AddStringToObject(widget, "text", w.text); cJSON_AddNumberToObject(widget, "knxAddr", w.knxAddress); cJSON_AddNumberToObject(widget, "fontSize", w.fontSize); + cJSON_AddNumberToObject(widget, "textAlign", w.textAlign); + cJSON_AddBoolToObject(widget, "isContainer", w.isContainer); char textColorStr[8]; snprintf(textColorStr, sizeof(textColorStr), "#%02X%02X%02X", @@ -920,6 +979,8 @@ bool WidgetManager::updateConfigFromJson(const char* json) { w.visible = true; w.action = ButtonAction::KNX; w.targetScreen = 0; + w.textAlign = static_cast(TextAlign::CENTER); + w.isContainer = false; cJSON* id = cJSON_GetObjectItem(widget, "id"); if (cJSON_IsNumber(id)) w.id = id->valueint; @@ -957,6 +1018,12 @@ bool WidgetManager::updateConfigFromJson(const char* json) { cJSON* fontSize = cJSON_GetObjectItem(widget, "fontSize"); if (cJSON_IsNumber(fontSize)) w.fontSize = fontSize->valueint; + cJSON* textAlign = cJSON_GetObjectItem(widget, "textAlign"); + if (cJSON_IsNumber(textAlign)) w.textAlign = textAlign->valueint; + + cJSON* isContainer = cJSON_GetObjectItem(widget, "isContainer"); + if (cJSON_IsBool(isContainer)) w.isContainer = cJSON_IsTrue(isContainer); + cJSON* textColor = cJSON_GetObjectItem(widget, "textColor"); if (cJSON_IsString(textColor)) { w.textColor = Color::fromHex(parseHexColor(textColor->valuestring)); @@ -1096,6 +1163,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) { if (!parseWidgets(widgets, screen)) { screen.widgetCount = 0; } + ensureButtonLabels(screen); newConfig->screenCount++; } @@ -1114,6 +1182,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) { cJSON_Delete(root); return false; } + ensureButtonLabels(screen); } cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen"); diff --git a/main/widgets/ButtonWidget.cpp b/main/widgets/ButtonWidget.cpp index b76cad5..3af1a28 100644 --- a/main/widgets/ButtonWidget.cpp +++ b/main/widgets/ButtonWidget.cpp @@ -5,6 +5,18 @@ static const char* TAG = "ButtonWidget"; +static lv_text_align_t toLvTextAlign(uint8_t align) { + if (align == static_cast(TextAlign::LEFT)) return LV_TEXT_ALIGN_LEFT; + if (align == static_cast(TextAlign::RIGHT)) return LV_TEXT_ALIGN_RIGHT; + return LV_TEXT_ALIGN_CENTER; +} + +static lv_flex_align_t toFlexAlign(uint8_t align) { + if (align == static_cast(TextAlign::LEFT)) return LV_FLEX_ALIGN_START; + if (align == static_cast(TextAlign::RIGHT)) return LV_FLEX_ALIGN_END; + return LV_FLEX_ALIGN_CENTER; +} + ButtonWidget::ButtonWidget(const WidgetConfig& config) : Widget(config) , contentContainer_(nullptr) @@ -73,14 +85,30 @@ void ButtonWidget::setupFlexLayout() { lv_obj_set_flex_flow(contentContainer_, isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(contentContainer_, - LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_flex_align_t mainAlign = LV_FLEX_ALIGN_CENTER; + lv_flex_align_t crossAlign = LV_FLEX_ALIGN_CENTER; + lv_flex_align_t contentAlign = toFlexAlign(config_.textAlign); + if (contentAlign != LV_FLEX_ALIGN_CENTER) { + if (isVertical) { + crossAlign = contentAlign; + } else { + mainAlign = contentAlign; + } + } + lv_obj_set_flex_align(contentContainer_, mainAlign, crossAlign, LV_FLEX_ALIGN_CENTER); // Set gap between icon and text int gap = config_.iconGap > 0 ? config_.iconGap : 8; lv_obj_set_style_pad_gap(contentContainer_, gap, 0); } +void ButtonWidget::applyTextAlignment() { + if (label_ == nullptr) return; + lv_obj_set_width(label_, LV_PCT(100)); + lv_obj_set_style_text_align(label_, toLvTextAlign(config_.textAlign), 0); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); +} + lv_obj_t* ButtonWidget::create(lv_obj_t* parent) { obj_ = lv_btn_create(parent); lv_obj_set_pos(obj_, config_.x, config_.y); @@ -89,51 +117,54 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) { lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this); - bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont(); + bool hasIcon = !config_.isContainer && + config_.iconCodepoint > 0 && Fonts::hasIconFont(); - if (hasIcon) { - // Create container for flex layout - contentContainer_ = lv_obj_create(obj_); - if (contentContainer_ == nullptr) { - return obj_; // Continue without icon container + if (!config_.isContainer) { + if (hasIcon) { + // Create container for flex layout + contentContainer_ = lv_obj_create(obj_); + if (contentContainer_ == nullptr) { + return obj_; // Continue without icon container + } + lv_obj_remove_style_all(contentContainer_); + lv_obj_set_size(contentContainer_, LV_PCT(100), LV_PCT(100)); + lv_obj_center(contentContainer_); + lv_obj_clear_flag(contentContainer_, LV_OBJ_FLAG_CLICKABLE); // Pass clicks to parent + + // Create icon label + bool iconFirst = (config_.iconPosition == static_cast(IconPosition::LEFT) || + config_.iconPosition == static_cast(IconPosition::TOP)); + + if (iconFirst) { + iconLabel_ = lv_label_create(contentContainer_); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(iconLabel_, iconText); + lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); + } + + // Create text label + label_ = lv_label_create(contentContainer_); + lv_label_set_text(label_, config_.text); + lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE); + + if (!iconFirst) { + iconLabel_ = lv_label_create(contentContainer_); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(iconLabel_, iconText); + lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); + } + + setupFlexLayout(); + } else { + // Simple button without icon + label_ = lv_label_create(obj_); + lv_label_set_text(label_, config_.text); + applyTextAlignment(); + lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE); } - lv_obj_remove_style_all(contentContainer_); - lv_obj_set_size(contentContainer_, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_center(contentContainer_); - lv_obj_clear_flag(contentContainer_, LV_OBJ_FLAG_CLICKABLE); // Pass clicks to parent - - // Create icon label - bool iconFirst = (config_.iconPosition == static_cast(IconPosition::LEFT) || - config_.iconPosition == static_cast(IconPosition::TOP)); - - if (iconFirst) { - iconLabel_ = lv_label_create(contentContainer_); - char iconText[5]; - encodeUtf8(config_.iconCodepoint, iconText); - lv_label_set_text(iconLabel_, iconText); - lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); - } - - // Create text label - label_ = lv_label_create(contentContainer_); - lv_label_set_text(label_, config_.text); - lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE); - - if (!iconFirst) { - iconLabel_ = lv_label_create(contentContainer_); - char iconText[5]; - encodeUtf8(config_.iconCodepoint, iconText); - lv_label_set_text(iconLabel_, iconText); - lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); - } - - setupFlexLayout(); - } else { - // Simple button without icon - label_ = lv_label_create(obj_); - lv_label_set_text(label_, config_.text); - lv_obj_center(label_); - lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE); } ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)", diff --git a/main/widgets/ButtonWidget.hpp b/main/widgets/ButtonWidget.hpp index 71b7e99..ceaa638 100644 --- a/main/widgets/ButtonWidget.hpp +++ b/main/widgets/ButtonWidget.hpp @@ -19,6 +19,7 @@ private: lv_obj_t* iconLabel_ = nullptr; void setupFlexLayout(); + void applyTextAlignment(); static int encodeUtf8(uint32_t codepoint, char* buf); static void clickCallback(lv_event_t* e); }; diff --git a/main/widgets/LabelWidget.cpp b/main/widgets/LabelWidget.cpp index 36aa585..7c29890 100644 --- a/main/widgets/LabelWidget.cpp +++ b/main/widgets/LabelWidget.cpp @@ -10,6 +10,18 @@ LabelWidget::LabelWidget(const WidgetConfig& config) { } +static lv_text_align_t toLvTextAlign(uint8_t align) { + if (align == static_cast(TextAlign::LEFT)) return LV_TEXT_ALIGN_LEFT; + if (align == static_cast(TextAlign::RIGHT)) return LV_TEXT_ALIGN_RIGHT; + return LV_TEXT_ALIGN_CENTER; +} + +static lv_flex_align_t toFlexAlign(uint8_t align) { + if (align == static_cast(TextAlign::LEFT)) return LV_FLEX_ALIGN_START; + if (align == static_cast(TextAlign::RIGHT)) return LV_FLEX_ALIGN_END; + return LV_FLEX_ALIGN_CENTER; +} + int LabelWidget::encodeUtf8(uint32_t codepoint, char* buf) { if (codepoint < 0x80) { buf[0] = static_cast(codepoint); @@ -47,8 +59,17 @@ void LabelWidget::setupFlexLayout() { lv_obj_set_flex_flow(container_, isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(container_, - LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_flex_align_t mainAlign = LV_FLEX_ALIGN_CENTER; + lv_flex_align_t crossAlign = LV_FLEX_ALIGN_CENTER; + lv_flex_align_t contentAlign = toFlexAlign(config_.textAlign); + if (contentAlign != LV_FLEX_ALIGN_CENTER) { + if (isVertical) { + crossAlign = contentAlign; + } else { + mainAlign = contentAlign; + } + } + lv_obj_set_flex_align(container_, mainAlign, crossAlign, LV_FLEX_ALIGN_CENTER); // Set gap between icon and text int gap = config_.iconGap > 0 ? config_.iconGap : 8; @@ -85,16 +106,19 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) { char iconText[5]; encodeUtf8(config_.iconCodepoint, iconText); lv_label_set_text(iconLabel_, iconText); + lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); } textLabel_ = lv_label_create(container_); lv_label_set_text(textLabel_, config_.text); + lv_obj_clear_flag(textLabel_, LV_OBJ_FLAG_CLICKABLE); if (!iconFirst) { iconLabel_ = lv_label_create(container_); char iconText[5]; encodeUtf8(config_.iconCodepoint, iconText); lv_label_set_text(iconLabel_, iconText); + lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); } setupFlexLayout(); @@ -103,6 +127,7 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) { textLabel_ = lv_label_create(container_); lv_label_set_text(textLabel_, config_.text); lv_obj_center(textLabel_); + lv_obj_clear_flag(textLabel_, LV_OBJ_FLAG_CLICKABLE); } } else { // Simple label without container @@ -115,6 +140,9 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) { } } + if (obj_ != nullptr) { + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE); + } return obj_; } @@ -138,6 +166,7 @@ void LabelWidget::applyStyle() { lv_obj_set_style_text_color(textLabel_, lv_color_make( config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); lv_obj_set_style_text_font(textLabel_, Fonts::bySizeIndex(config_.fontSize), 0); + lv_obj_set_style_text_align(textLabel_, toLvTextAlign(config_.textAlign), 0); } // Apply icon style diff --git a/web-interface/src/App.vue b/web-interface/src/App.vue index 7d001a0..50d0b35 100644 --- a/web-interface/src/App.vue +++ b/web-interface/src/App.vue @@ -1,24 +1,32 @@ \ No newline at end of file + diff --git a/web-interface/src/components/CanvasArea.vue b/web-interface/src/components/CanvasArea.vue index 45f876d..23a912e 100644 --- a/web-interface/src/components/CanvasArea.vue +++ b/web-interface/src/components/CanvasArea.vue @@ -1,13 +1,46 @@ + + diff --git a/web-interface/src/components/SettingsModal.vue b/web-interface/src/components/SettingsModal.vue new file mode 100644 index 0000000..9471927 --- /dev/null +++ b/web-interface/src/components/SettingsModal.vue @@ -0,0 +1,46 @@ + + + diff --git a/web-interface/src/components/SidebarLeft.vue b/web-interface/src/components/SidebarLeft.vue index 1ad1d7d..36fbb65 100644 --- a/web-interface/src/components/SidebarLeft.vue +++ b/web-interface/src/components/SidebarLeft.vue @@ -1,89 +1,43 @@ diff --git a/web-interface/src/components/SidebarRight.vue b/web-interface/src/components/SidebarRight.vue index 6c30d2a..dc88715 100644 --- a/web-interface/src/components/SidebarRight.vue +++ b/web-interface/src/components/SidebarRight.vue @@ -1,34 +1,34 @@