Compare commits

..

No commits in common. "3dfd2b461d7174a28c1a01d33dcc627bee971b8d" and "ae8bb5a01fb00fb837e1187a47f74e12729c94bb" have entirely different histories.

99 changed files with 2185 additions and 5741 deletions

View File

@ -12,12 +12,7 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c
"widgets/PowerLinkWidget.cpp"
"widgets/ChartWidget.cpp"
"widgets/ClockWidget.cpp"
"widgets/RectangleWidget.cpp"
"widgets/ArcWidget.cpp"
"widgets/ButtonMatrixWidget.cpp"
"widgets/RoomCardWidgetBase.cpp"
"widgets/RoomCardBubbleWidget.cpp"
"widgets/RoomCardTileWidget.cpp"
"widgets/RoomCardWidget.cpp"
"webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp"

View File

@ -17,13 +17,8 @@ static constexpr const char* kIconFontPath = "/sdcard/fonts/MaterialSymbolsOutli
static constexpr uint16_t kFontSizes[] = {14, 18, 22, 28, 36, 48};
static constexpr size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]);
// Icon fonts have more sizes including large ones for decorative use
// Large icons (>150px) use PSRAM for draw buffers via CLIB_MALLOC
static constexpr uint16_t kIconFontSizes[] = {14, 18, 22, 28, 36, 48, 64, 80, 96, 120, 150, 180, 220, 260};
static constexpr size_t kIconFontCount = sizeof(kIconFontSizes) / sizeof(kIconFontSizes[0]);
static const lv_font_t* s_fonts[kFontCount] = {nullptr};
static const lv_font_t* s_iconFonts[kIconFontCount] = {nullptr};
static const lv_font_t* s_iconFonts[kFontCount] = {nullptr};
static bool s_initialized = false;
static bool s_iconFontAvailable = false;
@ -114,14 +109,14 @@ void Fonts::init() {
}
ESP_LOGI(TAG, "Text font initialization complete");
// Load icon font if available (includes larger sizes for decorative icons)
// Load icon font if available
if (fontFileExists(kIconFontPath)) {
ESP_LOGI(TAG, "Icon font file exists: %s", kIconFontPath);
for (size_t i = 0; i < kIconFontCount; ++i) {
ESP_LOGI(TAG, "Loading icon font size %u...", kIconFontSizes[i]);
for (size_t i = 0; i < kFontCount; ++i) {
ESP_LOGI(TAG, "Loading icon font size %u...", kFontSizes[i]);
esp_lv_adapter_ft_font_config_t cfg = {};
cfg.name = kIconFontPath;
cfg.size = kIconFontSizes[i];
cfg.size = kFontSizes[i];
cfg.style = ESP_LV_ADAPTER_FT_FONT_STYLE_NORMAL;
cfg.mem = nullptr;
cfg.mem_size = 0;
@ -136,10 +131,10 @@ void Fonts::init() {
s_iconFonts[i] = esp_lv_adapter_ft_font_get(handle);
if (s_iconFonts[i]) {
s_iconFontAvailable = true;
ESP_LOGI(TAG, "Icon font size %u loaded successfully", kIconFontSizes[i]);
ESP_LOGI(TAG, "Icon font size %u loaded successfully", kFontSizes[i]);
}
}
ESP_LOGI(TAG, "Icon font initialization complete (%zu sizes)", kIconFontCount);
ESP_LOGI(TAG, "Icon font initialization complete");
} else {
ESP_LOGW(TAG, "Icon font file not found: %s", kIconFontPath);
}
@ -159,18 +154,18 @@ const lv_font_t* Fonts::bySizeIndex(uint8_t sizeIndex) {
const lv_font_t* Fonts::iconFont(uint8_t sizeIndex) {
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
if (sizeIndex < kIconFontCount && s_iconFonts[sizeIndex]) {
if (sizeIndex < kFontCount && s_iconFonts[sizeIndex]) {
return s_iconFonts[sizeIndex];
}
// Try to return any available icon font size as fallback
if (s_iconFontAvailable) {
for (size_t i = 0; i < kIconFontCount; ++i) {
for (size_t i = 0; i < kFontCount; ++i) {
if (s_iconFonts[i]) return s_iconFonts[i];
}
}
#endif
// No icon font available - return text font as last resort
return fallbackFont(sizeIndex < kFontCount ? sizeIndex : kFontCount - 1);
return fallbackFont(sizeIndex);
}
bool Fonts::hasIconFont() {

View File

@ -26,9 +26,6 @@ void WidgetConfig::serialize(uint8_t* buf) const {
buf[pos++] = bgColor.r; buf[pos++] = bgColor.g; buf[pos++] = bgColor.b;
buf[pos++] = bgOpacity;
buf[pos++] = borderRadius;
buf[pos++] = borderWidth;
buf[pos++] = borderColor.r; buf[pos++] = borderColor.g; buf[pos++] = borderColor.b;
buf[pos++] = borderOpacity;
buf[pos++] = shadow.offsetX;
buf[pos++] = shadow.offsetY;
@ -50,10 +47,6 @@ void WidgetConfig::serialize(uint8_t* buf) const {
buf[pos++] = iconPosition;
buf[pos++] = iconSize;
buf[pos++] = static_cast<uint8_t>(iconGap);
buf[pos++] = iconPositionX & 0xFF;
buf[pos++] = (iconPositionX >> 8) & 0xFF;
buf[pos++] = iconPositionY & 0xFF;
buf[pos++] = (iconPositionY >> 8) & 0xFF;
// Hierarchy
buf[pos++] = static_cast<uint8_t>(parentId);
@ -115,8 +108,6 @@ void WidgetConfig::serialize(uint8_t* buf) const {
buf[pos++] = subButtonCount;
buf[pos++] = subButtonSize;
buf[pos++] = subButtonDistance;
buf[pos++] = subButtonOpacity;
buf[pos++] = cardStyle;
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
const SubButtonConfig& sb = subButtons[i];
buf[pos++] = sb.iconCodepoint & 0xFF;
@ -140,20 +131,6 @@ void WidgetConfig::serialize(uint8_t* buf) const {
buf[pos++] = 0; // padding
buf[pos++] = 0; // padding
}
// Arc properties
buf[pos++] = arcMin & 0xFF; buf[pos++] = (arcMin >> 8) & 0xFF;
buf[pos++] = arcMax & 0xFF; buf[pos++] = (arcMax >> 8) & 0xFF;
buf[pos++] = arcUnit;
buf[pos++] = arcShowValue ? 1 : 0;
buf[pos++] = static_cast<uint8_t>(arcScaleOffset);
buf[pos++] = arcScaleColor.r;
buf[pos++] = arcScaleColor.g;
buf[pos++] = arcScaleColor.b;
buf[pos++] = arcValueColor.r;
buf[pos++] = arcValueColor.g;
buf[pos++] = arcValueColor.b;
buf[pos++] = arcValueFontSize;
}
void WidgetConfig::deserialize(const uint8_t* buf) {
@ -179,9 +156,6 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
bgColor.r = buf[pos++]; bgColor.g = buf[pos++]; bgColor.b = buf[pos++];
bgOpacity = buf[pos++];
borderRadius = buf[pos++];
borderWidth = buf[pos++];
borderColor.r = buf[pos++]; borderColor.g = buf[pos++]; borderColor.b = buf[pos++];
borderOpacity = buf[pos++];
shadow.offsetX = static_cast<int8_t>(buf[pos++]);
shadow.offsetY = static_cast<int8_t>(buf[pos++]);
@ -202,10 +176,6 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
iconPosition = buf[pos++];
iconSize = buf[pos++];
iconGap = static_cast<int8_t>(buf[pos++]);
iconPositionX = static_cast<int16_t>(buf[pos] | (buf[pos+1] << 8));
pos += 2;
iconPositionY = static_cast<int16_t>(buf[pos] | (buf[pos+1] << 8));
pos += 2;
// Hierarchy
parentId = static_cast<int8_t>(buf[pos++]);
@ -283,22 +253,17 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
}
// RoomCard sub-buttons
if (pos + 5 <= SERIALIZED_SIZE) {
if (pos + 3 <= SERIALIZED_SIZE) {
subButtonCount = buf[pos++];
if (subButtonCount > MAX_SUBBUTTONS) subButtonCount = MAX_SUBBUTTONS;
subButtonSize = buf[pos++];
if (subButtonSize == 0) subButtonSize = 40; // Default
subButtonDistance = buf[pos++];
if (subButtonDistance == 0) subButtonDistance = 80; // Default 80px
subButtonOpacity = buf[pos++];
if (subButtonOpacity == 0) subButtonOpacity = 255; // Default fully opaque
cardStyle = buf[pos++]; // 0=Bubble, 1=Tile
} else {
subButtonCount = 0;
subButtonSize = 40;
subButtonDistance = 80;
subButtonOpacity = 255;
cardStyle = 0; // Default to Bubble style
}
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
@ -324,39 +289,6 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
sb = SubButtonConfig{};
}
}
// Arc properties
if (pos + 6 <= SERIALIZED_SIZE) {
arcMin = static_cast<int16_t>(buf[pos] | (buf[pos + 1] << 8)); pos += 2;
arcMax = static_cast<int16_t>(buf[pos] | (buf[pos + 1] << 8)); pos += 2;
arcUnit = buf[pos++];
arcShowValue = buf[pos++] != 0;
} else {
arcMin = 0;
arcMax = 100;
arcUnit = 0;
arcShowValue = true;
}
if (pos + 4 <= SERIALIZED_SIZE) {
arcScaleOffset = static_cast<int8_t>(buf[pos++]);
arcScaleColor.r = buf[pos++];
arcScaleColor.g = buf[pos++];
arcScaleColor.b = buf[pos++];
} else {
arcScaleOffset = 0;
arcScaleColor = textColor;
}
if (pos + 4 <= SERIALIZED_SIZE) {
arcValueColor.r = buf[pos++];
arcValueColor.g = buf[pos++];
arcValueColor.b = buf[pos++];
arcValueFontSize = buf[pos++];
} else {
arcValueColor = textColor;
arcValueFontSize = fontSize;
}
}
WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) {
@ -378,17 +310,6 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c
cfg.bgColor = {0, 0, 0};
cfg.bgOpacity = 0;
cfg.borderRadius = 0;
cfg.borderWidth = 0;
cfg.borderColor = {255, 255, 255};
cfg.borderOpacity = 0;
cfg.arcMin = 0;
cfg.arcMax = 100;
cfg.arcUnit = 0;
cfg.arcShowValue = true;
cfg.arcScaleOffset = 0;
cfg.arcScaleColor = cfg.textColor;
cfg.arcValueColor = cfg.textColor;
cfg.arcValueFontSize = cfg.fontSize;
cfg.shadow.enabled = false;
// Icon defaults
cfg.iconCodepoint = 0;
@ -434,22 +355,11 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
cfg.fontSize = 1;
cfg.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
cfg.isContainer = false;
cfg.isContainer = true;
cfg.textColor = {255, 255, 255};
cfg.bgColor = {33, 150, 243}; // Blue
cfg.bgOpacity = 255;
cfg.borderRadius = 8;
cfg.borderWidth = 0;
cfg.borderColor = {255, 255, 255};
cfg.borderOpacity = 0;
cfg.arcMin = 0;
cfg.arcMax = 100;
cfg.arcUnit = 0;
cfg.arcShowValue = true;
cfg.arcScaleOffset = 0;
cfg.arcScaleColor = cfg.textColor;
cfg.arcValueColor = cfg.textColor;
cfg.arcValueFontSize = cfg.fontSize;
cfg.shadow.enabled = true;
cfg.shadow.offsetX = 2;
cfg.shadow.offsetY = 2;
@ -465,8 +375,6 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
cfg.iconPosition = 0;
cfg.iconSize = 1;
cfg.iconGap = 8;
cfg.iconPositionX = 0;
cfg.iconPositionY = 0;
// Secondary/tertiary address defaults
cfg.knxAddress2 = 0;
cfg.textSource2 = TextSource::STATIC;

View File

@ -14,7 +14,6 @@ static constexpr size_t CHART_MAX_SERIES = 3;
static constexpr size_t MAX_CONDITIONS = 3;
static constexpr size_t MAX_FORMAT_LEN = 16; // Shorter format strings for left/right values
static constexpr size_t MAX_SUBBUTTONS = 6; // Sub-buttons for RoomCard
static constexpr size_t MAX_TEXTLINES = 5; // Variable text lines for RoomCard
enum class WidgetType : uint8_t {
LABEL = 0,
@ -29,9 +28,6 @@ enum class WidgetType : uint8_t {
CHART = 9,
CLOCK = 10,
ROOMCARD = 11,
RECTANGLE = 12,
ARC = 13,
BUTTONMATRIX = 14,
};
enum class IconPosition : uint8_t {
@ -184,18 +180,6 @@ struct SubButtonConfig {
// Total: 20 bytes per SubButton
};
// Text line configuration for RoomCard (24 bytes)
struct TextLineConfig {
uint32_t iconCodepoint; // 4 bytes - Icon codepoint (0 = no icon)
uint16_t knxAddr; // 2 bytes - KNX address for dynamic value
TextSource textSrc; // 1 byte - Text source (static or KNX DPT)
uint8_t fontSize; // 1 byte - Font size index (0-5)
bool enabled; // 1 byte - Is this text line active?
uint8_t _padding; // 1 byte - Alignment padding
char text[MAX_FORMAT_LEN]; // 16 bytes - Static text or format string
// Total: 26 bytes per TextLine
};
// Shadow configuration
struct ShadowConfig {
int8_t offsetX;
@ -228,9 +212,6 @@ struct WidgetConfig {
Color bgColor;
uint8_t bgOpacity; // 0-255
uint8_t borderRadius;
uint8_t borderWidth; // 0-32
Color borderColor;
uint8_t borderOpacity; // 0-255
// Shadow
ShadowConfig shadow;
@ -244,10 +225,8 @@ struct WidgetConfig {
// Icon properties (for Label, Button, Icon widgets)
uint32_t iconCodepoint; // Unicode codepoint (0 = no icon)
uint8_t iconPosition; // IconPosition: 0=left, 1=right, 2=top, 3=bottom
uint8_t iconSize; // Font size index (0-13 for icons)
uint8_t iconSize; // Font size index (0-5), same as fontSize
int8_t iconGap; // Gap between icon and text (px)
int16_t iconPositionX; // Icon Pos X (for decorative icons in Tile)
int16_t iconPositionY; // Icon Pos Y (for decorative icons in Tile)
// Hierarchy
int8_t parentId; // ID of parent widget (-1 = root/screen)
@ -277,27 +256,11 @@ struct WidgetConfig {
uint8_t subButtonCount;
uint8_t subButtonSize; // Sub-button size in pixels (default 40)
uint8_t subButtonDistance; // Distance from center in pixels (default 80)
uint8_t subButtonOpacity; // Sub-button opacity 0-255 (default 255)
uint8_t cardStyle; // 0=Bubble (round), 1=Tile (rectangular)
SubButtonConfig subButtons[MAX_SUBBUTTONS];
// RoomCard text lines (for Tile style)
uint8_t textLineCount;
TextLineConfig textLines[MAX_TEXTLINES];
// Arc properties
int16_t arcMin;
int16_t arcMax;
uint8_t arcUnit; // 0=none, 1=percent, 2=C
bool arcShowValue; // Show center value label
int8_t arcScaleOffset; // Radial offset for scale (ticks/labels)
Color arcScaleColor; // Scale (tick/label) color
Color arcValueColor; // Center value text color
uint8_t arcValueFontSize; // Center value font size index
// Serialization size (fixed for NVS storage)
// 331 + 14 (arcMin + arcMax + arcUnit + arcShowValue + arcScaleOffset + arcScaleColor + arcValueColor + arcValueFontSize) = 345
static constexpr size_t SERIALIZED_SIZE = 345;
// 197 + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 120 (6 subButtons * 20) = 320
static constexpr size_t SERIALIZED_SIZE = 320;
void serialize(uint8_t* buf) const;
void deserialize(const uint8_t* buf);

View File

@ -1,7 +1,6 @@
#include "WidgetManager.hpp"
#include "widgets/WidgetFactory.hpp"
#include "widgets/RoomCardWidgetBase.hpp"
#include "widgets/RoomCardTileWidget.hpp"
#include "widgets/RoomCardWidget.hpp"
#include "HistoryStore.hpp"
#include "SdCard.hpp"
#include "esp_lv_adapter.h"
@ -89,73 +88,58 @@ static void latin1_to_utf8(const char* src, size_t src_len, char* dst, size_t ds
dst[di] = '\0';
}
static bool color_equals(const Color& a, const Color& b) {
return a.r == b.r && a.g == b.g && a.b == b.b;
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 bool is_auto_button_label(const WidgetConfig& button, const WidgetConfig& label) {
if (label.type != WidgetType::LABEL) return false;
if (label.parentId < 0 || label.parentId != static_cast<int8_t>(button.id)) return false;
if (label.textSource != TextSource::STATIC) return false;
bool textMatches = (strncmp(label.text, button.text, MAX_TEXT_LEN) == 0);
if (!textMatches && button.text[0] == '\0') {
if (strncmp(label.text, "Button", MAX_TEXT_LEN) == 0 || label.text[0] == '\0') {
textMatches = true;
}
}
if (!textMatches) return false;
if (label.fontSize != button.fontSize) return false;
if (label.textAlign != button.textAlign) return false;
if (!color_equals(label.textColor, button.textColor)) return false;
if (label.bgOpacity != 0) return false;
if (label.borderRadius != 0) return false;
if (label.shadow.enabled) return false;
if (label.iconCodepoint != button.iconCodepoint) return false;
if (label.iconPosition != button.iconPosition) return false;
if (label.iconSize != button.iconSize) return false;
if (label.iconGap != button.iconGap) return false;
if (label.x != 0 || label.y != 0) return false;
return true;
}
static void normalizeButtons(ScreenConfig& screen) {
uint8_t removeIds[MAX_WIDGETS] = {};
uint8_t removeCount = 0;
// Remove legacy auto-generated label children for buttons
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) continue;
WidgetConfig* parent = screen.findWidget(static_cast<uint8_t>(w.parentId));
if (!parent || parent->type != WidgetType::BUTTON) continue;
bool forceRemove = parent->text[0] != '\0';
if (forceRemove || is_auto_button_label(*parent, w)) {
if (removeCount < MAX_WIDGETS) {
removeIds[removeCount++] = w.id;
}
if (w.type == WidgetType::LABEL && w.parentId >= 0 && w.parentId < MAX_WIDGETS) {
hasLabelChild[w.parentId] = true;
}
}
for (uint8_t i = 0; i < removeCount; i++) {
screen.removeWidget(removeIds[i]);
}
// Update container flag based on remaining children
for (uint8_t i = 0; i < screen.widgetCount; i++) {
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;
bool hasChild = false;
for (uint8_t j = 0; j < screen.widgetCount; j++) {
const WidgetConfig& child = screen.widgets[j];
if (child.parentId >= 0 && child.parentId == static_cast<int8_t>(w.id)) {
hasChild = true;
break;
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;
}
w.isContainer = hasChild;
}
}
@ -203,7 +187,7 @@ void WidgetManager::createDefaultConfig() {
progBtn.bgColor = {200, 50, 50}; // Red
screen.addWidget(progBtn);
normalizeButtons(screen);
ensureButtonLabels(screen);
config_->startScreenId = screen.id;
config_->standbyEnabled = false;
@ -367,7 +351,7 @@ void WidgetManager::applyScreenLocked(uint8_t screenId) {
return;
}
normalizeButtons(*screen);
ensureButtonLabels(*screen);
if (modalContainer_) {
ESP_LOGI(TAG, "Closing modal first");
@ -576,8 +560,8 @@ void WidgetManager::handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target
printf("WM: handleButtonAction btn=%d act=%d type=%d\n", cfg.id, (int)cfg.action, (int)cfg.type);
fflush(stdout);
if (cfg.type != WidgetType::BUTTON && cfg.type != WidgetType::BUTTONMATRIX) {
printf("WM: Not a clickable widget!\n");
if (cfg.type != WidgetType::BUTTON) {
printf("WM: Not a button!\n");
fflush(stdout);
return;
}
@ -1052,22 +1036,6 @@ void WidgetManager::applyCachedValuesToWidgets() {
widget->onKnxValue3(value);
}
}
// RoomCard text lines (only for Tile style)
if (widget->getType() == WidgetType::ROOMCARD) {
const WidgetConfig& cfg = widget->getConfig();
if (cfg.cardStyle == 1 && cfg.textLineCount > 0) {
for (uint8_t i = 0; i < cfg.textLineCount && i < MAX_TEXTLINES; ++i) {
const TextLineConfig& tl = cfg.textLines[i];
if (tl.enabled && tl.knxAddr != 0 && tl.textSrc != TextSource::STATIC && isNumericTextSource(tl.textSrc)) {
float value = 0.0f;
if (getCachedKnxValue(tl.knxAddr, tl.textSrc, &value)) {
static_cast<RoomCardTileWidget*>(widget.get())->onTextLineValue(i, value);
}
}
}
}
}
}
}
@ -1131,19 +1099,6 @@ void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource so
widget->getTextSource3() == source) {
widget->onKnxValue3(value);
}
// RoomCard text line updates (only for Tile style)
if (widget->getType() == WidgetType::ROOMCARD) {
const WidgetConfig& cfg = widget->getConfig();
if (cfg.cardStyle == 1 && cfg.textLineCount > 0) {
for (uint8_t i = 0; i < cfg.textLineCount && i < MAX_TEXTLINES; ++i) {
const TextLineConfig& tl = cfg.textLines[i];
if (tl.enabled && tl.knxAddr == groupAddr && tl.textSrc == source) {
static_cast<RoomCardTileWidget*>(widget.get())->onTextLineValue(i, value);
}
}
}
}
}
if (HistoryStore::instance().updateLatest(groupAddr, source, value)) {
@ -1164,7 +1119,7 @@ void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
const WidgetConfig& cfg = widget->getConfig();
for (uint8_t i = 0; i < cfg.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
if (cfg.subButtons[i].enabled && cfg.subButtons[i].knxAddrRead == groupAddr) {
static_cast<RoomCardWidgetBase*>(widget.get())->onSubButtonStatus(i, value);
static_cast<RoomCardWidget*>(widget.get())->onSubButtonStatus(i, value);
}
}
}
@ -1537,12 +1492,6 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddNumberToObject(widget, "bgOpacity", w.bgOpacity);
cJSON_AddNumberToObject(widget, "radius", w.borderRadius);
cJSON_AddNumberToObject(widget, "borderWidth", w.borderWidth);
cJSON_AddNumberToObject(widget, "borderOpacity", w.borderOpacity);
char borderColorStr[8];
snprintf(borderColorStr, sizeof(borderColorStr), "#%02X%02X%02X",
w.borderColor.r, w.borderColor.g, w.borderColor.b);
cJSON_AddStringToObject(widget, "borderColor", borderColorStr);
cJSON* shadow = cJSON_AddObjectToObject(widget, "shadow");
cJSON_AddBoolToObject(shadow, "enabled", w.shadow.enabled);
@ -1565,22 +1514,6 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition);
cJSON_AddNumberToObject(widget, "iconSize", w.iconSize);
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
cJSON_AddNumberToObject(widget, "iconPositionX", w.iconPositionX);
cJSON_AddNumberToObject(widget, "iconPositionY", w.iconPositionY);
cJSON_AddNumberToObject(widget, "arcMin", w.arcMin);
cJSON_AddNumberToObject(widget, "arcMax", w.arcMax);
cJSON_AddNumberToObject(widget, "arcUnit", w.arcUnit);
cJSON_AddBoolToObject(widget, "arcShowValue", w.arcShowValue);
cJSON_AddNumberToObject(widget, "arcScaleOffset", w.arcScaleOffset);
char arcScaleColorStr[8];
snprintf(arcScaleColorStr, sizeof(arcScaleColorStr), "#%02X%02X%02X",
w.arcScaleColor.r, w.arcScaleColor.g, w.arcScaleColor.b);
cJSON_AddStringToObject(widget, "arcScaleColor", arcScaleColorStr);
char arcValueColorStr[8];
snprintf(arcValueColorStr, sizeof(arcValueColorStr), "#%02X%02X%02X",
w.arcValueColor.r, w.arcValueColor.g, w.arcValueColor.b);
cJSON_AddStringToObject(widget, "arcValueColor", arcValueColorStr);
cJSON_AddNumberToObject(widget, "arcValueFontSize", w.arcValueFontSize);
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
@ -1675,8 +1608,6 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
if (w.type == WidgetType::ROOMCARD) {
cJSON_AddNumberToObject(widget, "subButtonSize", w.subButtonSize);
cJSON_AddNumberToObject(widget, "subButtonDistance", w.subButtonDistance);
cJSON_AddNumberToObject(widget, "subButtonOpacity", w.subButtonOpacity);
cJSON_AddNumberToObject(widget, "cardStyle", w.cardStyle);
}
if (w.type == WidgetType::ROOMCARD && w.subButtonCount > 0) {
cJSON* subButtons = cJSON_AddArrayToObject(widget, "subButtons");
@ -1704,24 +1635,6 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
}
}
// RoomCard text lines
if (w.type == WidgetType::ROOMCARD && w.textLineCount > 0) {
cJSON* textLines = cJSON_AddArrayToObject(widget, "textLines");
for (uint8_t ti = 0; ti < w.textLineCount && ti < MAX_TEXTLINES; ++ti) {
const TextLineConfig& tl = w.textLines[ti];
if (!tl.enabled) continue;
cJSON* tlJson = cJSON_CreateObject();
cJSON_AddNumberToObject(tlJson, "icon", tl.iconCodepoint);
cJSON_AddNumberToObject(tlJson, "textSrc", static_cast<int>(tl.textSrc));
cJSON_AddNumberToObject(tlJson, "knxAddr", tl.knxAddr);
cJSON_AddStringToObject(tlJson, "text", tl.text);
cJSON_AddNumberToObject(tlJson, "fontSize", tl.fontSize);
cJSON_AddItemToArray(textLines, tlJson);
}
}
cJSON_AddItemToArray(widgets, widget);
}
@ -1777,14 +1690,6 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
w.chartTextSource[i] = TextSource::KNX_DPT_TEMP;
w.chartSeriesColor[i] = defaultChartColor(i);
}
w.arcMin = 0;
w.arcMax = 100;
w.arcUnit = 0;
w.arcShowValue = true;
w.arcScaleOffset = 0;
w.arcScaleColor = w.textColor;
w.arcValueColor = w.textColor;
w.arcValueFontSize = w.fontSize;
cJSON* id = cJSON_GetObjectItem(widget, "id");
if (cJSON_IsNumber(id)) w.id = id->valueint;
@ -1844,17 +1749,6 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON* radius = cJSON_GetObjectItem(widget, "radius");
if (cJSON_IsNumber(radius)) w.borderRadius = radius->valueint;
cJSON* borderWidth = cJSON_GetObjectItem(widget, "borderWidth");
if (cJSON_IsNumber(borderWidth)) w.borderWidth = borderWidth->valueint;
cJSON* borderOpacity = cJSON_GetObjectItem(widget, "borderOpacity");
if (cJSON_IsNumber(borderOpacity)) w.borderOpacity = borderOpacity->valueint;
cJSON* borderColor = cJSON_GetObjectItem(widget, "borderColor");
if (cJSON_IsString(borderColor)) {
w.borderColor = Color::fromHex(parseHexColor(borderColor->valuestring));
}
cJSON* shadow = cJSON_GetObjectItem(widget, "shadow");
if (cJSON_IsObject(shadow)) {
cJSON* enabled = cJSON_GetObjectItem(shadow, "enabled");
@ -1903,44 +1797,6 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap");
if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint;
cJSON* iconPositionX = cJSON_GetObjectItem(widget, "iconPositionX");
if (cJSON_IsNumber(iconPositionX)) w.iconPositionX = iconPositionX->valueint;
cJSON* iconPositionY = cJSON_GetObjectItem(widget, "iconPositionY");
if (cJSON_IsNumber(iconPositionY)) w.iconPositionY = iconPositionY->valueint;
cJSON* arcMin = cJSON_GetObjectItem(widget, "arcMin");
if (cJSON_IsNumber(arcMin)) w.arcMin = arcMin->valueint;
cJSON* arcMax = cJSON_GetObjectItem(widget, "arcMax");
if (cJSON_IsNumber(arcMax)) w.arcMax = arcMax->valueint;
cJSON* arcUnit = cJSON_GetObjectItem(widget, "arcUnit");
if (cJSON_IsNumber(arcUnit)) w.arcUnit = arcUnit->valueint;
cJSON* arcShowValue = cJSON_GetObjectItem(widget, "arcShowValue");
if (cJSON_IsBool(arcShowValue)) {
w.arcShowValue = cJSON_IsTrue(arcShowValue);
} else if (cJSON_IsNumber(arcShowValue)) {
w.arcShowValue = arcShowValue->valueint != 0;
}
cJSON* arcScaleOffset = cJSON_GetObjectItem(widget, "arcScaleOffset");
if (cJSON_IsNumber(arcScaleOffset)) w.arcScaleOffset = static_cast<int8_t>(arcScaleOffset->valueint);
cJSON* arcScaleColor = cJSON_GetObjectItem(widget, "arcScaleColor");
if (cJSON_IsString(arcScaleColor)) {
w.arcScaleColor = Color::fromHex(parseHexColor(arcScaleColor->valuestring));
}
cJSON* arcValueColor = cJSON_GetObjectItem(widget, "arcValueColor");
if (cJSON_IsString(arcValueColor)) {
w.arcValueColor = Color::fromHex(parseHexColor(arcValueColor->valuestring));
}
cJSON* arcValueFontSize = cJSON_GetObjectItem(widget, "arcValueFontSize");
if (cJSON_IsNumber(arcValueFontSize)) w.arcValueFontSize = arcValueFontSize->valueint;
cJSON* parentId = cJSON_GetObjectItem(widget, "parentId");
if (cJSON_IsNumber(parentId)) {
w.parentId = static_cast<int8_t>(parentId->valueint);
@ -2100,20 +1956,6 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
} else {
w.subButtonDistance = 80; // Default 80px
}
cJSON* subButtonOpacity = cJSON_GetObjectItem(widget, "subButtonOpacity");
if (cJSON_IsNumber(subButtonOpacity)) {
w.subButtonOpacity = subButtonOpacity->valueint;
} else {
w.subButtonOpacity = 255; // Default fully opaque
}
cJSON* cardStyle = cJSON_GetObjectItem(widget, "cardStyle");
if (cJSON_IsNumber(cardStyle)) {
w.cardStyle = cardStyle->valueint;
ESP_LOGI(TAG, "RoomCard cardStyle loaded: %d", w.cardStyle);
} else {
w.cardStyle = 0; // Default to Bubble style
ESP_LOGI(TAG, "RoomCard cardStyle not found, using default 0");
}
// RoomCard sub-buttons
cJSON* subButtons = cJSON_GetObjectItem(widget, "subButtons");
@ -2172,51 +2014,6 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
w.subButtonCount = sbIdx;
}
// RoomCard text lines
cJSON* textLines = cJSON_GetObjectItem(widget, "textLines");
if (cJSON_IsArray(textLines)) {
uint8_t tlIdx = 0;
cJSON* tlItem = nullptr;
cJSON_ArrayForEach(tlItem, textLines) {
if (tlIdx >= MAX_TEXTLINES) break;
TextLineConfig& tl = w.textLines[tlIdx];
memset(&tl, 0, sizeof(tl));
tl.enabled = true;
cJSON* icon = cJSON_GetObjectItem(tlItem, "icon");
if (cJSON_IsNumber(icon)) {
tl.iconCodepoint = static_cast<uint32_t>(icon->valuedouble);
}
cJSON* textSrc = cJSON_GetObjectItem(tlItem, "textSrc");
if (cJSON_IsNumber(textSrc)) {
tl.textSrc = static_cast<TextSource>(textSrc->valueint);
}
cJSON* knxAddr = cJSON_GetObjectItem(tlItem, "knxAddr");
if (cJSON_IsNumber(knxAddr)) {
tl.knxAddr = knxAddr->valueint;
}
cJSON* text = cJSON_GetObjectItem(tlItem, "text");
if (cJSON_IsString(text)) {
strncpy(tl.text, text->valuestring, MAX_FORMAT_LEN - 1);
tl.text[MAX_FORMAT_LEN - 1] = '\0';
}
cJSON* fontSize = cJSON_GetObjectItem(tlItem, "fontSize");
if (cJSON_IsNumber(fontSize)) {
tl.fontSize = fontSize->valueint;
} else {
tl.fontSize = 1; // Default 18px
}
tlIdx++;
}
w.textLineCount = tlIdx;
}
screen.widgetCount++;
}
@ -2301,7 +2098,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
if (!parseWidgets(widgets, screen)) {
screen.widgetCount = 0;
}
normalizeButtons(screen);
ensureButtonLabels(screen);
newConfig->screenCount++;
}
@ -2320,7 +2117,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON_Delete(root);
return false;
}
normalizeButtons(screen);
ensureButtonLabels(screen);
}
cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen");

View File

@ -1,247 +0,0 @@
#include "ArcWidget.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
ArcWidget::ArcWidget(const WidgetConfig& config)
: Widget(config)
{
}
static int32_t clampToRange(float value, int32_t minValue, int32_t maxValue) {
if (maxValue < minValue) std::swap(maxValue, minValue);
if (value < minValue) value = static_cast<float>(minValue);
if (value > maxValue) value = static_cast<float>(maxValue);
return static_cast<int32_t>(std::lround(value));
}
int32_t ArcWidget::initialValueFromText() const {
if (config_.text[0] == '\0') return 75;
char* end = nullptr;
long parsed = strtol(config_.text, &end, 10);
if (end == config_.text) return 75;
return static_cast<int32_t>(parsed);
}
lv_obj_t* ArcWidget::create(lv_obj_t* parent) {
obj_ = lv_arc_create(parent);
if (!obj_) return nullptr;
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_,
config_.width > 0 ? config_.width : 180,
config_.height > 0 ? config_.height : 180);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(obj_, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
lv_obj_set_style_pad_all(obj_, 0, 0);
lv_arc_set_rotation(obj_, 0);
lv_arc_set_mode(obj_, LV_ARC_MODE_NORMAL);
lv_arc_set_bg_angles(obj_, 135, 45);
int32_t minValue = config_.arcMin;
int32_t maxValue = config_.arcMax;
if (maxValue <= minValue) {
minValue = 0;
maxValue = 100;
}
lv_arc_set_range(obj_, minValue, maxValue);
lastValue_ = static_cast<float>(initialValueFromText());
lv_arc_set_value(obj_, clampToRange(lastValue_, minValue, maxValue));
// Create scale as child of arc
scale_ = lv_scale_create(obj_);
if (scale_) {
lv_obj_clear_flag(scale_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(scale_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(scale_, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
lv_obj_set_style_bg_opa(scale_, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(scale_, 0, 0);
lv_obj_set_style_pad_all(scale_, 0, 0);
}
valueLabel_ = lv_label_create(obj_);
if (valueLabel_) {
lv_obj_center(valueLabel_);
updateValueLabel(lastValue_);
}
return obj_;
}
void ArcWidget::applyStyle() {
if (!obj_) return;
applyCommonStyle();
lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0);
int32_t minValue = config_.arcMin;
int32_t maxValue = config_.arcMax;
if (maxValue <= minValue) {
minValue = 0;
maxValue = 100;
}
lv_arc_set_range(obj_, minValue, maxValue);
int16_t arcWidth = config_.borderRadius > 0 ? static_cast<int16_t>(config_.borderRadius) : 12;
if (arcWidth < 2) arcWidth = 2;
if (arcWidth > 48) arcWidth = 48;
lv_color_t trackColor = lv_color_make(config_.bgColor.r, config_.bgColor.g, config_.bgColor.b);
uint8_t trackOpa = config_.bgOpacity > 0 ? config_.bgOpacity : static_cast<uint8_t>(LV_OPA_40);
lv_color_t indicatorColor = lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b);
lv_obj_set_style_arc_width(obj_, arcWidth, LV_PART_MAIN);
lv_obj_set_style_arc_color(obj_, trackColor, LV_PART_MAIN);
lv_obj_set_style_arc_opa(obj_, trackOpa, LV_PART_MAIN);
lv_obj_set_style_arc_rounded(obj_, true, LV_PART_MAIN);
lv_obj_set_style_arc_width(obj_, arcWidth, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(obj_, indicatorColor, LV_PART_INDICATOR);
lv_obj_set_style_arc_opa(obj_, LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_arc_rounded(obj_, true, LV_PART_INDICATOR);
int16_t knobSize = std::max<int16_t>(8, static_cast<int16_t>(arcWidth + 6));
lv_obj_set_style_arc_width(obj_, knobSize, LV_PART_KNOB);
lv_obj_set_style_bg_color(obj_, indicatorColor, LV_PART_KNOB);
lv_obj_set_style_bg_opa(obj_, LV_OPA_COVER, LV_PART_KNOB);
if (scale_) {
// Use config dimensions directly, not from arc object (which may have content area limitations)
int16_t scaleWidth = config_.width > 0 ? static_cast<int16_t>(config_.width) : 180;
int16_t scaleHeight = config_.height > 0 ? static_cast<int16_t>(config_.height) : 180;
lv_obj_set_size(scale_, scaleWidth, scaleHeight);
lv_obj_set_pos(scale_, 0, 0);
lv_obj_set_align(scale_, LV_ALIGN_CENTER);
lv_scale_set_mode(scale_, LV_SCALE_MODE_ROUND_INNER);
lv_scale_set_angle_range(scale_, 270);
lv_scale_set_rotation(scale_, 135);
lv_scale_set_range(scale_, minValue, maxValue);
lv_scale_set_total_tick_count(scale_, 21);
lv_scale_set_major_tick_every(scale_, 5);
lv_scale_set_label_show(scale_, true);
int16_t majorLen = std::max<int16_t>(3, static_cast<int16_t>(arcWidth / 3 + 1));
int16_t minorLen = std::max<int16_t>(2, static_cast<int16_t>(arcWidth / 4 + 1));
lv_obj_set_style_length(scale_, majorLen, LV_PART_INDICATOR);
lv_obj_set_style_length(scale_, minorLen, LV_PART_ITEMS);
// Use radial_offset to position ticks at inner edge of arc stroke.
// Positive arcScaleOffset should move the scale outward.
int16_t scaleOffset = static_cast<int16_t>(-config_.arcScaleOffset);
int16_t tickOffset = static_cast<int16_t>(-arcWidth + scaleOffset);
lv_obj_set_style_radial_offset(scale_, tickOffset, LV_PART_INDICATOR);
lv_obj_set_style_radial_offset(scale_, tickOffset, LV_PART_ITEMS);
lv_obj_set_style_arc_width(scale_, 1, LV_PART_MAIN);
lv_obj_set_style_arc_opa(scale_, LV_OPA_TRANSP, LV_PART_MAIN);
lv_color_t scaleColor = lv_color_make(
config_.arcScaleColor.r, config_.arcScaleColor.g, config_.arcScaleColor.b);
lv_obj_set_style_line_color(scale_, scaleColor, LV_PART_INDICATOR);
lv_obj_set_style_line_color(scale_, scaleColor, LV_PART_ITEMS);
lv_obj_set_style_line_width(scale_, 2, LV_PART_INDICATOR);
lv_obj_set_style_line_width(scale_, 1, LV_PART_ITEMS);
lv_obj_set_style_line_opa(scale_, LV_OPA_80, LV_PART_INDICATOR);
lv_obj_set_style_line_opa(scale_, LV_OPA_40, LV_PART_ITEMS);
uint8_t scaleFont = config_.fontSize > 0 ? static_cast<uint8_t>(config_.fontSize - 1) : config_.fontSize;
lv_obj_set_style_text_color(scale_, scaleColor, LV_PART_INDICATOR);
lv_obj_set_style_text_opa(scale_, LV_OPA_80, LV_PART_INDICATOR);
lv_obj_set_style_text_font(scale_, getFontBySize(scaleFont), LV_PART_INDICATOR);
lv_obj_set_style_transform_rotation(scale_, LV_SCALE_LABEL_ROTATE_MATCH_TICKS | LV_SCALE_LABEL_ROTATE_KEEP_UPRIGHT,
LV_PART_INDICATOR);
int16_t labelPad = static_cast<int16_t>(-(arcWidth / 2 + 4) + scaleOffset);
lv_obj_set_style_pad_radial(scale_, labelPad, LV_PART_INDICATOR);
}
if (valueLabel_) {
lv_color_t valueColor = lv_color_make(
config_.arcValueColor.r, config_.arcValueColor.g, config_.arcValueColor.b);
lv_obj_set_style_text_color(valueLabel_, valueColor, 0);
lv_obj_set_style_text_font(valueLabel_, getFontBySize(config_.arcValueFontSize), 0);
lv_obj_center(valueLabel_);
updateValueLabel(lastValue_);
}
}
void ArcWidget::onKnxValue(float value) {
if (!obj_) return;
switch (config_.textSource) {
case TextSource::KNX_DPT_TEMP:
case TextSource::KNX_DPT_PERCENT:
case TextSource::KNX_DPT_POWER:
case TextSource::KNX_DPT_ENERGY:
case TextSource::KNX_DPT_DECIMALFACTOR:
lastValue_ = value;
lv_arc_set_value(obj_, clampToRange(value, config_.arcMin, config_.arcMax));
updateValueLabel(value);
break;
default:
break;
}
}
void ArcWidget::onKnxSwitch(bool value) {
if (!obj_) return;
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
int32_t minValue = config_.arcMin;
int32_t maxValue = config_.arcMax;
if (maxValue <= minValue) {
minValue = 0;
maxValue = 100;
}
lastValue_ = value ? static_cast<float>(maxValue) : static_cast<float>(minValue);
lv_arc_set_value(obj_, value ? maxValue : minValue);
updateValueLabel(lastValue_);
}
const char* ArcWidget::unitSuffix(uint8_t unit) {
switch (unit) {
case 1: return "%";
case 2: return "C";
default: return "";
}
}
void ArcWidget::updateValueLabel(float value) {
if (!valueLabel_) return;
if (!config_.arcShowValue) {
lv_obj_add_flag(valueLabel_, LV_OBJ_FLAG_HIDDEN);
return;
}
lv_obj_clear_flag(valueLabel_, LV_OBJ_FLAG_HIDDEN);
int32_t minValue = config_.arcMin;
int32_t maxValue = config_.arcMax;
if (maxValue <= minValue) {
minValue = 0;
maxValue = 100;
}
float clamped = value;
if (clamped < minValue) clamped = static_cast<float>(minValue);
if (clamped > maxValue) clamped = static_cast<float>(maxValue);
char buf[24];
const char* suffix = unitSuffix(config_.arcUnit);
bool useDecimal = (config_.textSource == TextSource::KNX_DPT_TEMP);
if (useDecimal) {
std::snprintf(buf, sizeof(buf), "%.1f%s", clamped, suffix);
} else {
std::snprintf(buf, sizeof(buf), "%.0f%s", clamped, suffix);
}
lv_label_set_text(valueLabel_, buf);
}

View File

@ -1,23 +0,0 @@
#pragma once
#include "Widget.hpp"
class ArcWidget : public Widget {
public:
explicit ArcWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
void onKnxValue(float value) override;
void onKnxSwitch(bool value) override;
private:
int32_t initialValueFromText() const;
void updateValueLabel(float value);
static const char* unitSuffix(uint8_t unit);
lv_obj_t* scale_ = nullptr;
lv_obj_t* valueLabel_ = nullptr;
float lastValue_ = 0.0f;
};

View File

@ -1,157 +0,0 @@
#include "ButtonMatrixWidget.hpp"
#include "../WidgetManager.hpp"
#include <algorithm>
#include <cctype>
namespace {
std::string trimToken(const std::string& token) {
size_t start = 0;
size_t end = token.size();
while (start < end && std::isspace(static_cast<unsigned char>(token[start])) != 0) {
start++;
}
while (end > start && std::isspace(static_cast<unsigned char>(token[end - 1])) != 0) {
end--;
}
return token.substr(start, end - start);
}
}
ButtonMatrixWidget::ButtonMatrixWidget(const WidgetConfig& config)
: Widget(config)
{
}
ButtonMatrixWidget::~ButtonMatrixWidget() {
if (obj_) {
lv_obj_remove_event_cb(obj_, valueChangedCallback);
}
}
void ButtonMatrixWidget::rebuildMapStorage() {
mapStorage_.clear();
mapPointers_.clear();
auto addButton = [&](const std::string& token) {
std::string trimmed = trimToken(token);
if (!trimmed.empty()) {
mapStorage_.push_back(trimmed);
}
};
if (config_.text[0] != '\0') {
std::string token;
for (size_t i = 0; config_.text[i] != '\0'; ++i) {
const char ch = config_.text[i];
if (ch == ';' || ch == ',') {
addButton(token);
token.clear();
} else if (ch == '\n' || ch == '\r') {
addButton(token);
token.clear();
if (!mapStorage_.empty() && mapStorage_.back() != "\n") {
mapStorage_.push_back("\n");
}
if (ch == '\r' && config_.text[i + 1] == '\n') {
++i;
}
} else {
token.push_back(ch);
}
}
addButton(token);
while (!mapStorage_.empty() && mapStorage_.back() == "\n") {
mapStorage_.pop_back();
}
}
if (mapStorage_.empty()) {
mapStorage_ = {"1", "2", "3", "\n", "4", "5", "6", "\n", "7", "8", "9"};
}
mapPointers_.reserve(mapStorage_.size() + 1);
for (const std::string& entry : mapStorage_) {
mapPointers_.push_back(entry.c_str());
}
mapPointers_.push_back("");
}
lv_obj_t* ButtonMatrixWidget::create(lv_obj_t* parent) {
obj_ = lv_buttonmatrix_create(parent);
if (!obj_) return nullptr;
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_,
config_.width > 0 ? config_.width : 220,
config_.height > 0 ? config_.height : 140);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
rebuildMapStorage();
lv_buttonmatrix_set_map(obj_, mapPointers_.data());
lv_buttonmatrix_set_button_ctrl_all(obj_, LV_BUTTONMATRIX_CTRL_CLICK_TRIG);
if (config_.isToggle) {
lv_buttonmatrix_set_button_ctrl_all(obj_, LV_BUTTONMATRIX_CTRL_CHECKABLE);
}
lv_obj_add_event_cb(obj_, valueChangedCallback, LV_EVENT_VALUE_CHANGED, this);
return obj_;
}
void ButtonMatrixWidget::applyStyle() {
if (!obj_) return;
applyCommonStyle();
lv_color_t textColor = lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b);
lv_color_t buttonColor = lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b);
uint8_t buttonOpa = config_.bgOpacity > 0 ? config_.bgOpacity : static_cast<uint8_t>(LV_OPA_30);
lv_obj_set_style_text_font(obj_, getFontBySize(config_.fontSize), LV_PART_ITEMS);
lv_obj_set_style_text_color(obj_, textColor, LV_PART_ITEMS);
lv_obj_set_style_bg_color(obj_, buttonColor, LV_PART_ITEMS);
lv_obj_set_style_bg_opa(obj_, buttonOpa, LV_PART_ITEMS);
if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, LV_PART_ITEMS);
}
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, LV_PART_ITEMS);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), LV_PART_ITEMS);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, LV_PART_ITEMS);
} else {
lv_obj_set_style_border_width(obj_, 0, LV_PART_ITEMS);
}
lv_style_selector_t pressedSel = static_cast<lv_style_selector_t>(LV_PART_ITEMS) |
static_cast<lv_style_selector_t>(LV_STATE_PRESSED);
lv_style_selector_t checkedSel = static_cast<lv_style_selector_t>(LV_PART_ITEMS) |
static_cast<lv_style_selector_t>(LV_STATE_CHECKED);
lv_obj_set_style_bg_color(obj_, textColor, pressedSel);
lv_obj_set_style_bg_opa(obj_, LV_OPA_40, pressedSel);
lv_obj_set_style_text_color(obj_, lv_color_white(), pressedSel);
lv_obj_set_style_bg_color(obj_, textColor, checkedSel);
lv_obj_set_style_bg_opa(obj_, LV_OPA_50, checkedSel);
lv_obj_set_style_text_color(obj_, lv_color_white(), checkedSel);
}
void ButtonMatrixWidget::valueChangedCallback(lv_event_t* e) {
ButtonMatrixWidget* widget = static_cast<ButtonMatrixWidget*>(lv_event_get_user_data(e));
if (!widget) return;
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
WidgetManager::instance().handleButtonAction(widget->getConfig(), target);
}

View File

@ -1,22 +0,0 @@
#pragma once
#include "Widget.hpp"
#include <string>
#include <vector>
class ButtonMatrixWidget : public Widget {
public:
explicit ButtonMatrixWidget(const WidgetConfig& config);
~ButtonMatrixWidget() override;
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
private:
static void valueChangedCallback(lv_event_t* e);
void rebuildMapStorage();
std::vector<std::string> mapStorage_;
std::vector<const char*> mapPointers_;
};

View File

@ -12,12 +12,6 @@ static lv_text_align_t toLvTextAlign(uint8_t align) {
return LV_TEXT_ALIGN_CENTER;
}
static lv_align_t toLvAlign(uint8_t align) {
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_ALIGN_LEFT_MID;
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_ALIGN_RIGHT_MID;
return LV_ALIGN_CENTER;
}
static lv_flex_align_t toFlexAlign(uint8_t align) {
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_FLEX_ALIGN_START;
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_FLEX_ALIGN_END;
@ -136,7 +130,7 @@ void ButtonWidget::setupFlexLayout() {
void ButtonWidget::applyTextAlignment() {
if (label_ == nullptr) return;
lv_obj_set_style_text_align(label_, toLvTextAlign(config_.textAlign), 0);
lv_obj_align(label_, toLvAlign(config_.textAlign), 0, 0);
lv_obj_center(label_);
}
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {

View File

@ -80,6 +80,7 @@ void ChartWidget::applyStyle() {
if (!obj_) return;
Widget::applyStyle();
lv_obj_set_style_border_width(obj_, 0, 0);
lv_obj_set_style_pad_all(obj_, 0, 0);
if (chart_) {

View File

@ -67,17 +67,9 @@ void ClockWidget::applyStyle() {
lv_obj_set_style_radius(obj_, LV_RADIUS_CIRCLE, 0);
}
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, 2, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_border_opa(obj_, LV_OPA_COVER, 0);
}
lv_color_t handColor = lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b);

View File

@ -83,17 +83,6 @@ void IconWidget::applyStyle() {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
}
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, 0, 0);
}
applyShadowStyle();
// Apply icon style
if (iconLabel_ != nullptr) {
lv_obj_set_style_text_color(iconLabel_, lv_color_make(

View File

@ -202,15 +202,6 @@ void LabelWidget::applyStyle() {
if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
}
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, 0, 0);
}
applyShadowStyle();
}
// Apply text style

View File

@ -25,15 +25,6 @@ void LedWidget::applyStyle() {
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b));
lv_led_set_brightness(obj_, config_.bgOpacity);
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, 0, 0);
}
// Shadow
applyShadowStyle();
}

View File

@ -41,17 +41,10 @@ void PowerFlowWidget::applyStyle() {
lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0);
}
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, 1, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_border_opa(obj_, LV_OPA_20, 0);
}
if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);

View File

@ -206,17 +206,10 @@ void PowerNodeWidget::applyStyle() {
if (ring < 2) ring = 2;
if (ring > 12) ring = 12;
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, ring, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.bgOpacity, 0);
}
applyShadowStyle();

View File

@ -1,26 +0,0 @@
#include "RectangleWidget.hpp"
RectangleWidget::RectangleWidget(const WidgetConfig& config)
: Widget(config)
{
}
lv_obj_t* RectangleWidget::create(lv_obj_t* parent) {
obj_ = lv_obj_create(parent);
if (obj_ == nullptr) return nullptr;
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_,
config_.width > 0 ? config_.width : 180,
config_.height > 0 ? config_.height : 120);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
return obj_;
}
void RectangleWidget::applyStyle() {
if (obj_ == nullptr) return;
applyCommonStyle();
}

View File

@ -1,11 +0,0 @@
#pragma once
#include "Widget.hpp"
class RectangleWidget : public Widget {
public:
explicit RectangleWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
};

View File

@ -1,152 +0,0 @@
#include "RoomCardBubbleWidget.hpp"
#include "../Fonts.hpp"
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
RoomCardBubbleWidget::RoomCardBubbleWidget(const WidgetConfig& config)
: RoomCardWidgetBase(config)
{
}
void RoomCardBubbleWidget::clearLvglObject() {
RoomCardWidgetBase::clearLvglObject();
bubble_ = nullptr;
roomIcon_ = nullptr;
}
lv_obj_t* RoomCardBubbleWidget::create(lv_obj_t* parent) {
parseTextBase();
screenParent_ = parent;
// Create main container
obj_ = lv_obj_create(parent);
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_, config_.width, config_.height);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
// Add click handler for navigation
lv_obj_add_event_cb(obj_, cardClickCallback, LV_EVENT_CLICKED, this);
createBubbleLayout();
createSubButtonsCommon();
return obj_;
}
void RoomCardBubbleWidget::createBubbleLayout() {
// Calculate bubble size (80% of widget size, circular)
int16_t minSide = config_.width < config_.height ? config_.width : config_.height;
int16_t bubbleSize = (minSide * 80) / 100;
// Create bubble container (centered)
bubble_ = lv_obj_create(obj_);
lv_obj_remove_style_all(bubble_);
lv_obj_set_size(bubble_, bubbleSize, bubbleSize);
lv_obj_center(bubble_);
lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE);
// Circular shape
lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0);
lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0);
// Set up flex layout for bubble content
lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(bubble_, 8, 0);
lv_obj_set_style_pad_gap(bubble_, 2, 0);
// Room icon
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
roomIcon_ = lv_label_create(bubble_);
lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(roomIcon_, iconText);
}
// Temperature label
tempLabel_ = lv_label_create(bubble_);
lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE);
lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : "");
// Room name label
if (roomName_[0] != '\0') {
roomLabel_ = lv_label_create(bubble_);
lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE);
lv_label_set_text(roomLabel_, roomName_);
}
// Click handler for navigation
lv_obj_add_event_cb(bubble_, cardClickCallback, LV_EVENT_CLICKED, this);
}
void RoomCardBubbleWidget::calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) {
int16_t centerX = config_.width / 2;
int16_t centerY = config_.height / 2;
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80;
// Calculate angle: position 0=TOP, going clockwise, 8 positions = 45 degrees each
float angle = static_cast<float>(static_cast<uint8_t>(cfg.position)) * (M_PI / 4.0f) - (M_PI / 2.0f);
x = centerX + static_cast<int16_t>(orbitRadius * cosf(angle)) - subBtnSize / 2;
y = centerY + static_cast<int16_t>(orbitRadius * sinf(angle)) - subBtnSize / 2;
}
void RoomCardBubbleWidget::applyStyle() {
if (!obj_) return;
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
// Apply shadow to bubble if enabled
if (bubble_ && config_.shadow.enabled) {
lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0);
lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0);
lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0);
lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0);
lv_obj_set_style_shadow_opa(bubble_, 255, 0);
}
if (bubble_ && config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(bubble_, config_.borderWidth, 0);
lv_obj_set_style_border_color(bubble_, lv_color_hex(config_.borderColor.toLvColor()), 0);
lv_obj_set_style_border_opa(bubble_, config_.borderOpacity, 0);
} else if (bubble_) {
lv_obj_set_style_border_width(bubble_, 0, 0);
}
// Font sizes
const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize);
const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize);
const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0);
// Style room icon
if (roomIcon_ && iconFont) {
lv_obj_set_style_text_font(roomIcon_, iconFont, 0);
lv_obj_set_style_text_color(roomIcon_, textColor, 0);
}
// Style temperature
if (tempLabel_ && textFont) {
lv_obj_set_style_text_font(tempLabel_, textFont, 0);
lv_obj_set_style_text_color(tempLabel_, textColor, 0);
}
// Style room label
if (roomLabel_ && labelFont) {
lv_obj_set_style_text_font(roomLabel_, labelFont, 0);
lv_obj_set_style_text_color(roomLabel_, textColor, 0);
lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed
}
// Style sub-buttons
applySubButtonStyle();
}

View File

@ -1,23 +0,0 @@
#pragma once
#include "RoomCardWidgetBase.hpp"
class RoomCardBubbleWidget : public RoomCardWidgetBase {
public:
explicit RoomCardBubbleWidget(const WidgetConfig& config);
~RoomCardBubbleWidget() override = default;
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
void clearLvglObject() override;
protected:
void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) override;
private:
// Bubble-specific elements
lv_obj_t* bubble_ = nullptr;
lv_obj_t* roomIcon_ = nullptr;
void createBubbleLayout();
};

View File

@ -1,228 +0,0 @@
#include "RoomCardTileWidget.hpp"
#include "../Fonts.hpp"
#include <cstring>
#include <cstdio>
RoomCardTileWidget::RoomCardTileWidget(const WidgetConfig& config)
: RoomCardWidgetBase(config)
{
for (size_t i = 0; i < MAX_TEXTLINES; ++i) {
textLineLabels_[i] = nullptr;
}
}
void RoomCardTileWidget::clearLvglObject() {
RoomCardWidgetBase::clearLvglObject();
decorIcon_ = nullptr;
for (size_t i = 0; i < MAX_TEXTLINES; ++i) {
textLineLabels_[i] = nullptr;
}
}
lv_obj_t* RoomCardTileWidget::create(lv_obj_t* parent) {
parseTextBase(); // Parses roomName_ from first line of text
screenParent_ = parent;
// Create main container
obj_ = lv_obj_create(parent);
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_, config_.width, config_.height);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
// Add click handler for navigation
lv_obj_add_event_cb(obj_, cardClickCallback, LV_EVENT_CLICKED, this);
createTileLayout();
createTextLines();
createSubButtonsCommon();
return obj_;
}
void RoomCardTileWidget::createTileLayout() {
// Tile style: rectangular card with rounded corners
int16_t radius = config_.borderRadius;
if (radius <= 0 || radius > 50) {
radius = 16;
}
lv_obj_set_style_radius(obj_, radius, 0);
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
lv_obj_set_style_bg_color(obj_, lv_color_hex(config_.bgColor.toLvColor()), 0);
lv_obj_set_style_clip_corner(obj_, true, 0);
int16_t padding = 16;
// Room name (top-left)
roomLabel_ = lv_label_create(obj_);
lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE);
lv_label_set_text(roomLabel_, roomName_);
lv_obj_set_pos(roomLabel_, padding, padding);
// Large decorative icon (bottom-left, partially visible)
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
decorIcon_ = lv_label_create(obj_);
lv_obj_clear_flag(decorIcon_, LV_OBJ_FLAG_CLICKABLE);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(decorIcon_, iconText);
int16_t iconX = (config_.iconPositionX != 0) ? config_.iconPositionX : -20;
int16_t iconY = (config_.iconPositionY != 0) ? config_.iconPositionY : config_.height - 120;
lv_obj_set_pos(decorIcon_, iconX, iconY);
}
}
void RoomCardTileWidget::createTextLines() {
if (config_.textLineCount == 0) return;
int16_t padding = 16;
int16_t currentY = 48; // Below room name
for (uint8_t i = 0; i < config_.textLineCount && i < MAX_TEXTLINES; ++i) {
const TextLineConfig& tl = config_.textLines[i];
if (!tl.enabled) continue;
// Get font for this line
uint8_t fontIdx = tl.fontSize;
if (fontIdx > 5) fontIdx = 1; // Default to 18px
const lv_font_t* lineFont = Fonts::bySizeIndex(fontIdx);
const lv_font_t* iconFont = Fonts::iconFont(fontIdx);
// Create a row container
lv_obj_t* row = lv_obj_create(obj_);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_pos(row, padding, currentY);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(row, 6, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(row, LV_OBJ_FLAG_CLICKABLE);
// Create icon if set
if (tl.iconCodepoint > 0 && Fonts::hasIconFont()) {
lv_obj_t* icon = lv_label_create(row);
lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE);
char iconText[5];
encodeUtf8(tl.iconCodepoint, iconText);
lv_label_set_text(icon, iconText);
if (iconFont) {
lv_obj_set_style_text_font(icon, iconFont, 0);
}
lv_obj_set_style_text_color(icon, lv_color_hex(config_.textColor.toLvColor()), 0);
}
// Create value label
lv_obj_t* label = lv_label_create(row);
lv_obj_clear_flag(label, LV_OBJ_FLAG_CLICKABLE);
// Set initial text based on source
if (tl.textSrc == TextSource::STATIC) {
lv_label_set_text(label, tl.text);
} else {
lv_label_set_text(label, "--");
}
if (lineFont) {
lv_obj_set_style_text_font(label, lineFont, 0);
}
lv_obj_set_style_text_color(label, lv_color_hex(config_.textColor.toLvColor()), 0);
textLineLabels_[i] = label;
// Move Y position for next line
currentY += lv_font_get_line_height(lineFont) + 8;
}
}
void RoomCardTileWidget::calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) {
(void)cfg; // Not used for Tile layout
// Buttons are stacked vertically on the right side
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
int16_t gap = 10;
int16_t padding = 12;
x = config_.width - subBtnSize - padding;
y = padding + index * (subBtnSize + gap);
}
void RoomCardTileWidget::updateTextLineValue(uint8_t index, float value) {
if (index >= MAX_TEXTLINES || !textLineLabels_[index]) return;
const TextLineConfig& tl = config_.textLines[index];
if (tl.textSrc == TextSource::STATIC) return;
char buf[32];
if (tl.text[0] != '\0') {
snprintf(buf, sizeof(buf), tl.text, value);
} else {
// Default formats based on source type
switch (tl.textSrc) {
case TextSource::KNX_DPT_TEMP:
snprintf(buf, sizeof(buf), "%.1f C", value);
break;
case TextSource::KNX_DPT_PERCENT:
snprintf(buf, sizeof(buf), "%.0f %%", value);
break;
case TextSource::KNX_DPT_POWER:
snprintf(buf, sizeof(buf), "%.1f W", value);
break;
case TextSource::KNX_DPT_ENERGY:
snprintf(buf, sizeof(buf), "%.0f kWh", value);
break;
default:
snprintf(buf, sizeof(buf), "%.1f", value);
break;
}
}
lv_label_set_text(textLineLabels_[index], buf);
}
void RoomCardTileWidget::onTextLineValue(uint8_t index, float value) {
updateTextLineValue(index, value);
}
void RoomCardTileWidget::applyStyle() {
if (!obj_) return;
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_hex(config_.borderColor.toLvColor()), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else if (config_.shadow.enabled) {
// Backward-compatible accent border behavior
lv_obj_set_style_border_width(obj_, 3, 0);
lv_obj_set_style_border_color(obj_, lv_color_hex(config_.shadow.color.toLvColor()), 0);
lv_obj_set_style_border_opa(obj_, 255, 0);
} else {
lv_obj_set_style_border_width(obj_, 0, 0);
}
// Room name - large font
if (roomLabel_) {
const lv_font_t* nameFont = Fonts::bySizeIndex(config_.fontSize);
lv_obj_set_style_text_font(roomLabel_, nameFont, 0);
lv_obj_set_style_text_color(roomLabel_, textColor, 0);
}
// Large decorative icon
if (decorIcon_) {
uint8_t decorIconIdx = config_.iconSize;
if (decorIconIdx < 6) decorIconIdx = 8; // Default to 96px for decorative
const lv_font_t* bigIconFont = Fonts::iconFont(decorIconIdx);
if (bigIconFont) {
lv_obj_set_style_text_font(decorIcon_, bigIconFont, 0);
lv_obj_set_style_text_color(decorIcon_, textColor, 0);
lv_obj_set_style_text_opa(decorIcon_, 50, 0); // Very transparent
}
}
// Style sub-buttons
applySubButtonStyle();
}

View File

@ -1,30 +0,0 @@
#pragma once
#include "RoomCardWidgetBase.hpp"
class RoomCardTileWidget : public RoomCardWidgetBase {
public:
explicit RoomCardTileWidget(const WidgetConfig& config);
~RoomCardTileWidget() override = default;
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
void clearLvglObject() override;
// Text line KNX updates
void onTextLineValue(uint8_t index, float value);
protected:
void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) override;
private:
// Tile-specific elements
lv_obj_t* decorIcon_ = nullptr;
// Text line elements (rows with icon + label)
lv_obj_t* textLineLabels_[MAX_TEXTLINES] = {};
void createTileLayout();
void createTextLines();
void updateTextLineValue(uint8_t index, float value);
};

View File

@ -0,0 +1,335 @@
#include "RoomCardWidget.hpp"
#include "../Fonts.hpp"
#include "../WidgetManager.hpp"
#include <cstring>
#include <cstdio>
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
RoomCardWidget::RoomCardWidget(const WidgetConfig& config)
: Widget(config)
{
roomName_[0] = '\0';
tempFormat_[0] = '\0';
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
subButtonStates_[i] = false;
}
}
int RoomCardWidget::encodeUtf8(uint32_t codepoint, char* buf) {
if (codepoint < 0x80) {
buf[0] = static_cast<char>(codepoint);
buf[1] = '\0';
return 1;
} else if (codepoint < 0x800) {
buf[0] = static_cast<char>(0xC0 | (codepoint >> 6));
buf[1] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[2] = '\0';
return 2;
} else if (codepoint < 0x10000) {
buf[0] = static_cast<char>(0xE0 | (codepoint >> 12));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[2] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[3] = '\0';
return 3;
} else if (codepoint < 0x110000) {
buf[0] = static_cast<char>(0xF0 | (codepoint >> 18));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F));
buf[2] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[3] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[4] = '\0';
return 4;
}
buf[0] = '\0';
return 0;
}
void RoomCardWidget::parseText() {
roomName_[0] = '\0';
tempFormat_[0] = '\0';
const char* text = config_.text;
if (text && text[0] != '\0') {
const char* newline = strchr(text, '\n');
if (newline) {
size_t nameLen = static_cast<size_t>(newline - text);
if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1;
memcpy(roomName_, text, nameLen);
roomName_[nameLen] = '\0';
strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1);
tempFormat_[MAX_TEXT_LEN - 1] = '\0';
} else {
strncpy(roomName_, text, MAX_TEXT_LEN - 1);
roomName_[MAX_TEXT_LEN - 1] = '\0';
}
}
}
void RoomCardWidget::calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y) {
int16_t centerX = config_.width / 2;
int16_t centerY = config_.height / 2;
// Sub-button size (configurable, default 40)
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
// Sub-buttons orbit radius in pixels (default 80)
int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80;
// Calculate angle: 0=TOP, going clockwise, 8 positions = 45 degrees each
float angle = static_cast<float>(static_cast<uint8_t>(pos)) * (M_PI / 4.0f) - (M_PI / 2.0f);
// Calculate position (center of sub-button)
x = centerX + static_cast<int16_t>(orbitRadius * cosf(angle)) - subBtnSize / 2;
y = centerY + static_cast<int16_t>(orbitRadius * sinf(angle)) - subBtnSize / 2;
}
lv_obj_t* RoomCardWidget::create(lv_obj_t* parent) {
parseText();
// Create main container
obj_ = lv_obj_create(parent);
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_, config_.width, config_.height);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
// Create central bubble first
createCentralBubble();
// Create sub-buttons
createSubButtons();
return obj_;
}
void RoomCardWidget::createCentralBubble() {
// Calculate bubble size (80% of widget size, circular)
int16_t minSide = config_.width < config_.height ? config_.width : config_.height;
int16_t bubbleSize = (minSide * 80) / 100;
// Create bubble container (centered)
bubble_ = lv_obj_create(obj_);
lv_obj_remove_style_all(bubble_);
lv_obj_set_size(bubble_, bubbleSize, bubbleSize);
lv_obj_center(bubble_);
lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE);
// Circular shape
lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0);
lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0);
// Set up flex layout for bubble content
lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(bubble_, 8, 0);
lv_obj_set_style_pad_gap(bubble_, 2, 0);
// Room icon
if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) {
roomIcon_ = lv_label_create(bubble_);
lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(roomIcon_, iconText);
}
// Temperature label
tempLabel_ = lv_label_create(bubble_);
lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE);
lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : "");
// Room name label
if (roomName_[0] != '\0') {
roomLabel_ = lv_label_create(bubble_);
lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE);
lv_label_set_text(roomLabel_, roomName_);
}
// Click handler for navigation
lv_obj_add_event_cb(bubble_, bubbleClickCallback, LV_EVENT_CLICKED, this);
}
void RoomCardWidget::createSubButtons() {
// Sub-button size (configurable, default 40)
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
const SubButtonConfig& cfg = config_.subButtons[i];
if (!cfg.enabled) continue;
// Create sub-button object
lv_obj_t* btn = lv_obj_create(obj_);
lv_obj_remove_style_all(btn);
lv_obj_set_size(btn, subBtnSize, subBtnSize);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
// Circular shape
lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(btn, 255, 0);
// Position using circle geometry
int16_t x, y;
calculateSubButtonPosition(cfg.position, x, y);
lv_obj_set_pos(btn, x, y);
// Create icon
if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) {
lv_obj_t* icon = lv_label_create(btn);
lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE);
char iconText[5];
encodeUtf8(cfg.iconCodepoint, iconText);
lv_label_set_text(icon, iconText);
lv_obj_center(icon);
subButtonIcons_[i] = icon;
}
// Store index in user_data for click handler
lv_obj_set_user_data(btn, reinterpret_cast<void*>(static_cast<uintptr_t>(i)));
lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this);
subButtonObjs_[i] = btn;
subButtonStates_[i] = false;
// Apply initial color (OFF state)
updateSubButtonColor(i);
}
}
void RoomCardWidget::applyStyle() {
if (!obj_) return;
// Apply shadow to bubble if enabled
if (bubble_ && config_.shadow.enabled) {
lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0);
lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0);
lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0);
lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0);
lv_obj_set_style_shadow_opa(bubble_, 255, 0);
}
// Font sizes
const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize);
const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize);
const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0);
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
// Style room icon
if (roomIcon_ && iconFont) {
lv_obj_set_style_text_font(roomIcon_, iconFont, 0);
lv_obj_set_style_text_color(roomIcon_, textColor, 0);
}
// Style temperature
if (tempLabel_ && textFont) {
lv_obj_set_style_text_font(tempLabel_, textFont, 0);
lv_obj_set_style_text_color(tempLabel_, textColor, 0);
}
// Style room label
if (roomLabel_ && labelFont) {
lv_obj_set_style_text_font(roomLabel_, labelFont, 0);
lv_obj_set_style_text_color(roomLabel_, lv_color_hex(config_.textColor.toLvColor()), 0);
lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed
}
// Style sub-buttons - adjust icon size based on button size
// subButtonSize: 30-40 -> font 1, 41-55 -> font 2, 56+ -> font 3
uint8_t subBtnFontIdx = 1; // Default small
if (config_.subButtonSize > 55) {
subBtnFontIdx = 3;
} else if (config_.subButtonSize > 40) {
subBtnFontIdx = 2;
}
const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx);
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
if (subButtonIcons_[i] && subBtnIconFont) {
lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0);
lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0);
}
}
}
void RoomCardWidget::updateSubButtonColor(uint8_t index) {
if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return;
const SubButtonConfig& cfg = config_.subButtons[index];
const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff;
lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0);
}
void RoomCardWidget::updateTemperature(float value) {
if (!tempLabel_ || tempFormat_[0] == '\0') return;
char buf[32];
snprintf(buf, sizeof(buf), tempFormat_, value);
lv_label_set_text(tempLabel_, buf);
}
void RoomCardWidget::onKnxValue(float value) {
cachedPrimaryValue_ = value;
hasCachedPrimary_ = true;
updateTemperature(value);
}
void RoomCardWidget::onKnxSwitch(bool value) {
// Not used directly - sub-button status is handled via onSubButtonStatus
(void)value;
}
void RoomCardWidget::onSubButtonStatus(uint8_t index, bool value) {
if (index >= MAX_SUBBUTTONS) return;
subButtonStates_[index] = value;
updateSubButtonColor(index);
}
void RoomCardWidget::sendSubButtonToggle(uint8_t index) {
if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return;
const SubButtonConfig& cfg = config_.subButtons[index];
if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) {
bool newState = !subButtonStates_[index];
// Send KNX toggle via WidgetManager
WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState);
// Optimistically update local state
subButtonStates_[index] = newState;
updateSubButtonColor(index);
}
}
void RoomCardWidget::bubbleClickCallback(lv_event_t* e) {
RoomCardWidget* widget = static_cast<RoomCardWidget*>(lv_event_get_user_data(e));
if (!widget) return;
// Handle navigation based on action
if (widget->config_.action == ButtonAction::JUMP) {
WidgetManager::instance().navigateToScreen(widget->config_.targetScreen);
} else if (widget->config_.action == ButtonAction::BACK) {
WidgetManager::instance().navigateBack();
}
}
void RoomCardWidget::subButtonClickCallback(lv_event_t* e) {
RoomCardWidget* widget = static_cast<RoomCardWidget*>(lv_event_get_user_data(e));
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
if (!widget || !target) return;
uint8_t index = static_cast<uint8_t>(reinterpret_cast<uintptr_t>(lv_obj_get_user_data(target)));
if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) {
const SubButtonConfig& cfg = widget->config_.subButtons[index];
if (cfg.action == SubButtonAction::TOGGLE_KNX) {
widget->sendSubButtonToggle(index);
} else if (cfg.action == SubButtonAction::NAVIGATE) {
WidgetManager::instance().navigateToScreen(cfg.targetScreen);
}
}
}

View File

@ -2,11 +2,11 @@
#include "Widget.hpp"
class RoomCardWidgetBase : public Widget {
class RoomCardWidget : public Widget {
public:
explicit RoomCardWidgetBase(const WidgetConfig& config);
~RoomCardWidgetBase() override;
explicit RoomCardWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
void onKnxValue(float value) override; // Temperature update
void onKnxSwitch(bool value) override; // Not used directly
@ -14,14 +14,10 @@ public:
void onSubButtonStatus(uint8_t index, bool value);
void sendSubButtonToggle(uint8_t index);
// Clear LVGL objects
virtual void clearLvglObject();
protected:
// Parent (screen) for sub-buttons to avoid clipping
lv_obj_t* screenParent_ = nullptr;
// Common elements
private:
// Central bubble elements
lv_obj_t* bubble_ = nullptr;
lv_obj_t* roomIcon_ = nullptr;
lv_obj_t* roomLabel_ = nullptr;
lv_obj_t* tempLabel_ = nullptr;
@ -34,18 +30,18 @@ protected:
char roomName_[MAX_TEXT_LEN] = {0};
char tempFormat_[MAX_TEXT_LEN] = {0};
// Common helpers
void parseTextBase();
// Layout helpers
void parseText();
void createCentralBubble();
void createSubButtons();
void updateSubButtonColor(uint8_t index);
void updateTemperature(float value);
// Virtual methods for layout-specific sub-button positioning
virtual void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) = 0;
void createSubButtonsCommon();
void applySubButtonStyle();
// Geometry calculations
void calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y);
// Event handlers
static void cardClickCallback(lv_event_t* e);
static void bubbleClickCallback(lv_event_t* e);
static void subButtonClickCallback(lv_event_t* e);
// UTF-8 encoding helper

View File

@ -1,231 +0,0 @@
#include "RoomCardWidgetBase.hpp"
#include "../Fonts.hpp"
#include "../WidgetManager.hpp"
#include "esp_log.h"
#include <cstring>
#include <cstdio>
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
RoomCardWidgetBase::RoomCardWidgetBase(const WidgetConfig& config)
: Widget(config)
{
roomName_[0] = '\0';
tempFormat_[0] = '\0';
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
subButtonStates_[i] = false;
}
}
RoomCardWidgetBase::~RoomCardWidgetBase() {
// Sub-buttons are on screen parent, not on obj_, so delete them explicitly
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
if (subButtonObjs_[i] && lv_obj_is_valid(subButtonObjs_[i])) {
lv_obj_delete(subButtonObjs_[i]);
subButtonObjs_[i] = nullptr;
}
}
}
void RoomCardWidgetBase::clearLvglObject() {
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
subButtonObjs_[i] = nullptr;
subButtonIcons_[i] = nullptr;
}
roomLabel_ = nullptr;
tempLabel_ = nullptr;
obj_ = nullptr;
}
int RoomCardWidgetBase::encodeUtf8(uint32_t codepoint, char* buf) {
if (codepoint < 0x80) {
buf[0] = static_cast<char>(codepoint);
buf[1] = '\0';
return 1;
} else if (codepoint < 0x800) {
buf[0] = static_cast<char>(0xC0 | (codepoint >> 6));
buf[1] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[2] = '\0';
return 2;
} else if (codepoint < 0x10000) {
buf[0] = static_cast<char>(0xE0 | (codepoint >> 12));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[2] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[3] = '\0';
return 3;
} else if (codepoint < 0x110000) {
buf[0] = static_cast<char>(0xF0 | (codepoint >> 18));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F));
buf[2] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[3] = static_cast<char>(0x80 | (codepoint & 0x3F));
buf[4] = '\0';
return 4;
}
buf[0] = '\0';
return 0;
}
void RoomCardWidgetBase::parseTextBase() {
roomName_[0] = '\0';
tempFormat_[0] = '\0';
const char* text = config_.text;
if (text && text[0] != '\0') {
const char* newline = strchr(text, '\n');
if (newline) {
size_t nameLen = static_cast<size_t>(newline - text);
if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1;
memcpy(roomName_, text, nameLen);
roomName_[nameLen] = '\0';
strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1);
tempFormat_[MAX_TEXT_LEN - 1] = '\0';
} else {
strncpy(roomName_, text, MAX_TEXT_LEN - 1);
roomName_[MAX_TEXT_LEN - 1] = '\0';
}
}
}
void RoomCardWidgetBase::createSubButtonsCommon() {
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
const SubButtonConfig& cfg = config_.subButtons[i];
if (!cfg.enabled) continue;
// Create sub-button on screen parent (not on widget container) to avoid clipping
lv_obj_t* btn = lv_obj_create(screenParent_);
lv_obj_remove_style_all(btn);
lv_obj_set_size(btn, subBtnSize, subBtnSize);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
// Circular shape
lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0);
// Position using layout-specific calculation
int16_t relX, relY;
calculateSubButtonPosition(i, cfg, relX, relY);
int16_t absX = config_.x + relX;
int16_t absY = config_.y + relY;
lv_obj_set_pos(btn, absX, absY);
// Create icon
if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) {
lv_obj_t* icon = lv_label_create(btn);
lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE);
char iconText[5];
encodeUtf8(cfg.iconCodepoint, iconText);
lv_label_set_text(icon, iconText);
lv_obj_center(icon);
subButtonIcons_[i] = icon;
}
// Store index in user_data for click handler
lv_obj_set_user_data(btn, reinterpret_cast<void*>(static_cast<uintptr_t>(i)));
lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this);
subButtonObjs_[i] = btn;
subButtonStates_[i] = false;
// Apply initial color (OFF state)
updateSubButtonColor(i);
}
}
void RoomCardWidgetBase::applySubButtonStyle() {
uint8_t subBtnFontIdx = 1; // Default small
if (config_.subButtonSize > 55) {
subBtnFontIdx = 3;
} else if (config_.subButtonSize > 40) {
subBtnFontIdx = 2;
}
const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx);
for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) {
if (subButtonIcons_[i] && subBtnIconFont) {
lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0);
lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0);
}
}
}
void RoomCardWidgetBase::updateSubButtonColor(uint8_t index) {
if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return;
const SubButtonConfig& cfg = config_.subButtons[index];
const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff;
lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0);
}
void RoomCardWidgetBase::updateTemperature(float value) {
if (!tempLabel_ || tempFormat_[0] == '\0') return;
char buf[32];
snprintf(buf, sizeof(buf), tempFormat_, value);
lv_label_set_text(tempLabel_, buf);
}
void RoomCardWidgetBase::onKnxValue(float value) {
cachedPrimaryValue_ = value;
hasCachedPrimary_ = true;
updateTemperature(value);
}
void RoomCardWidgetBase::onKnxSwitch(bool value) {
// Not used directly - sub-button status is handled via onSubButtonStatus
(void)value;
}
void RoomCardWidgetBase::onSubButtonStatus(uint8_t index, bool value) {
if (index >= MAX_SUBBUTTONS) return;
subButtonStates_[index] = value;
updateSubButtonColor(index);
}
void RoomCardWidgetBase::sendSubButtonToggle(uint8_t index) {
if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return;
const SubButtonConfig& cfg = config_.subButtons[index];
if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) {
bool newState = !subButtonStates_[index];
WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState);
subButtonStates_[index] = newState;
updateSubButtonColor(index);
}
}
void RoomCardWidgetBase::cardClickCallback(lv_event_t* e) {
RoomCardWidgetBase* widget = static_cast<RoomCardWidgetBase*>(lv_event_get_user_data(e));
if (!widget) return;
if (widget->config_.action == ButtonAction::JUMP) {
WidgetManager::instance().navigateToScreen(widget->config_.targetScreen);
} else if (widget->config_.action == ButtonAction::BACK) {
WidgetManager::instance().navigateBack();
}
}
void RoomCardWidgetBase::subButtonClickCallback(lv_event_t* e) {
RoomCardWidgetBase* widget = static_cast<RoomCardWidgetBase*>(lv_event_get_user_data(e));
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
if (!widget || !target) return;
uint8_t index = static_cast<uint8_t>(reinterpret_cast<uintptr_t>(lv_obj_get_user_data(target)));
if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) {
const SubButtonConfig& cfg = widget->config_.subButtons[index];
if (cfg.action == SubButtonAction::TOGGLE_KNX) {
widget->sendSubButtonToggle(index);
} else if (cfg.action == SubButtonAction::NAVIGATE) {
WidgetManager::instance().navigateToScreen(cfg.targetScreen);
}
}
}

View File

@ -39,22 +39,5 @@ void TabPageWidget::applyStyle() {
lv_obj_set_style_bg_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
} else {
lv_obj_set_style_bg_opa(obj_, LV_OPA_TRANSP, 0);
}
if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
}
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, 0, 0);
}
applyShadowStyle();
}

View File

@ -183,16 +183,6 @@ void Widget::applyCommonStyle() {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
}
// Border
if (config_.borderWidth > 0 && config_.borderOpacity > 0) {
lv_obj_set_style_border_width(obj_, config_.borderWidth, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.borderColor.r, config_.borderColor.g, config_.borderColor.b), 0);
lv_obj_set_style_border_opa(obj_, config_.borderOpacity, 0);
} else {
lv_obj_set_style_border_width(obj_, 0, 0);
}
// Shadow
applyShadowStyle();
}

View File

@ -10,11 +10,7 @@
#include "PowerLinkWidget.hpp"
#include "ChartWidget.hpp"
#include "ClockWidget.hpp"
#include "RoomCardBubbleWidget.hpp"
#include "RoomCardTileWidget.hpp"
#include "RectangleWidget.hpp"
#include "ArcWidget.hpp"
#include "ButtonMatrixWidget.hpp"
#include "RoomCardWidget.hpp"
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
if (!config.visible) return nullptr;
@ -43,17 +39,7 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
case WidgetType::CLOCK:
return std::make_unique<ClockWidget>(config);
case WidgetType::ROOMCARD:
// cardStyle: 0=Bubble, 1=Tile
if (config.cardStyle == 1) {
return std::make_unique<RoomCardTileWidget>(config);
}
return std::make_unique<RoomCardBubbleWidget>(config);
case WidgetType::RECTANGLE:
return std::make_unique<RectangleWidget>(config);
case WidgetType::ARC:
return std::make_unique<ArcWidget>(config);
case WidgetType::BUTTONMATRIX:
return std::make_unique<ButtonMatrixWidget>(config);
return std::make_unique<RoomCardWidget>(config);
default:
return nullptr;
}

View File

@ -10,15 +10,8 @@ CONFIG_LV_FONT_MONTSERRAT_48=y
# Keep LVGL draw thread stack reasonable to avoid xTaskCreate failures
CONFIG_LV_DRAW_THREAD_STACK_SIZE=32768
# Use C library malloc instead of LVGL builtin - enables PSRAM usage
CONFIG_LV_USE_BUILTIN_MALLOC=n
CONFIG_LV_USE_CLIB_MALLOC=y
# Increase draw layer buffer for large icons (250px icons need ~120KB)
CONFIG_LV_DRAW_LAYER_SIMPLE_BUF_SIZE=131072
# Backup LVGL heap size (used if builtin malloc is enabled)
CONFIG_LV_MEM_SIZE_KILOBYTES=512
# Increase LVGL heap to avoid draw task OOM with dynamic UI
CONFIG_LV_MEM_SIZE_KILOBYTES=128
# Enable object names for LVGL debug mapping
CONFIG_LV_USE_OBJ_NAME=y
@ -29,10 +22,3 @@ CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE=y
# Increase main task stack for FreeType (needs ~16KB+)
CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384
# PSRAM configuration for large allocations
# Allocations > 1KB go to PSRAM to avoid internal RAM exhaustion
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=1024
# Allow PSRAM as malloc target
CONFIG_SPIRAM_USE_MALLOC=y

View File

@ -282,12 +282,7 @@ function resizeDrag(e) {
let newW = Math.round(rawW);
let newH = Math.round(rawH);
// Force square for LED, PowerNode, and RoomCard Bubble (not Tile)
const forceSquare = w.type === WIDGET_TYPES.LED ||
w.type === WIDGET_TYPES.POWERNODE ||
(w.type === WIDGET_TYPES.ROOMCARD && w.cardStyle !== 1);
if (forceSquare) {
if (w.type === WIDGET_TYPES.LED || w.type === WIDGET_TYPES.POWERNODE || w.type === WIDGET_TYPES.ROOMCARD) {
const maxSize = Math.min(maxW, maxH);
const size = clamp(Math.max(newW, newH), minSize.w, maxSize);
newW = size;

View File

@ -42,18 +42,6 @@
<span class="text-[13px] font-semibold">Chart</span>
<span class="text-[11px] text-muted mt-0.5 block">Verlauf</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('rectangle')">
<span class="text-[13px] font-semibold">Rechteck</span>
<span class="text-[11px] text-muted mt-0.5 block">Form</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('arc')">
<span class="text-[13px] font-semibold">Arc</span>
<span class="text-[11px] text-muted mt-0.5 block">Gauge</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('buttonmatrix')">
<span class="text-[13px] font-semibold">Button Matrix</span>
<span class="text-[11px] text-muted mt-0.5 block">Mehrfach</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('roomcard')">
<span class="text-[13px] font-semibold">Room Card</span>
<span class="text-[11px] text-muted mt-0.5 block">Raum</span>

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
<template>
<div class="relative">
<div
class="group flex items-center gap-1.5 border border-transparent rounded-[4px] px-1.5 py-1 text-text cursor-pointer mb-px select-none hover:bg-panel-2"
class="flex items-center gap-1.5 border border-transparent rounded-[4px] px-1.5 py-1 text-text cursor-pointer mb-px select-none hover:bg-panel-2"
:class="{
'bg-accent-2/15 border-accent-2/30': store.selectedWidgetId === node.id,
'opacity-50': !node.visible,
@ -25,31 +25,10 @@
<span class="material-symbols-outlined text-[16px] text-accent opacity-80">{{ getIconForType(node.type) }}</span>
<div class="flex-1 min-w-0 flex flex-col overflow-hidden">
<div class="flex flex-col overflow-hidden">
<span class="text-[12px] truncate">{{ displayTitle(node) }}</span>
<span class="text-[9px] text-muted">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
</div>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
<button
class="w-4 h-4 grid place-items-center text-[10px] text-muted hover:text-text"
:class="canMoveUp ? '' : 'opacity-30 cursor-not-allowed'"
@click.stop="moveUp"
:disabled="!canMoveUp"
title="Nach oben"
>
<span class="material-symbols-outlined text-[14px]">arrow_upward</span>
</button>
<button
class="w-4 h-4 grid place-items-center text-[10px] text-muted hover:text-text"
:class="canMoveDown ? '' : 'opacity-30 cursor-not-allowed'"
@click.stop="moveDown"
:disabled="!canMoveDown"
title="Nach unten"
>
<span class="material-symbols-outlined text-[14px]">arrow_downward</span>
</button>
</div>
</div>
<div class="relative" v-if="node.children.length > 0 && expanded">
@ -65,7 +44,7 @@
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils';
import { TYPE_LABELS, WIDGET_TYPES } from '../constants';
@ -78,8 +57,6 @@ const props = defineProps({
const store = useEditorStore();
const expanded = ref(true);
const isDragOver = ref(false);
const canMoveUp = computed(() => store.canMoveWidget?.(props.node.id, 'up'));
const canMoveDown = computed(() => store.canMoveWidget?.(props.node.id, 'down'));
function toggleExpand() {
expanded.value = !expanded.value;
@ -95,8 +72,6 @@ function getIconForType(type) {
case WIDGET_TYPES.TABPAGE: return 'article';
case WIDGET_TYPES.POWERFLOW: return 'device_hub';
case WIDGET_TYPES.POWERNODE: return 'radio_button_checked';
case WIDGET_TYPES.ARC: return 'donut_large';
case WIDGET_TYPES.BUTTONMATRIX: return 'dialpad';
default: return 'widgets';
}
}
@ -152,14 +127,4 @@ function onDrop(e, targetNode) {
store.reparentWidget(draggedId, targetNode.id);
}
}
function moveUp() {
if (!canMoveUp.value) return;
store.moveWidget(props.node.id, 'up');
}
function moveDown() {
if (!canMoveDown.value) return;
store.moveWidget(props.node.id, 'down');
}
</script>

View File

@ -1,34 +1,213 @@
<template>
<component
:is="widgetComponent"
:widget="widget"
<div
class="z-[1] select-none touch-none"
:class="[
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
isLabel ? 'px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap' : ''
]"
:style="computedStyle"
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@click.stop="handleWidgetClick"
>
<!-- Recursive Children -->
<!-- Special handling for TabView to render structure -->
<template v-if="isTabView">
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
<div
v-for="(child, idx) in children"
:key="child.id"
:class="tabBtnClass(activePageId === child.id)"
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
>
{{ child.text }}
</div>
</div>
<div class="flex-1 relative overflow-hidden">
<WidgetElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="selected"
@select="$emit('select')"
:selected="store.selectedWidgetId === child.id"
:style="{ display: activePageId === child.id ? 'block' : 'none' }"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</div>
</div>
</template>
<template v-else-if="isPowerFlow">
<div class="absolute inset-0" :style="powerFlowBgStyle"></div>
<div v-if="widget.text" class="absolute left-4 top-3 text-[13px] uppercase tracking-[0.08em]" :style="{ color: widget.textColor }">
{{ widget.text }}
</div>
<svg class="absolute inset-0 pointer-events-none" :width="widget.w * scale" :height="widget.h * scale">
<path
v-for="link in powerFlowLinks"
:key="`link-${link.id}`"
:d="link.path"
:stroke="link.color"
:stroke-width="link.width"
stroke-linecap="round"
fill="none"
:opacity="link.opacity"
/>
<circle
v-for="link in powerFlowLinks"
:key="`dot-${link.id}`"
:r="link.dotRadius"
:fill="link.color"
:opacity="link.opacity"
>
<animateMotion
:dur="`${link.duration}s`"
repeatCount="indefinite"
:path="link.path"
/>
</circle>
</svg>
<div v-if="!powerNodes.length" class="absolute inset-0 grid place-items-center text-[12px] text-muted">
Power Nodes hinzufuegen
</div>
<WidgetElement
v-for="child in powerFlowChildren"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</template>
<script setup>
import { computed, markRaw } from 'vue';
import { WIDGET_TYPES } from '../constants';
<!-- Standard Recursive Children (for Buttons, Pages, etc) -->
<template v-else>
<WidgetElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</template>
// Import all widget element components
import LabelElement from './widgets/elements/LabelElement.vue';
import ButtonElement from './widgets/elements/ButtonElement.vue';
import LedElement from './widgets/elements/LedElement.vue';
import IconElement from './widgets/elements/IconElement.vue';
import TabViewElement from './widgets/elements/TabViewElement.vue';
import TabPageElement from './widgets/elements/TabPageElement.vue';
import PowerFlowElement from './widgets/elements/PowerFlowElement.vue';
import PowerNodeElement from './widgets/elements/PowerNodeElement.vue';
import ChartElement from './widgets/elements/ChartElement.vue';
import ClockElement from './widgets/elements/ClockElement.vue';
import RoomCardElement from './widgets/elements/RoomCardElement.vue';
import RectangleElement from './widgets/elements/RectangleElement.vue';
import ArcElement from './widgets/elements/ArcElement.vue';
import ButtonMatrixElement from './widgets/elements/ButtonMatrixElement.vue';
<!-- Power Node Widget -->
<template v-if="isPowerNode">
<div class="flex flex-col items-center justify-center w-full h-full text-center leading-tight">
<span v-if="powerNodeParts.label" :style="powerNodeLabelStyle">{{ powerNodeParts.label }}</span>
<span v-if="widget.iconCodepoint" class="material-symbols-outlined mt-1" :style="powerNodeIconStyle">
{{ iconChar }}
</span>
<span v-if="powerNodeParts.value" class="mt-1" :style="powerNodeValueStyle">{{ powerNodeParts.value }}</span>
</div>
</template>
<template v-else-if="isChart">
<div class="w-full h-full flex flex-col gap-2">
<div class="text-[11px] uppercase tracking-[0.12em] opacity-80">
{{ widget.text || 'Chart' }}
</div>
<div class="flex-1 rounded-[10px] bg-black/20 relative overflow-hidden">
<div class="absolute inset-0 opacity-30" style="background-image: linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px); background-size: 24px 24px;"></div>
<svg class="absolute inset-0" viewBox="0 0 100 40" preserveAspectRatio="none">
<path d="M0,30 L15,22 L30,26 L45,14 L60,18 L75,10 L100,16" fill="none" stroke="rgba(239,99,81,0.8)" stroke-width="2" />
<path d="M0,34 L20,28 L40,32 L60,20 L80,24 L100,18" fill="none" stroke="rgba(125,211,176,0.8)" stroke-width="2" />
</svg>
</div>
</div>
</template>
<template v-else-if="isClock">
<div class="relative w-full h-full rounded-full border-2 box-border flex items-center justify-center overflow-hidden" :style="{ borderColor: widget.textColor }">
<!-- Center Dot -->
<div class="absolute w-2 h-2 rounded-full z-10" :style="{ backgroundColor: widget.textColor }"></div>
<!-- Hour Hand -->
<div class="absolute w-1.5 h-[28%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(300deg)' }"></div>
<!-- Minute Hand -->
<div class="absolute w-1 h-[40%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(70deg)' }"></div>
<!-- Second Hand -->
<div class="absolute w-0.5 h-[45%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full bg-[#c83232]" :style="{ transform: 'rotate(140deg)' }"></div>
</div>
</template>
<!-- RoomCard Widget -->
<template v-else-if="isRoomCard">
<!-- Central Bubble -->
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
{{ iconChar }}
</span>
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
</div>
<!-- Sub-Buttons -->
<div
v-for="(sb, idx) in roomCardSubButtons"
:key="idx"
class="absolute rounded-full flex items-center justify-center shadow-md"
:style="getSubButtonStyle(sb, idx)"
>
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
{{ String.fromCodePoint(sb.icon) }}
</span>
</div>
</template>
<!-- Icon-only Widget -->
<template v-else-if="isIcon">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
{{ iconChar }}
</span>
</template>
<!-- Label/Button with Icon -->
<template v-else-if="hasIcon">
<div :style="contentStyle">
<span
v-if="iconPosition === 0 || iconPosition === 2"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
<span class="flex-shrink-0">{{ widget.text }}</span>
<span
v-if="iconPosition === 1 || iconPosition === 3"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
</div>
</template>
<!-- Standard (no icon) -->
<template v-else>
<span v-if="showDefaultText">{{ widget.text }}</span>
</template>
<!-- Resize Handle (at end to be on top) -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
import { clamp, hexToRgba } from '../utils';
const props = defineProps({
widget: { type: Object, required: true },
@ -36,27 +215,562 @@ const props = defineProps({
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const emit = defineEmits(['select', 'drag-start', 'resize-start']);
// Map widget types to components
const componentMap = {
[WIDGET_TYPES.LABEL]: markRaw(LabelElement),
[WIDGET_TYPES.BUTTON]: markRaw(ButtonElement),
[WIDGET_TYPES.LED]: markRaw(LedElement),
[WIDGET_TYPES.ICON]: markRaw(IconElement),
[WIDGET_TYPES.TABVIEW]: markRaw(TabViewElement),
[WIDGET_TYPES.TABPAGE]: markRaw(TabPageElement),
[WIDGET_TYPES.POWERFLOW]: markRaw(PowerFlowElement),
[WIDGET_TYPES.POWERNODE]: markRaw(PowerNodeElement),
[WIDGET_TYPES.CHART]: markRaw(ChartElement),
[WIDGET_TYPES.CLOCK]: markRaw(ClockElement),
[WIDGET_TYPES.ROOMCARD]: markRaw(RoomCardElement),
[WIDGET_TYPES.RECTANGLE]: markRaw(RectangleElement),
[WIDGET_TYPES.ARC]: markRaw(ArcElement),
[WIDGET_TYPES.BUTTONMATRIX]: markRaw(ButtonMatrixElement)
const store = useEditorStore();
const children = computed(() => {
if (!store.activeScreen) return [];
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
});
const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
const isTabPage = computed(() => props.widget.type === WIDGET_TYPES.TABPAGE);
const isButtonContainer = computed(() => isButton.value && props.widget.isContainer);
const isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW);
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
const isClock = computed(() => props.widget.type === WIDGET_TYPES.CLOCK);
const isRoomCard = computed(() => props.widget.type === WIDGET_TYPES.ROOMCARD);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
// Find active tab index (client-side state only, maybe store in widget temporarily?)
// For designer simplicity: show all tabs content stacked, or just first one?
// Better: mimic LVGL. We need state. Let's use a local ref or just show the selected one in tree?
// To keep it simple: Show the active tab based on which child is selected or default to first.
const activeTabIndex = ref(0);
const activePageId = computed(() => {
// If a child page is selected, make it active
const selectedChild = children.value.find(c => store.selectedWidgetId === c.id);
if (selectedChild) return selectedChild.id;
// If a widget inside a page is selected
if (store.selectedWidget && store.selectedWidget.parentId !== -1) {
// Find ancestor page
let curr = store.selectedWidget;
while (curr && curr.parentId !== -1 && curr.parentId !== props.widget.id) {
curr = store.activeScreen.widgets.find(w => w.id === curr.parentId);
}
if (curr && curr.parentId === props.widget.id) return curr.id;
}
if (children.value.length > 0) return children.value[activeTabIndex.value]?.id;
return -1;
});
const hasIcon = computed(() => {
if (isButtonContainer.value) return false;
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
});
const iconChar = computed(() => {
const cp = props.widget.iconCodepoint || 0xe88a;
return String.fromCodePoint(cp);
});
const iconPosition = computed(() => props.widget.iconPosition || 0);
const textAlign = computed(() => props.widget.textAlign ?? 1);
const showDefaultText = computed(() => {
if (isTabView.value || isTabPage.value) return false;
if (isPowerFlow.value || isPowerNode.value) return false;
if (isPowerLink.value) return false;
if (isButtonContainer.value) return false;
if (isRoomCard.value) return false;
return true;
});
const justifyForAlign = (align) => {
if (align === 0) return 'flex-start';
if (align === 2) return 'flex-end';
return 'center';
};
const widgetComponent = computed(() => {
return componentMap[props.widget.type] || LabelElement;
const textAlignCss = (align) => {
if (align === 0) return 'left';
if (align === 2) return 'right';
return 'center';
};
const contentJustify = computed(() => {
if (isButton.value || isLabel.value) return justifyForAlign(textAlign.value);
return 'center';
});
const isVerticalLayout = computed(() => {
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
});
const contentStyle = computed(() => {
const s = props.scale;
const gap = (props.widget.iconGap || 8) * s;
return {
display: 'flex',
flexDirection: isVerticalLayout.value ? 'column' : 'row',
alignItems: 'center',
justifyContent: contentJustify.value,
gap: `${gap}px`,
width: '100%',
height: '100%'
};
});
const iconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
const size = fontSizes[sizeIdx] || 18;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const iconOnlyStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? 3;
const size = fontSizes[sizeIdx] || 28;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const powerNodeParts = computed(() => splitPowerNodeText(props.widget.text));
const powerNodeValueSize = computed(() => {
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeLabelSize = computed(() => {
return Math.max(10 * props.scale, powerNodeValueSize.value * 0.55);
});
const powerNodeIconSize = computed(() => {
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeIconStyle = computed(() => ({
fontSize: `${powerNodeIconSize.value}px`,
color: props.widget.textColor
}));
const powerNodeLabelStyle = computed(() => ({
fontSize: `${powerNodeLabelSize.value}px`,
color: hexToRgba(props.widget.textColor, 0.72)
}));
const powerNodeValueStyle = computed(() => ({
fontSize: `${powerNodeValueSize.value}px`,
color: props.widget.textColor,
fontWeight: '600'
}));
const powerNodes = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERNODE && child.visible !== false);
});
const powerLinkWidgets = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERLINK && child.visible !== false);
});
const powerFlowChildren = computed(() => {
if (!isPowerFlow.value) return [];
return children.value.filter((child) => child.type !== WIDGET_TYPES.POWERLINK);
});
const powerFlowLinks = computed(() => {
if (!isPowerFlow.value || powerNodes.value.length < 2 || !powerLinkWidgets.value.length) return [];
const s = props.scale;
const nodeMap = new Map(powerNodes.value.map((node) => [node.id, node]));
return powerLinkWidgets.value.flatMap((link, idx) => {
const fromNode = nodeMap.get(link.x);
const toNode = nodeMap.get(link.y);
if (!fromNode || !toNode) return [];
const lineWidth = Math.max(3, link.w || 3);
const fromCenter = {
x: (fromNode.x + fromNode.w / 2) * s,
y: (fromNode.y + fromNode.h / 2) * s
};
const toCenter = {
x: (toNode.x + toNode.w / 2) * s,
y: (toNode.y + toNode.h / 2) * s
};
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const fromRadius = Math.max(0, (Math.min(fromNode.w, fromNode.h) * 0.5 - lineWidth * 0.5) * s);
const toRadius = Math.max(0, (Math.min(toNode.w, toNode.h) * 0.5 - lineWidth * 0.5) * s);
let startX = fromCenter.x + ux * fromRadius;
let startY = fromCenter.y + uy * fromRadius;
let endX = toCenter.x - ux * toRadius;
let endY = toCenter.y - uy * toRadius;
if (len <= fromRadius + toRadius + 1) {
startX = fromCenter.x;
startY = fromCenter.y;
endX = toCenter.x;
endY = toCenter.y;
}
const dxTrim = endX - startX;
const dyTrim = endY - startY;
const lenTrim = Math.hypot(dxTrim, dyTrim) || 1;
const nx = -dyTrim / lenTrim;
const ny = dxTrim / lenTrim;
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const curveSign = idx % 2 === 0 ? 1 : -1;
const curve = Math.min(42 * s, lenTrim * 0.3) * curveSign;
const cpx = midX + nx * curve;
const cpy = midY + ny * curve;
const dotRadius = Math.min(8, Math.max(4, lineWidth * 1.6));
const rawValue = parseFloat(link.text);
const hasRaw = Number.isFinite(rawValue);
const isStatic = (link.textSrc ?? 0) === 0;
const factor = hasRaw ? rawValue : (isStatic ? 60 : 1);
const previewValue = 50;
const speed = Math.max(5, isStatic ? factor : Math.abs(previewValue) * factor);
const duration = Math.max(2, Math.min(10, lenTrim / speed));
return [{
id: link.id,
path: `M ${startX} ${startY} Q ${cpx} ${cpy} ${endX} ${endY}`,
color: link.bgColor || '#6fa7d8',
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
width: lineWidth,
dotRadius,
duration
}];
});
});
const powerFlowBgStyle = computed(() => {
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
const base = hexToRgba(props.widget.bgColor, alpha);
const dot = hexToRgba('#9aa7b4', 0.25);
const dotSize = 16 * props.scale;
return {
backgroundColor: base,
backgroundImage: `radial-gradient(${dot} 0.9px, transparent 1px), linear-gradient(140deg, rgba(255,255,255,0.9) 0%, ${base} 70%)`,
backgroundSize: `${dotSize}px ${dotSize}px, 100% 100%`,
backgroundPosition: '0 0, 0 0'
};
});
// RoomCard computed properties
const roomCardParts = computed(() => {
if (!props.widget.text) return { name: '', format: '' };
const parts = props.widget.text.split('\n');
return {
name: parts[0] || '',
format: parts[1] || ''
};
});
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
const roomCardBubbleStyle = computed(() => {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const padding = subBtnSize * 0.6;
const bubbleSize = Math.min(w, h) * s - padding * 2;
const left = (w * s - bubbleSize) / 2;
const top = (h * s - bubbleSize) / 2;
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
return {
width: `${bubbleSize}px`,
height: `${bubbleSize}px`,
left: `${left}px`,
top: `${top}px`,
backgroundColor: hexToRgba(props.widget.bgColor, alpha),
color: props.widget.textColor,
boxShadow: props.widget.shadow?.enabled
? `${(props.widget.shadow.x || 0) * s}px ${(props.widget.shadow.y || 0) * s}px ${(props.widget.shadow.blur || 0) * s}px ${hexToRgba(props.widget.shadow.color || '#000000', 0.3)}`
: '0 4px 12px rgba(0,0,0,0.15)'
};
});
const roomCardIconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? 3;
const size = fontSizes[sizeIdx] || 28;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const roomCardNameStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.7}px`,
color: props.widget.textColor
};
});
const roomCardTempStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.55}px`,
color: props.widget.textColor
};
});
function getSubButtonStyle(sb, idx) {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const centerX = w * s / 2;
const centerY = h * s / 2;
// Distance from center in pixels (default 80)
const orbitRadius = (props.widget.subButtonDistance || 80) * s;
// Position based on sb.pos (0=Top, 1=TopRight, 2=Right, etc.)
const pos = sb.pos ?? idx;
const angle = (pos * (Math.PI / 4)) - (Math.PI / 2); // Start from top, go clockwise
const x = centerX + orbitRadius * Math.cos(angle) - subBtnSize / 2;
const y = centerY + orbitRadius * Math.sin(angle) - subBtnSize / 2;
// Use colorOff for preview (no KNX state in editor)
const bgColor = sb.colorOff || '#666666';
return {
width: `${subBtnSize}px`,
height: `${subBtnSize}px`,
left: `${x}px`,
top: `${y}px`,
backgroundColor: bgColor,
border: `2px solid ${hexToRgba('#ffffff', 0.3)}`
};
}
function getSubButtonIconStyle(sb) {
const s = props.scale;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const iconSize = subBtnSize * 0.5;
return {
fontSize: `${iconSize}px`,
color: '#ffffff'
};
}
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = {
left: `${w.x * s}px`,
top: `${w.y * s}px`,
width: `${w.w * s}px`,
height: `${w.h * s}px`,
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
color: w.textColor,
position: 'absolute',
zIndex: 1,
cursor: 'move',
userSelect: 'none',
touchAction: 'none'
};
if (isIcon.value) {
// Icon widget
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
}
} else if (isLabel.value) {
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
style.display = 'flex';
style.alignItems = 'center';
if (!hasIcon.value) {
style.justifyContent = justifyForAlign(textAlign.value);
style.textAlign = textAlignCss(textAlign.value);
}
} else if (isButton.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = contentJustify.value;
style.fontWeight = '600';
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
}
} else if (isLed.value) {
style.borderRadius = '999px';
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
const highlight = clamp(brightness + 0.25, 0, 1);
const core = clamp(brightness, 0, 1);
const edge = clamp(brightness * 0.5, 0, 1);
style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.4 + brightness * 0.6, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
} else {
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
}
} else if (isPowerFlow.value) {
style.borderRadius = `${w.radius * s}px`;
style.overflow = 'hidden';
style.border = `1px solid ${hexToRgba('#94a3b8', 0.35)}`;
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
}
} else if (isPowerNode.value) {
const ringAlpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const ring = Math.max(3, Math.round(Math.min(w.w, w.h) * 0.06 * s));
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
style.borderRadius = '999px';
style.background = hexToRgba('#ffffff', 0.96);
style.border = `${ring}px solid ${hexToRgba(w.bgColor, ringAlpha)}`;
style.textAlign = 'center';
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.35 + ringAlpha * 0.5, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(w.shadow.color || w.bgColor, glowAlpha)}`;
} else {
style.boxShadow = '0 8px 18px rgba(15, 23, 42, 0.12)';
}
if (store.powerLinkMode.active && store.powerLinkMode.powerflowId === w.parentId) {
style.cursor = 'crosshair';
}
if (store.powerLinkMode.active && store.powerLinkMode.fromNodeId === w.id && store.powerLinkMode.powerflowId === w.parentId) {
style.outline = `2px dashed ${hexToRgba('#4f8ad9', 0.9)}`;
style.outlineOffset = '2px';
}
} else if (isClock.value) {
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
} else {
style.borderRadius = '50%';
}
} else if (isTabView.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
// No flex here, we handle internal layout manually
} else if (isTabPage.value) {
style.position = 'relative'; // Relative to content area
style.width = '100%';
style.height = '100%';
style.left = '0';
style.top = '0';
} else if (isRoomCard.value) {
// RoomCard container - transparent, children handle rendering
style.overflow = 'visible';
}
return style;
});
const tabViewStyle = computed(() => {
return {
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
};
});
const tabBtnsStyle = computed(() => {
const s = props.scale;
const h = tabHeight.value * s;
const isVert = tabPosition.value === 0 || tabPosition.value === 1;
return {
[isVert ? 'height' : 'width']: `${h}px`,
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
flexDirection: isVert ? 'row' : 'column'
};
});
const tabBtnClass = (isActive) => {
const isVerticalTabs = tabPosition.value === 2 || tabPosition.value === 3;
const base = 'flex-1 flex items-center justify-center px-1 py-1 text-[12px] cursor-pointer select-none';
const border = isVerticalTabs ? 'border-b border-white/10' : 'border-b border-r border-white/10';
const active = isVerticalTabs
? 'bg-white/10 font-bold border-b-0 border-r-2 border-accent'
: 'bg-white/10 font-bold border-b-2 border-accent';
return `${base} ${border}${isActive ? ` ${active}` : ''}`;
};
function splitPowerNodeText(text) {
if (typeof text !== 'string') return { label: '', value: '' };
const parts = text.split('\n');
const label = parts[0] ?? '';
const value = parts.slice(1).join('\n');
return { label, value };
}
function handleWidgetClick() {
if (isPowerNode.value && store.powerLinkMode.active) {
store.handlePowerNodeLink(props.widget.id, props.widget.parentId);
return;
}
emit('select');
}
</script>

View File

@ -1,295 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<svg class="absolute inset-0" :viewBox="viewBox" preserveAspectRatio="xMidYMid meet">
<g v-if="scaleTicks.length">
<line
v-for="tick in scaleTicks"
:key="tick.key"
:x1="tick.x1"
:y1="tick.y1"
:x2="tick.x2"
:y2="tick.y2"
:stroke="tick.major ? scaleMajorColor : scaleMinorColor"
:stroke-width="tick.major ? scaleMajorWidth : scaleMinorWidth"
stroke-linecap="round"
/>
<text
v-for="label in scaleLabels"
:key="label.key"
:x="label.x"
:y="label.y"
:fill="scaleMajorColor"
:font-size="scaleFontSize"
text-anchor="middle"
dominant-baseline="middle"
>
{{ label.text }}
</text>
</g>
<path :d="trackPath" :stroke="trackColor" :stroke-width="strokeWidth" stroke-linecap="round" fill="none" />
<path v-if="valuePath" :d="valuePath" :stroke="indicatorColor" :stroke-width="strokeWidth" stroke-linecap="round" fill="none" />
<circle
v-if="knob"
:cx="knob.x"
:cy="knob.y"
:r="knob.r"
:fill="indicatorColor"
stroke="#0b0f14"
stroke-opacity="0.35"
stroke-width="1"
/>
</svg>
<div v-if="showValue" class="absolute font-semibold" :style="valueStyle">
{{ displayLabel }}
</div>
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { fontSizes } from '../../../constants';
import { getBaseStyle, getShadowStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const arcMin = computed(() => {
const val = Number(props.widget.arcMin ?? 0);
return Number.isFinite(val) ? val : 0;
});
const arcMax = computed(() => {
const val = Number(props.widget.arcMax ?? 100);
return Number.isFinite(val) ? val : 100;
});
const scaleMin = computed(() => (arcMax.value <= arcMin.value ? 0 : arcMin.value));
const scaleMax = computed(() => (arcMax.value <= arcMin.value ? 100 : arcMax.value));
const arcRange = computed(() => scaleMax.value - scaleMin.value);
const previewValue = computed(() => {
if (props.widget.textSrc === 2) return scaleMax.value;
if (props.widget.textSrc !== 0) return scaleMin.value + arcRange.value * 0.68;
const parsed = Number.parseFloat(props.widget.text ?? '0');
if (Number.isNaN(parsed)) return scaleMin.value;
return parsed;
});
const displayValue = computed(() => {
return clamp(previewValue.value, scaleMin.value, scaleMax.value);
});
const startAngle = 135;
const endAngle = 45;
const widthPx = computed(() => Math.max(10, props.widget.w * props.scale));
const heightPx = computed(() => Math.max(10, props.widget.h * props.scale));
const strokeWidth = computed(() => {
const base = Math.max(3, (props.widget.radius || 12) * props.scale);
const max = Math.min(widthPx.value, heightPx.value) / 2 - 2;
return Math.max(3, Math.min(base, max));
});
const radius = computed(() => {
const knobMargin = Math.max(2, strokeWidth.value * 0.35);
return Math.max(4, Math.min(widthPx.value, heightPx.value) / 2 - strokeWidth.value / 2 - knobMargin);
});
const center = computed(() => ({
x: widthPx.value / 2,
y: heightPx.value / 2
}));
const viewBox = computed(() => `0 0 ${widthPx.value} ${heightPx.value}`);
const sweep = computed(() => {
return endAngle >= startAngle ? endAngle - startAngle : 360 - startAngle + endAngle;
});
function polarToCartesian(cx, cy, r, angleDeg) {
const rad = (Math.PI / 180) * angleDeg;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad)
};
}
function describeArc(cx, cy, r, startDeg, endDeg) {
const start = polarToCartesian(cx, cy, r, startDeg);
const end = polarToCartesian(cx, cy, r, endDeg);
const sweepAngle = endDeg >= startDeg ? endDeg - startDeg : 360 - startDeg + endDeg;
const largeArc = sweepAngle > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
}
const trackPath = computed(() => {
return describeArc(center.value.x, center.value.y, radius.value, startAngle, endAngle);
});
const valueAngle = computed(() => {
const ratio = arcRange.value > 0 ? (displayValue.value - scaleMin.value) / arcRange.value : 0;
const deg = (startAngle + sweep.value * clamp(ratio, 0, 1)) % 360;
return deg;
});
const valuePath = computed(() => {
if (arcRange.value <= 0) return '';
if (displayValue.value <= scaleMin.value) return '';
return describeArc(center.value.x, center.value.y, radius.value, startAngle, valueAngle.value);
});
const knob = computed(() => {
if (arcRange.value <= 0) return null;
if (displayValue.value <= scaleMin.value) return null;
const point = polarToCartesian(center.value.x, center.value.y, radius.value, valueAngle.value);
return {
x: point.x,
y: point.y,
r: Math.max(4, strokeWidth.value * 0.55)
};
});
const unitSuffix = computed(() => {
if (props.widget.arcUnit === 1) return '%';
if (props.widget.arcUnit === 2) return 'C';
return '';
});
const displayLabel = computed(() => {
const value = displayValue.value;
const useDecimal = props.widget.textSrc === 1;
const formatted = useDecimal ? value.toFixed(1) : Math.round(value).toString();
return `${formatted}${unitSuffix.value}`;
});
const showValue = computed(() => props.widget.arcShowValue !== false);
const indicatorColor = computed(() => props.widget.textColor || '#5DD39E');
const valueColor = computed(() => props.widget.arcValueColor || indicatorColor.value);
const valueFontSizeIndex = computed(() => {
const idx = Number(props.widget.arcValueFontSize);
if (Number.isFinite(idx) && idx >= 0) return idx;
return Number(props.widget.fontSize ?? 2);
});
const scaleColor = computed(() => props.widget.arcScaleColor || indicatorColor.value);
const trackColor = computed(() => {
return hexToRgba(
props.widget.bgColor || '#274060',
clamp((props.widget.bgOpacity ?? 180) / 255, 0, 1)
);
});
const scaleMajorColor = computed(() => hexToRgba(scaleColor.value, 0.75));
const scaleMinorColor = computed(() => hexToRgba(scaleColor.value, 0.35));
const scaleOffset = computed(() => {
const raw = Number(props.widget.arcScaleOffset ?? 0);
return (Number.isFinite(raw) ? raw : 0) * props.scale;
});
const scaleMajorWidth = computed(() => Math.max(1, Math.round(strokeWidth.value * 0.16)));
const scaleMinorWidth = computed(() => Math.max(1, Math.round(strokeWidth.value * 0.1)));
const scaleFontSize = computed(() => {
const size = (fontSizes[props.widget.fontSize] || 22) * props.scale * 0.45;
return Math.max(9, size);
});
const scaleTicks = computed(() => {
if (arcRange.value <= 0) return [];
const totalTicks = 21;
const majorEvery = 5;
const baseRadius = Math.max(4, radius.value - strokeWidth.value * 0.2 + scaleOffset.value);
const majorLen = Math.max(3, strokeWidth.value * 0.35 + 1);
const minorLen = Math.max(2, strokeWidth.value * 0.2 + 1);
const ticks = [];
for (let i = 0; i < totalTicks; i += 1) {
const ratio = totalTicks > 1 ? i / (totalTicks - 1) : 0;
const angle = (startAngle + sweep.value * ratio) % 360;
const isMajor = i % majorEvery === 0 || i === totalTicks - 1;
const len = isMajor ? majorLen : minorLen;
const p1 = polarToCartesian(center.value.x, center.value.y, baseRadius, angle);
const p2 = polarToCartesian(center.value.x, center.value.y, baseRadius - len, angle);
ticks.push({
key: `tick-${i}`,
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
major: isMajor,
ratio
});
}
return ticks;
});
const scaleLabels = computed(() => {
if (!scaleTicks.value.length) return [];
const baseRadius = Math.max(4, radius.value - strokeWidth.value * 0.2 + scaleOffset.value);
const majorLen = Math.max(3, strokeWidth.value * 0.35 + 1);
const labelGap = Math.max(2, strokeWidth.value * 0.25 + 2);
const labelRadius = Math.max(6, baseRadius - majorLen - labelGap);
return scaleTicks.value
.filter(tick => tick.major)
.map((tick, idx) => {
const value = scaleMin.value + tick.ratio * arcRange.value;
const text = Math.round(value).toString();
const angle = (startAngle + sweep.value * tick.ratio) % 360;
const pos = polarToCartesian(center.value.x, center.value.y, labelRadius, angle);
return {
key: `label-${idx}`,
x: pos.x,
y: pos.y,
text
};
});
});
const valueStyle = computed(() => {
const size = (fontSizes[valueFontSizeIndex.value] || 22) * props.scale;
return {
color: valueColor.value,
fontSize: `${Math.max(10, size)}px`,
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
style.borderRadius = '50%';
style.overflow = 'hidden';
Object.assign(style, getBorderStyle(w, s));
Object.assign(style, getShadowStyle(w, s));
return style;
});
</script>

View File

@ -1,134 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<!-- Children (for container buttons) -->
<WidgetElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
<!-- With Icon (not container) -->
<template v-if="hasIcon && !isContainer">
<div :style="contentStyle">
<span
v-if="iconPosition === 0 || iconPosition === 2"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
<span class="flex-shrink-0">{{ widget.text }}</span>
<span
v-if="iconPosition === 1 || iconPosition === 3"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
</div>
</template>
<!-- Without Icon (not container) -->
<template v-else-if="!isContainer">
<span>{{ widget.text }}</span>
</template>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed, defineAsyncComponent } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { fontSizes, ICON_POSITIONS } from '../../../constants';
import { getBaseStyle, justifyForAlign, getShadowStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils';
// Lazy import to avoid circular dependency
const WidgetElement = defineAsyncComponent(() => import('../../WidgetElement.vue'));
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const store = useEditorStore();
const children = computed(() => {
if (!store.activeScreen) return [];
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
});
const isContainer = computed(() => props.widget.isContainer);
const hasIcon = computed(() => props.widget.iconCodepoint > 0);
const iconChar = computed(() => String.fromCodePoint(props.widget.iconCodepoint || 0xe88a));
const iconPosition = computed(() => props.widget.iconPosition || 0);
const textAlign = computed(() => props.widget.textAlign ?? 1);
const isVerticalLayout = computed(() => {
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
});
const contentStyle = computed(() => {
const s = props.scale;
const gap = (props.widget.iconGap || 8) * s;
return {
display: 'flex',
flexDirection: isVerticalLayout.value ? 'column' : 'row',
alignItems: 'center',
justifyContent: justifyForAlign(textAlign.value),
gap: `${gap}px`,
width: '100%',
height: '100%'
};
});
const iconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
const size = fontSizes[sizeIdx] || 18;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
style.background = hexToRgba(w.bgColor, alpha);
style.borderRadius = `${w.radius * s}px`;
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = justifyForAlign(textAlign.value);
style.fontWeight = '600';
Object.assign(style, getBorderStyle(w, s));
Object.assign(style, getShadowStyle(w, s));
return style;
});
</script>

View File

@ -1,107 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<div class="absolute inset-0 p-1.5 flex flex-col gap-1" :style="contentStyle">
<div
v-for="(row, rowIdx) in rows"
:key="rowIdx"
class="flex-1 min-h-0 flex gap-1"
>
<div
v-for="(label, colIdx) in row"
:key="`${rowIdx}-${colIdx}`"
class="min-w-0 flex-1 rounded-md flex items-center justify-center px-1 text-center truncate"
:style="cellStyle"
>
{{ label }}
</div>
</div>
</div>
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { fontSizes } from '../../../constants';
import { getBaseStyle, getBorderStyle, getShadowStyle, clamp, hexToRgba } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
function parseMatrix(text) {
if (!text || !text.trim()) {
return [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9']
];
}
const result = text
.split('\n')
.map(row => row.split(/[;,]/).map(item => item.trim()).filter(Boolean))
.filter(row => row.length > 0);
if (result.length === 0) {
return [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9']
];
}
return result;
}
const rows = computed(() => parseMatrix(props.widget.text || ''));
const contentStyle = computed(() => {
return {
fontSize: `${(fontSizes[props.widget.fontSize] || 18) * props.scale}px`
};
});
const cellStyle = computed(() => {
const buttonAlpha = clamp((props.widget.bgOpacity ?? 180) / 255, 0, 1);
return {
color: props.widget.textColor || '#FFFFFF',
background: hexToRgba(props.widget.bgColor || '#2A3A4A', buttonAlpha)
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
style.background = hexToRgba(w.bgColor || '#2A3A4A', clamp((w.bgOpacity ?? 180) / 255, 0, 0.4));
style.borderRadius = `${(w.radius || 10) * s}px`;
style.overflow = 'hidden';
Object.assign(style, getBorderStyle(w, s));
Object.assign(style, getShadowStyle(w, s));
return style;
});
</script>

View File

@ -1,69 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<div class="w-full h-full flex flex-col gap-2">
<div class="text-[11px] uppercase tracking-[0.12em] opacity-80">
{{ widget.text || 'Chart' }}
</div>
<div class="flex-1 rounded-[10px] bg-black/20 relative overflow-hidden">
<div class="absolute inset-0 opacity-30" style="background-image: linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px); background-size: 24px 24px;"></div>
<svg class="absolute inset-0" viewBox="0 0 100 40" preserveAspectRatio="none">
<path d="M0,30 L15,22 L30,26 L45,14 L60,18 L75,10 L100,16" fill="none" stroke="rgba(239,99,81,0.8)" stroke-width="2" />
<path d="M0,34 L20,28 L40,32 L60,20 L80,24 L100,18" fill="none" stroke="rgba(125,211,176,0.8)" stroke-width="2" />
</svg>
</div>
</div>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { getBaseStyle, clamp, hexToRgba, getBorderStyle } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
}
Object.assign(style, getBorderStyle(w, s));
style.padding = `${12 * s}px`;
return style;
});
</script>

View File

@ -1,71 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<div class="relative w-full h-full rounded-full box-border flex items-center justify-center overflow-hidden">
<!-- Center Dot -->
<div class="absolute w-2 h-2 rounded-full z-10" :style="{ backgroundColor: widget.textColor }"></div>
<!-- Hour Hand -->
<div class="absolute w-1.5 h-[28%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(300deg)' }"></div>
<!-- Minute Hand -->
<div class="absolute w-1 h-[40%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(70deg)' }"></div>
<!-- Second Hand -->
<div class="absolute w-0.5 h-[45%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full bg-[#c83232]" :style="{ transform: 'rotate(140deg)' }"></div>
</div>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { getBaseStyle, clamp, hexToRgba, getBorderStyle } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
} else {
style.borderRadius = '50%';
}
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
Object.assign(style, getBorderStyle(w, s));
} else {
style.border = `${2 * s}px solid ${w.textColor}`;
}
return style;
});
</script>

View File

@ -1,77 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
{{ iconChar }}
</span>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { fontSizes } from '../../../constants';
import { getBaseStyle, clamp, hexToRgba, getBorderStyle } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const iconChar = computed(() => {
const cp = props.widget.iconCodepoint || 0xe88a;
return String.fromCodePoint(cp);
});
const iconOnlyStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? 3;
const size = fontSizes[sizeIdx] || 28;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
}
Object.assign(style, getBorderStyle(w, s));
return style;
});
</script>

View File

@ -1,114 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<!-- With Icon -->
<template v-if="hasIcon">
<div :style="contentStyle">
<span
v-if="iconPosition === 0 || iconPosition === 2"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
<span class="flex-shrink-0">{{ widget.text }}</span>
<span
v-if="iconPosition === 1 || iconPosition === 3"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
</div>
</template>
<!-- Without Icon -->
<template v-else>
<span>{{ widget.text }}</span>
</template>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { fontSizes, ICON_POSITIONS } from '../../../constants';
import { getBaseStyle, justifyForAlign, textAlignCss, clamp, hexToRgba, getBorderStyle } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const hasIcon = computed(() => props.widget.iconCodepoint > 0);
const iconChar = computed(() => String.fromCodePoint(props.widget.iconCodepoint || 0xe88a));
const iconPosition = computed(() => props.widget.iconPosition || 0);
const textAlign = computed(() => props.widget.textAlign ?? 1);
const isVerticalLayout = computed(() => {
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
});
const contentStyle = computed(() => {
const s = props.scale;
const gap = (props.widget.iconGap || 8) * s;
return {
display: 'flex',
flexDirection: isVerticalLayout.value ? 'column' : 'row',
alignItems: 'center',
justifyContent: justifyForAlign(textAlign.value),
gap: `${gap}px`,
width: '100%',
height: '100%'
};
});
const iconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
const size = fontSizes[sizeIdx] || 18;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
Object.assign(style, getBorderStyle(w, s));
style.display = 'flex';
style.alignItems = 'center';
if (!hasIcon.value) {
style.justifyContent = justifyForAlign(textAlign.value);
style.textAlign = textAlignCss(textAlign.value);
}
return style;
});
</script>

View File

@ -1,65 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { getBaseStyle, clamp, hexToRgba, getBorderStyle } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
style.borderRadius = '999px';
const brightness = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const glowColor = (w.shadow && w.shadow.color) ? w.shadow.color : w.bgColor;
const highlight = clamp(brightness + 0.25, 0, 1);
const core = clamp(brightness, 0, 1);
const edge = clamp(brightness * 0.5, 0, 1);
style.background = `radial-gradient(circle at 30% 30%, ${hexToRgba(w.bgColor, highlight)} 0%, ${hexToRgba(w.bgColor, core)} 45%, ${hexToRgba(w.bgColor, edge)} 70%, rgba(0,0,0,0.4) 100%)`;
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.4 + brightness * 0.6, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(glowColor, glowAlpha)}`;
} else {
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
}
Object.assign(style, getBorderStyle(w, s));
return style;
});
</script>

View File

@ -1,205 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<div class="absolute inset-0" :style="powerFlowBgStyle"></div>
<div v-if="widget.text" class="absolute left-4 top-3 text-[13px] uppercase tracking-[0.08em]" :style="{ color: widget.textColor }">
{{ widget.text }}
</div>
<svg class="absolute inset-0 pointer-events-none" :width="widget.w * scale" :height="widget.h * scale">
<path
v-for="link in powerFlowLinks"
:key="`link-${link.id}`"
:d="link.path"
:stroke="link.color"
:stroke-width="link.width"
stroke-linecap="round"
fill="none"
:opacity="link.opacity"
/>
<circle
v-for="link in powerFlowLinks"
:key="`dot-${link.id}`"
:r="link.dotRadius"
:fill="link.color"
:opacity="link.opacity"
>
<animateMotion
:dur="`${link.duration}s`"
repeatCount="indefinite"
:path="link.path"
/>
</circle>
</svg>
<div v-if="!powerNodes.length" class="absolute inset-0 grid place-items-center text-[12px] text-muted">
Power Nodes hinzufuegen
</div>
<WidgetElement
v-for="child in powerFlowChildren"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed, defineAsyncComponent } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { WIDGET_TYPES } from '../../../constants';
import { getBaseStyle, getShadowStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils';
const WidgetElement = defineAsyncComponent(() => import('../../WidgetElement.vue'));
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const store = useEditorStore();
const children = computed(() => {
if (!store.activeScreen) return [];
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
});
const powerNodes = computed(() => {
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERNODE && child.visible !== false);
});
const powerLinkWidgets = computed(() => {
return children.value.filter((child) => child.type === WIDGET_TYPES.POWERLINK && child.visible !== false);
});
const powerFlowChildren = computed(() => {
return children.value.filter((child) => child.type !== WIDGET_TYPES.POWERLINK);
});
const powerFlowLinks = computed(() => {
if (powerNodes.value.length < 2 || !powerLinkWidgets.value.length) return [];
const s = props.scale;
const nodeMap = new Map(powerNodes.value.map((node) => [node.id, node]));
return powerLinkWidgets.value.flatMap((link, idx) => {
const fromNode = nodeMap.get(link.x);
const toNode = nodeMap.get(link.y);
if (!fromNode || !toNode) return [];
const lineWidth = Math.max(3, link.w || 3);
const fromCenter = {
x: (fromNode.x + fromNode.w / 2) * s,
y: (fromNode.y + fromNode.h / 2) * s
};
const toCenter = {
x: (toNode.x + toNode.w / 2) * s,
y: (toNode.y + toNode.h / 2) * s
};
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const fromRadius = Math.max(0, (Math.min(fromNode.w, fromNode.h) * 0.5 - lineWidth * 0.5) * s);
const toRadius = Math.max(0, (Math.min(toNode.w, toNode.h) * 0.5 - lineWidth * 0.5) * s);
let startX = fromCenter.x + ux * fromRadius;
let startY = fromCenter.y + uy * fromRadius;
let endX = toCenter.x - ux * toRadius;
let endY = toCenter.y - uy * toRadius;
if (len <= fromRadius + toRadius + 1) {
startX = fromCenter.x;
startY = fromCenter.y;
endX = toCenter.x;
endY = toCenter.y;
}
const dxTrim = endX - startX;
const dyTrim = endY - startY;
const lenTrim = Math.hypot(dxTrim, dyTrim) || 1;
const nx = -dyTrim / lenTrim;
const ny = dxTrim / lenTrim;
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const curveSign = idx % 2 === 0 ? 1 : -1;
const curve = Math.min(42 * s, lenTrim * 0.3) * curveSign;
const cpx = midX + nx * curve;
const cpy = midY + ny * curve;
const dotRadius = Math.min(8, Math.max(4, lineWidth * 1.6));
const rawValue = parseFloat(link.text);
const hasRaw = Number.isFinite(rawValue);
const isStatic = (link.textSrc ?? 0) === 0;
const factor = hasRaw ? rawValue : (isStatic ? 60 : 1);
const previewValue = 50;
const speed = Math.max(5, isStatic ? factor : Math.abs(previewValue) * factor);
const duration = Math.max(2, Math.min(10, lenTrim / speed));
return [{
id: link.id,
path: `M ${startX} ${startY} Q ${cpx} ${cpy} ${endX} ${endY}`,
color: link.bgColor || '#6fa7d8',
opacity: clamp((link.bgOpacity ?? 255) / 255, 0.1, 1),
width: lineWidth,
dotRadius,
duration
}];
});
});
const powerFlowBgStyle = computed(() => {
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
const base = hexToRgba(props.widget.bgColor, alpha);
const dot = hexToRgba('#9aa7b4', 0.25);
const dotSize = 16 * props.scale;
return {
backgroundColor: base,
backgroundImage: `radial-gradient(${dot} 0.9px, transparent 1px), linear-gradient(140deg, rgba(255,255,255,0.9) 0%, ${base} 70%)`,
backgroundSize: `${dotSize}px ${dotSize}px, 100% 100%`,
backgroundPosition: '0 0, 0 0'
};
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
style.borderRadius = `${w.radius * s}px`;
style.overflow = 'hidden';
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
Object.assign(style, getBorderStyle(w, s));
} else {
style.border = `1px solid ${hexToRgba('#94a3b8', 0.35)}`;
}
Object.assign(style, getShadowStyle(w, s));
return style;
});
</script>

View File

@ -1,136 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="handleClick"
>
<div class="flex flex-col items-center justify-center w-full h-full text-center leading-tight">
<span v-if="powerNodeParts.label" :style="powerNodeLabelStyle">{{ powerNodeParts.label }}</span>
<span v-if="widget.iconCodepoint" class="material-symbols-outlined mt-1" :style="powerNodeIconStyle">
{{ iconChar }}
</span>
<span v-if="powerNodeParts.value" class="mt-1" :style="powerNodeValueStyle">{{ powerNodeParts.value }}</span>
</div>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { fontSizes } from '../../../constants';
import { getBaseStyle, getBorderStyle, clamp, hexToRgba, splitPowerNodeText } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
const emit = defineEmits(['select', 'drag-start', 'resize-start']);
const store = useEditorStore();
const iconChar = computed(() => {
const cp = props.widget.iconCodepoint || 0xe88a;
return String.fromCodePoint(cp);
});
const powerNodeParts = computed(() => splitPowerNodeText(props.widget.text));
const powerNodeValueSize = computed(() => {
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeLabelSize = computed(() => {
return Math.max(10 * props.scale, powerNodeValueSize.value * 0.55);
});
const powerNodeIconSize = computed(() => {
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return size * props.scale;
});
const powerNodeIconStyle = computed(() => ({
fontSize: `${powerNodeIconSize.value}px`,
color: props.widget.textColor
}));
const powerNodeLabelStyle = computed(() => ({
fontSize: `${powerNodeLabelSize.value}px`,
color: hexToRgba(props.widget.textColor, 0.72)
}));
const powerNodeValueStyle = computed(() => ({
fontSize: `${powerNodeValueSize.value}px`,
color: props.widget.textColor,
fontWeight: '600'
}));
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
const ringAlpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
const ring = Math.max(3, Math.round(Math.min(w.w, w.h) * 0.06 * s));
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
style.borderRadius = '999px';
style.background = hexToRgba('#ffffff', 0.96);
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
Object.assign(style, getBorderStyle(w, s));
} else {
style.border = `${ring}px solid ${hexToRgba(w.bgColor, ringAlpha)}`;
}
style.textAlign = 'center';
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * s;
const sy = (w.shadow.y || 0) * s;
const blur = (w.shadow.blur || 0) * s;
const spread = (w.shadow.spread || 0) * s;
const glowAlpha = clamp(0.35 + ringAlpha * 0.5, 0, 1);
style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${hexToRgba(w.shadow.color || w.bgColor, glowAlpha)}`;
} else {
style.boxShadow = '0 8px 18px rgba(15, 23, 42, 0.12)';
}
if (store.powerLinkMode.active && store.powerLinkMode.powerflowId === w.parentId) {
style.cursor = 'crosshair';
}
if (store.powerLinkMode.active && store.powerLinkMode.fromNodeId === w.id && store.powerLinkMode.powerflowId === w.parentId) {
style.outline = `2px dashed ${hexToRgba('#4f8ad9', 0.9)}`;
style.outlineOffset = '2px';
}
return style;
});
function handleClick() {
if (store.powerLinkMode.active) {
store.handlePowerNodeLink(props.widget.id, props.widget.parentId);
return;
}
emit('select');
}
</script>

View File

@ -1,54 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { getBaseStyle, getShadowStyle, clamp, hexToRgba } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
const bgAlpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
style.background = hexToRgba(w.bgColor || '#000000', bgAlpha);
style.borderRadius = `${(w.radius || 0) * s}px`;
const borderWidth = Math.max(0, (w.borderWidth || 0) * s);
const borderAlpha = clamp((w.borderOpacity ?? 0) / 255, 0, 1);
if (borderWidth > 0 && borderAlpha > 0) {
style.border = `${borderWidth}px solid ${hexToRgba(w.borderColor || '#ffffff', borderAlpha)}`;
} else {
style.border = 'none';
}
Object.assign(style, getShadowStyle(w, s));
return style;
});
</script>

View File

@ -1,288 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<!-- Bubble Style -->
<template v-if="widget.cardStyle !== 1">
<!-- Central Bubble -->
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
{{ iconChar }}
</span>
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
</div>
<!-- Sub-Buttons (circular orbit) -->
<div
v-for="(sb, idx) in roomCardSubButtons"
:key="idx"
class="absolute rounded-full flex items-center justify-center shadow-md"
:style="getSubButtonStyle(sb, idx)"
>
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
{{ String.fromCodePoint(sb.icon) }}
</span>
</div>
</template>
<!-- Tile Style -->
<template v-else>
<div class="absolute inset-0 overflow-hidden" :style="roomCardTileContainerStyle">
<!-- Room name (top-left) -->
<div class="absolute" :style="{ left: '16px', top: '12px', color: widget.textColor, fontSize: fontSizes[widget.fontSize || 2] + 'px', fontWeight: 600 }">
{{ roomCardParts.name }}
</div>
<!-- Text Lines -->
<div class="absolute flex flex-col gap-1" :style="textLinesContainerStyle">
<span v-for="(line, idx) in textLines" :key="idx" class="flex items-center gap-1" :style="getTextLineStyle(line)">
<span v-if="line.icon" class="material-symbols-outlined" :style="{ fontSize: getTextLineIconSize(line) + 'px' }">{{ String.fromCodePoint(line.icon) }}</span>
<span>{{ line.text || '--' }}</span>
</span>
</div>
<!-- Large decorative icon (configurable position) -->
<span v-if="widget.iconCodepoint" class="material-symbols-outlined absolute opacity-20" :style="decorIconStyle">
{{ iconChar }}
</span>
</div>
<!-- Sub-Buttons (right side, vertical) -->
<div
v-for="(sb, idx) in roomCardSubButtons"
:key="idx"
class="absolute rounded-full flex items-center justify-center shadow-md"
:style="getSubButtonStyleTile(sb, idx)"
>
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
{{ String.fromCodePoint(sb.icon) }}
</span>
</div>
</template>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { fontSizes, iconFontSizes } from '../../../constants';
import { getBaseStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const iconChar = computed(() => {
const cp = props.widget.iconCodepoint || 0xe88a;
return String.fromCodePoint(cp);
});
const roomCardParts = computed(() => {
if (!props.widget.text) return { name: '', format: '' };
const parts = props.widget.text.split('\n');
return {
name: parts[0] || '',
format: props.widget.cardStyle === 1 ? '' : (parts[1] || '')
};
});
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
const textLines = computed(() => props.widget.textLines || []);
const textLinesContainerStyle = computed(() => {
const s = props.scale;
// Calculate top based on room name position + font size + padding
const roomNameTop = 12;
const roomNameFontSize = fontSizes[props.widget.fontSize || 2] || 22;
const padding = 10;
const top = (roomNameTop + roomNameFontSize + padding) * s;
return {
left: (16 * s) + 'px',
top: top + 'px',
color: props.widget.textColor
};
});
function getTextLineStyle(line) {
const s = props.scale;
const sizeIdx = line.fontSize ?? 1;
return {
fontSize: (fontSizes[sizeIdx] * s) + 'px'
};
}
function getTextLineIconSize(line) {
const s = props.scale;
const sizeIdx = line.fontSize ?? 1;
return fontSizes[sizeIdx] * s;
}
const roomCardTileContainerStyle = computed(() => {
const alpha = (props.widget.bgOpacity ?? 255) / 255;
return {
backgroundColor: hexToRgba(props.widget.bgColor || '#333333', alpha),
borderRadius: (props.widget.radius || 16) + 'px',
};
});
const decorIconStyle = computed(() => {
const s = props.scale;
const w = props.widget;
const x = (w.iconPositionX !== undefined && w.iconPositionX !== 0) ? w.iconPositionX * s : -20 * s;
const y = (w.iconPositionY !== undefined && w.iconPositionY !== 0) ? w.iconPositionY * s : (w.h - 120) * s;
const sizeIdx = w.iconSize >= 6 ? w.iconSize : 8;
const fontSize = iconFontSizes[sizeIdx] || 96;
return {
left: x + 'px',
top: y + 'px',
fontSize: (fontSize * s) + 'px',
color: w.textColor,
};
});
const getSubButtonStyleTile = (sb, idx) => {
const s = props.scale;
const btnSize = (props.widget.subButtonSize || 40) * s;
const gap = 10 * s;
const padding = 12 * s;
return {
width: btnSize + 'px',
height: btnSize + 'px',
right: padding + 'px',
top: (padding + idx * (btnSize + gap)) + 'px',
backgroundColor: sb.colorOff || '#666666',
};
};
const roomCardBubbleStyle = computed(() => {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const padding = subBtnSize * 0.6;
const bubbleSize = Math.min(w, h) * s - padding * 2;
const left = (w * s - bubbleSize) / 2;
const top = (h * s - bubbleSize) / 2;
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
return {
width: `${bubbleSize}px`,
height: `${bubbleSize}px`,
left: `${left}px`,
top: `${top}px`,
backgroundColor: hexToRgba(props.widget.bgColor, alpha),
color: props.widget.textColor,
border: ((props.widget.borderWidth || 0) > 0 && (props.widget.borderOpacity ?? 0) > 0)
? `${(props.widget.borderWidth || 0) * s}px solid ${hexToRgba(props.widget.borderColor || '#ffffff', clamp((props.widget.borderOpacity ?? 0) / 255, 0, 1))}`
: 'none',
boxShadow: props.widget.shadow?.enabled
? `${(props.widget.shadow.x || 0) * s}px ${(props.widget.shadow.y || 0) * s}px ${(props.widget.shadow.blur || 0) * s}px ${hexToRgba(props.widget.shadow.color || '#000000', 0.3)}`
: '0 4px 12px rgba(0,0,0,0.15)'
};
});
const roomCardIconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? 3;
const size = fontSizes[sizeIdx] || 28;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const roomCardNameStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.7}px`,
color: props.widget.textColor
};
});
const roomCardTempStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.55}px`,
color: props.widget.textColor
};
});
function getSubButtonStyle(sb, idx) {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const centerX = w * s / 2;
const centerY = h * s / 2;
const orbitRadius = (props.widget.subButtonDistance || 80) * s;
const pos = sb.pos ?? idx;
const angle = (pos * (Math.PI / 4)) - (Math.PI / 2);
const x = centerX + orbitRadius * Math.cos(angle) - subBtnSize / 2;
const y = centerY + orbitRadius * Math.sin(angle) - subBtnSize / 2;
const bgColor = sb.colorOff || '#666666';
return {
width: `${subBtnSize}px`,
height: `${subBtnSize}px`,
left: `${x}px`,
top: `${y}px`,
backgroundColor: bgColor,
border: `2px solid ${hexToRgba('#ffffff', 0.3)}`
};
}
function getSubButtonIconStyle(sb) {
const s = props.scale;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const iconSize = subBtnSize * 0.5;
return {
fontSize: `${iconSize}px`,
color: '#ffffff'
};
}
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
if (w.cardStyle === 1) {
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
style.background = hexToRgba(w.bgColor || '#333333', alpha);
style.borderRadius = `${(w.radius || 16) * s}px`;
style.overflow = 'hidden';
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
Object.assign(style, getBorderStyle(w, s));
} else if (w.shadow && w.shadow.enabled) {
style.border = `3px solid ${w.shadow.color || '#ff6b6b'}`;
}
} else {
style.overflow = 'visible';
}
return style;
});
</script>

View File

@ -1,72 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@click.stop="$emit('select')"
>
<!-- Children widgets inside the tab page -->
<WidgetElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</div>
</template>
<script setup>
import { computed, defineAsyncComponent } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { fontSizes } from '../../../constants';
import { clamp, hexToRgba, getBorderStyle } from '../shared/utils';
const WidgetElement = defineAsyncComponent(() => import('../../WidgetElement.vue'));
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const store = useEditorStore();
const children = computed(() => {
if (!store.activeScreen) return [];
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = {
position: 'relative',
width: '100%',
height: '100%',
left: '0',
top: '0',
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
color: w.textColor,
zIndex: 1,
userSelect: 'none',
touchAction: 'none'
};
if ((w.bgOpacity ?? 0) > 0) {
style.background = hexToRgba(w.bgColor, clamp((w.bgOpacity ?? 255) / 255, 0, 1));
}
if ((w.radius || 0) > 0) {
style.borderRadius = `${(w.radius || 0) * s}px`;
}
Object.assign(style, getBorderStyle(w, s));
return style;
});
</script>

View File

@ -1,127 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
<div
v-for="(child, idx) in children"
:key="child.id"
:class="tabBtnClass(activePageId === child.id)"
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
>
{{ child.text }}
</div>
</div>
<div class="flex-1 relative overflow-hidden">
<TabPageElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
:style="{ display: activePageId === child.id ? 'block' : 'none' }"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</div>
</div>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { getBaseStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils';
import TabPageElement from './TabPageElement.vue';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const store = useEditorStore();
const activeTabIndex = ref(0);
const children = computed(() => {
if (!store.activeScreen) return [];
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
});
const tabPosition = computed(() => props.widget.iconPosition || 0);
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
const activePageId = computed(() => {
const selectedChild = children.value.find(c => store.selectedWidgetId === c.id);
if (selectedChild) return selectedChild.id;
if (store.selectedWidget && store.selectedWidget.parentId !== -1) {
let curr = store.selectedWidget;
while (curr && curr.parentId !== -1 && curr.parentId !== props.widget.id) {
curr = store.activeScreen.widgets.find(w => w.id === curr.parentId);
}
if (curr && curr.parentId === props.widget.id) return curr.id;
}
if (children.value.length > 0) return children.value[activeTabIndex.value]?.id;
return -1;
});
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
style.background = hexToRgba(w.bgColor, clamp((w.bgOpacity ?? 255) / 255, 0, 1));
style.borderRadius = `${w.radius * s}px`;
Object.assign(style, getBorderStyle(w, s));
return style;
});
const tabViewStyle = computed(() => ({
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
}));
const tabBtnsStyle = computed(() => {
const s = props.scale;
const h = tabHeight.value * s;
const isVert = tabPosition.value === 0 || tabPosition.value === 1;
return {
[isVert ? 'height' : 'width']: `${h}px`,
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0,
flexDirection: isVert ? 'row' : 'column'
};
});
const tabBtnClass = (isActive) => {
const isVerticalTabs = tabPosition.value === 2 || tabPosition.value === 3;
const base = 'flex-1 flex items-center justify-center px-1 py-1 text-[12px] cursor-pointer select-none';
const border = isVerticalTabs ? 'border-b border-white/10' : 'border-b border-r border-white/10';
const active = isVerticalTabs
? 'bg-white/10 font-bold border-b-0 border-r-2 border-accent'
: 'bg-white/10 font-bold border-b-2 border-accent';
return `${base} ${border}${isActive ? ` ${active}` : ''}`;
};
</script>

View File

@ -1,101 +0,0 @@
<template>
<div>
<h4 :class="headingClass">Wertanzeige</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="widget.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.arc)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="widget.textSrc === 0" :class="rowClass">
<label :class="labelClass">Statisch</label>
<input :class="inputClass" type="number" min="0" max="100" v-model="widget.text">
</div>
<div :class="rowClass"><label :class="labelClass">KNX Lese</label>
<select :class="inputClass" v-model.number="widget.knxAddr">
<option :value="0">-- Keine --</option>
<option v-for="addr in readableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">GA manuell</label>
<input :class="inputClass" type="number" v-model.number="widget.knxAddr">
</div>
<div v-if="!store.knxAddresses.length" :class="noteClass">Keine KNX-Adressen geladen (Dev-API?).</div>
<div :class="rowClass"><label :class="labelClass">Von</label><input :class="inputClass" type="number" v-model.number="widget.arcMin"></div>
<div :class="rowClass"><label :class="labelClass">Bis</label><input :class="inputClass" type="number" v-model.number="widget.arcMax"></div>
<div :class="rowClass"><label :class="labelClass">Einheit</label>
<select :class="inputClass" v-model.number="widget.arcUnit">
<option :value="0">Keine</option>
<option :value="1">%</option>
<option :value="2">C</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Wert anzeigen</label>
<input class="accent-[var(--accent)]" type="checkbox" v-model="widget.arcShowValue">
</div>
<h4 :class="headingClass">Skala</h4>
<div :class="rowClass"><label :class="labelClass">Abstand</label>
<input :class="inputClass" type="number" v-model.number="widget.arcScaleOffset">
</div>
<div :class="rowClass"><label :class="labelClass">Farbe</label>
<input :class="colorInputClass" type="color" v-model="widget.arcScaleColor">
</div>
<h4 :class="headingClass">Wert</h4>
<div :class="rowClass"><label :class="labelClass">Farbe</label>
<input :class="colorInputClass" type="color" v-model="widget.arcValueColor">
</div>
<div :class="rowClass"><label :class="labelClass">Groesse</label>
<select :class="inputClass" v-model.number="widget.arcValueFontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Indikator</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Track</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Track Opa</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Linienbreite</label><input :class="inputClass" type="number" min="2" max="48" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" min="0" max="40" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Spread</label><input :class="inputClass" type="number" min="0" max="20" v-model.number="widget.shadow.spread"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, fontSizes } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass, noteClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
const store = useEditorStore();
const readableAddresses = computed(() => store.knxAddresses);
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
</script>

View File

@ -1,80 +0,0 @@
<template>
<div>
<h4 :class="headingClass">Buttons</h4>
<div :class="rowClass">
<label :class="labelClass">Matrix</label>
<textarea
:class="`${inputClass} min-h-[74px] resize-y`"
v-model="widget.text"
placeholder="z.B. 1;2;3\n4;5;6"
></textarea>
</div>
<div :class="noteClass">Spalten mit `;` oder `,` trennen, Zeilen mit Enter.</div>
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Buttonfarbe</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" min="0" max="40" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Text</h4>
<div :class="rowClass"><label :class="labelClass">Schriftgroesse</label>
<select :class="inputClass" v-model.number="widget.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }} px</option>
</select>
</div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" min="0" max="40" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Spread</label><input :class="inputClass" type="number" min="0" max="20" v-model.number="widget.shadow.spread"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
<h4 :class="headingClass">Aktion</h4>
<div :class="rowClass"><label :class="labelClass">Typ</label>
<select :class="inputClass" v-model.number="widget.action">
<option :value="BUTTON_ACTIONS.KNX">KNX</option>
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
</select>
</div>
<div v-if="widget.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
<label :class="labelClass">Ziel</label>
<select :class="inputClass" v-model.number="widget.targetScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<template v-if="widget.action === BUTTON_ACTIONS.KNX">
<div :class="rowClass"><label :class="labelClass">Toggle</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.isToggle"></div>
<div :class="rowClass"><label :class="labelClass">KNX Schreib</label>
<select :class="inputClass" v-model.number="widget.knxAddrWrite">
<option :value="0">-- Keine --</option>
<option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { BUTTON_ACTIONS, fontSizes } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass, noteClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
const store = useEditorStore();
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
</script>

View File

@ -1,135 +0,0 @@
<template>
<div>
<!-- Content -->
<h4 :class="headingClass">Inhalt</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="widget.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.button)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="widget.textSrc === 0" :class="rowClass">
<label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="widget.text">
</div>
<!-- Typography -->
<h4 :class="headingClass">Typo</h4>
<div :class="rowClass"><label :class="labelClass">Schriftgr.</label>
<select :class="inputClass" v-model.number="widget.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Ausrichtung</label>
<select :class="inputClass" v-model.number="widget.textAlign">
<option :value="0">Links</option>
<option :value="1">Zentriert</option>
<option :value="2">Rechts</option>
</select>
</div>
<!-- Icon -->
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
</div>
<template v-if="widget.iconCodepoint">
<div :class="rowClass">
<label :class="labelClass">Position</label>
<select :class="inputClass" v-model.number="widget.iconPosition">
<option :value="0">Links</option>
<option :value="1">Rechts</option>
<option :value="2">Oben</option>
<option :value="3">Unten</option>
</select>
</div>
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="widget.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div :class="rowClass">
<label :class="labelClass">Abstand</label>
<input :class="inputClass" type="number" v-model.number="widget.iconGap" min="0" max="50">
</div>
</template>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
<!-- Action -->
<h4 :class="headingClass">Aktion</h4>
<div :class="rowClass"><label :class="labelClass">Typ</label>
<select :class="inputClass" v-model.number="widget.action">
<option :value="BUTTON_ACTIONS.KNX">KNX</option>
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
</select>
</div>
<div v-if="widget.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
<label :class="labelClass">Ziel</label>
<select :class="inputClass" v-model.number="widget.targetScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<template v-if="widget.action === BUTTON_ACTIONS.KNX">
<div :class="rowClass"><label :class="labelClass">Toggle</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.isToggle"></div>
<div :class="rowClass"><label :class="labelClass">KNX Schreib</label>
<select :class="inputClass" v-model.number="widget.knxAddrWrite">
<option :value="0">-- Keine --</option>
<option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, BUTTON_ACTIONS, fontSizes } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass, iconSelectClass, iconRemoveClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
defineEmits(['open-icon-picker']);
const store = useEditorStore();
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
</script>

View File

@ -1,112 +0,0 @@
<template>
<div>
<!-- Chart -->
<h4 :class="headingClass">Chart</h4>
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="widget.text"></div>
<div :class="rowClass"><label :class="labelClass">Zeitraum</label>
<select :class="inputClass" v-model.number="widget.chart.period">
<option v-for="opt in chartPeriods" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Serien</label>
<select :class="inputClass" v-model.number="chartSeriesCount">
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
</select>
</div>
<div v-for="(series, idx) in chartSeries" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Serie {{ idx + 1 }}</label>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="series.color">
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Quelle</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.chart)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">KNX</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, chartPeriods } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
const props = defineProps({
widget: { type: Object, required: true }
});
const store = useEditorStore();
const chartSeries = computed(() => props.widget?.chart?.series ?? []);
const chartSeriesCount = computed({
get() {
const count = chartSeries.value.length || 1;
return Math.max(1, Math.min(count, 3));
},
set(value) {
if (!props.widget || !props.widget.chart) return;
const target = Math.max(1, Math.min(value, 3));
const colors = ['#EF6351', '#7DD3B0', '#5EA2EF'];
if (!Array.isArray(props.widget.chart.series)) {
props.widget.chart.series = [];
}
while (props.widget.chart.series.length < target) {
const idx = props.widget.chart.series.length;
props.widget.chart.series.push({
knxAddr: 0,
textSrc: 1,
color: colors[idx] || '#EF6351'
});
}
if (props.widget.chart.series.length > target) {
props.widget.chart.series = props.widget.chart.series.slice(0, target);
}
}
});
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
</script>

View File

@ -1,30 +0,0 @@
<template>
<div>
<!-- Clock -->
<h4 :class="headingClass">Uhr</h4>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Zeiger</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
</script>

View File

@ -1,70 +0,0 @@
<template>
<div>
<!-- Icon -->
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
<span v-else>Auswaehlen</span>
</button>
</div>
<div :class="rowClass">
<label :class="labelClass">Groesse</label>
<select :class="inputClass" v-model.number="widget.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Modus</label>
<select :class="inputClass" v-model.number="widget.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.icon)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="widget.textSrc === 2" :class="rowClass">
<label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
</div>
</template>
<script setup>
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, fontSizes } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, iconSelectClass, colorInputClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
defineEmits(['open-icon-picker']);
const store = useEditorStore();
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
</script>

View File

@ -1,117 +0,0 @@
<template>
<div>
<!-- Content -->
<h4 :class="headingClass">Inhalt</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="widget.textSrc" @change="handleTextSrcChange">
<optgroup v-for="group in groupedSources(sourceOptions.label)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="widget.textSrc === 0" :class="rowClass">
<label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="widget.text">
</div>
<template v-else>
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="widget.text"></div>
<div v-if="widget.textSrc < 11" :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
<!-- Typography -->
<h4 :class="headingClass">Typo</h4>
<div :class="rowClass"><label :class="labelClass">Schriftgr.</label>
<select :class="inputClass" v-model.number="widget.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<!-- Icon -->
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
</div>
<template v-if="widget.iconCodepoint">
<div :class="rowClass">
<label :class="labelClass">Position</label>
<select :class="inputClass" v-model.number="widget.iconPosition">
<option :value="0">Links</option>
<option :value="1">Rechts</option>
<option :value="2">Oben</option>
<option :value="3">Unten</option>
</select>
</div>
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="widget.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div :class="rowClass">
<label :class="labelClass">Abstand</label>
<input :class="inputClass" type="number" v-model.number="widget.iconGap" min="0" max="50">
</div>
</template>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, fontSizes, defaultFormats } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, iconSelectClass, iconRemoveClass, colorInputClass } from '../shared/styles';
const props = defineProps({
widget: { type: Object, required: true }
});
defineEmits(['open-icon-picker']);
const store = useEditorStore();
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
function handleTextSrcChange() {
const newSrc = props.widget.textSrc;
if (newSrc > 0 && defaultFormats[newSrc]) {
props.widget.text = defaultFormats[newSrc];
}
}
</script>

View File

@ -1,59 +0,0 @@
<template>
<div>
<!-- LED -->
<h4 :class="headingClass">LED</h4>
<div :class="rowClass"><label :class="labelClass">Modus</label>
<select :class="inputClass" v-model.number="widget.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.led)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="widget.textSrc === 2" :class="rowClass">
<label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Helligkeit</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Glow -->
<h4 :class="headingClass">Glow</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
const store = useEditorStore();
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
</script>

View File

@ -1,179 +0,0 @@
<template>
<div>
<!-- Power Flow -->
<h4 :class="headingClass">Power Flow</h4>
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="widget.text"></div>
<div :class="rowClass">
<label :class="labelClass">Knoten</label>
<span class="text-[12px] text-muted">{{ powerNodeCount }}</span>
<button class="ml-auto border border-border bg-panel-2 px-2.5 py-1.5 rounded-lg text-[11px] font-semibold hover:bg-[#e4ebf2]" @click="addPowerNode">+ Node</button>
</div>
<div :class="rowClass">
<label :class="labelClass">Verbindungen</label>
<button class="border border-border bg-panel-2 px-2.5 py-1.5 rounded-lg text-[11px] font-semibold hover:bg-[#e4ebf2]" @click="togglePowerLinkMode">
{{ isLinkModeActive ? 'Modus: aktiv' : 'Modus: aus' }}
</button>
<button v-if="isLinkModeActive && linkSourceLabel" class="ml-auto border border-border bg-panel-2 px-2.5 py-1.5 rounded-lg text-[11px] hover:bg-[#e4ebf2]" @click="clearPowerLinkSource">Quelle loeschen</button>
</div>
<div class="text-[11px] text-muted mb-2">{{ linkModeHint }}</div>
<div v-if="powerFlowLinkItems.length" class="mt-2">
<div v-for="link in powerFlowLinkItems" :key="link.id" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<span class="px-1.5 py-0.5 rounded-md bg-white border border-border text-text max-w-[90px] truncate">{{ link.fromLabel }}</span>
<span>-&gt;</span>
<span class="px-1.5 py-0.5 rounded-md bg-white border border-border text-text max-w-[90px] truncate">{{ link.toLabel }}</span>
<button class="ml-auto w-6 h-6 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[11px] cursor-pointer hover:bg-[#f2cfcf]" @click="removePowerLink(link.id)">x</button>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Linie</label>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="link.widget.bgColor">
<input class="w-[70px] bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="number" min="1" max="12" v-model.number="link.widget.w">
<span class="text-[10px] text-muted">px</span>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Speed</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.powerlink)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="link.widget.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Wert</label>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" inputmode="decimal" v-model="link.widget.text" placeholder="z.B. 60">
</div>
<div v-else class="flex flex-col gap-2 text-[11px] text-muted">
<div class="flex items-center gap-2">
<label class="w-[70px] text-[11px] text-muted">Faktor</label>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" inputmode="decimal" v-model="link.widget.text" placeholder="z.B. 0.2">
</div>
<div class="flex items-center gap-2">
<label class="w-[70px] text-[11px] text-muted">KNX</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="link.widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</div>
</div>
</div>
<div v-else class="text-[11px] text-muted">Keine Verbindungen.</div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, WIDGET_TYPES } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
const props = defineProps({
widget: { type: Object, required: true }
});
const store = useEditorStore();
const powerNodeCount = computed(() => {
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW || !store.activeScreen) return 0;
return store.activeScreen.widgets.filter((child) => child.parentId === props.widget.id && child.type === WIDGET_TYPES.POWERNODE).length;
});
const powerFlowLinkItems = computed(() => {
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW || !store.activeScreen) return [];
const nodes = store.activeScreen.widgets.filter((child) => child.parentId === props.widget.id && child.type === WIDGET_TYPES.POWERNODE);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
return store.activeScreen.widgets
.filter((child) => child.parentId === props.widget.id && child.type === WIDGET_TYPES.POWERLINK)
.map((link) => {
const fromNode = nodeMap.get(link.x);
const toNode = nodeMap.get(link.y);
return {
id: link.id,
widget: link,
fromLabel: getPowerNodeLabel(fromNode, link.x),
toLabel: getPowerNodeLabel(toNode, link.y)
};
});
});
const isLinkModeActive = computed(() => {
return store.powerLinkMode.active && store.powerLinkMode.powerflowId === props.widget?.id;
});
const linkSourceLabel = computed(() => {
if (!isLinkModeActive.value || !store.activeScreen || !store.powerLinkMode.fromNodeId) return '';
const node = store.activeScreen.widgets.find((child) => child.id === store.powerLinkMode.fromNodeId);
return getPowerNodeLabel(node, store.powerLinkMode.fromNodeId);
});
const linkModeHint = computed(() => {
if (!isLinkModeActive.value) return 'Aktiviere den Modus und klicke zwei Knoten, um eine Verbindung zu erstellen.';
if (!linkSourceLabel.value) return 'Klicke den Startknoten.';
return `Quelle: ${linkSourceLabel.value} - jetzt Zielknoten waehlen.`;
});
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
function addPowerNode() {
store.addWidget('powernode');
}
function togglePowerLinkMode() {
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW) return;
const nextState = !(store.powerLinkMode.active && store.powerLinkMode.powerflowId === props.widget.id);
store.setPowerLinkMode(nextState, props.widget.id);
}
function clearPowerLinkSource() {
if (!props.widget || props.widget.type !== WIDGET_TYPES.POWERFLOW) return;
store.powerLinkMode.fromNodeId = null;
}
function removePowerLink(linkId) {
store.removePowerLink(linkId);
}
function splitPowerNodeText(text) {
if (typeof text !== 'string') return { label: '', value: '' };
const parts = text.split('\n');
const label = parts[0] ?? '';
const value = parts.slice(1).join('\n');
return { label, value };
}
function getPowerNodeLabel(node, fallbackId) {
if (!node) return `Node ${fallbackId ?? ''}`.trim();
const parts = splitPowerNodeText(node.text);
return parts.label || `Node ${node.id}`;
}
</script>

View File

@ -1,262 +0,0 @@
<template>
<div>
<!-- Power Node -->
<h4 :class="headingClass">Power Node</h4>
<div :class="rowClass"><label :class="labelClass">Label</label><input :class="inputClass" type="text" v-model="powerNodeLabel"></div>
<!-- Primary Value (Bottom) -->
<h4 :class="subHeadingClass">Unten (Primaer)</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="widget.textSrc" @change="handleTextSrcChange">
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="widget.textSrc === 0" :class="rowClass">
<label :class="labelClass">Wert</label><input :class="inputClass" type="text" v-model="powerNodeValue">
</div>
<template v-else>
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="powerNodeValue"></div>
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
<!-- Secondary Value (Left) -->
<h4 :class="subHeadingClass">Links (Sekundaer)</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="widget.textSrc2">
<option :value="0">-- Keine --</option>
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<template v-if="widget.textSrc2 > 0">
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="widget.text2" maxlength="15"></div>
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="widget.knxAddr2">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
<!-- Tertiary Value (Right) -->
<h4 :class="subHeadingClass">Rechts (Tertiaer)</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="widget.textSrc3">
<option :value="0">-- Keine --</option>
<optgroup v-for="group in groupedSources(sourceOptions.powernode)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<template v-if="widget.textSrc3 > 0">
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="widget.text3" maxlength="15"></div>
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="widget.knxAddr3">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
<!-- Conditions -->
<h4 :class="headingClass">Bedingungen</h4>
<div :class="rowClass">
<label :class="labelClass">Anzahl</label>
<select :class="inputClass" v-model.number="conditionCount">
<option :value="0">Keine</option>
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
</select>
</div>
<div v-for="(cond, idx) in conditions" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Bed. {{ idx + 1 }}</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model="cond.source">
<option value="primary">Unten</option>
<option value="secondary">Links</option>
<option value="tertiary">Rechts</option>
</select>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Wenn</label>
<select class="w-[60px] bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model="cond.op">
<option value="lt">&lt;</option>
<option value="lte">&lt;=</option>
<option value="eq">=</option>
<option value="gte">&gt;=</option>
<option value="gt">&gt;</option>
<option value="neq">!=</option>
</select>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="number" step="0.1" v-model.number="cond.threshold" placeholder="Schwelle">
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Icon</label>
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-condition-icon-picker', idx)">
<span v-if="cond.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(cond.icon) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="cond.icon" class="w-6 h-6 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[11px] cursor-pointer hover:bg-[#f2cfcf]" @click="cond.icon = 0">x</button>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[50px]">Farbe</label>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="cond.textColor">
</div>
</div>
<!-- Typography -->
<h4 :class="headingClass">Typo</h4>
<div :class="rowClass"><label :class="labelClass">Wert Schriftgr.</label>
<select :class="inputClass" v-model.number="widget.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<!-- Icon -->
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
</div>
<template v-if="widget.iconCodepoint">
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="widget.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
</template>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Ringfarbe</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Ring Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Glow -->
<h4 :class="headingClass">Glow</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, fontSizes, defaultFormats } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, subHeadingClass, iconSelectClass, iconRemoveClass, colorInputClass } from '../shared/styles';
const props = defineProps({
widget: { type: Object, required: true }
});
defineEmits(['open-icon-picker', 'open-condition-icon-picker']);
const store = useEditorStore();
const conditions = computed(() => props.widget?.conditions ?? []);
const conditionCount = computed({
get() {
return conditions.value.length || 0;
},
set(value) {
if (!props.widget) return;
const target = Math.max(0, Math.min(value, 3));
if (!Array.isArray(props.widget.conditions)) {
props.widget.conditions = [];
}
while (props.widget.conditions.length < target) {
props.widget.conditions.push({
source: 'secondary',
threshold: 0,
op: 'lt',
priority: props.widget.conditions.length,
icon: 0,
textColor: '#FF0000'
});
}
if (props.widget.conditions.length > target) {
props.widget.conditions = props.widget.conditions.slice(0, target);
}
}
});
const powerNodeLabel = computed({
get() {
return splitPowerNodeText(props.widget?.text).label;
},
set(value) {
const parts = splitPowerNodeText(props.widget?.text);
setPowerNodeText(value, parts.value);
}
});
const powerNodeValue = computed({
get() {
return splitPowerNodeText(props.widget?.text).value;
},
set(value) {
const parts = splitPowerNodeText(props.widget?.text);
setPowerNodeText(parts.label, value);
}
});
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
function handleTextSrcChange() {
const newSrc = props.widget.textSrc;
if (newSrc > 0 && defaultFormats[newSrc]) {
const parts = splitPowerNodeText(props.widget.text);
setPowerNodeText(parts.label, defaultFormats[newSrc]);
}
}
function splitPowerNodeText(text) {
if (typeof text !== 'string') return { label: '', value: '' };
const parts = text.split('\n');
const label = parts[0] ?? '';
const value = parts.slice(1).join('\n');
return { label, value };
}
function setPowerNodeText(label, value) {
if (!props.widget) return;
const labelLine = label ?? '';
const valueLine = value ?? '';
props.widget.text = valueLine !== '' || labelLine !== '' ? `${labelLine}${valueLine !== '' ? `\n${valueLine}` : ''}` : '';
}
</script>

View File

@ -1,29 +0,0 @@
<template>
<div>
<h4 :class="headingClass">Fuellung</h4>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" min="0" max="200" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" min="0" max="40" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Spread</label><input :class="inputClass" type="number" min="0" max="20" v-model.number="widget.shadow.spread"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
</script>

View File

@ -1,343 +0,0 @@
<template>
<div>
<!-- Room Card -->
<h4 :class="headingClass">Room Card</h4>
<div :class="rowClass"><label :class="labelClass">Raumname</label><input :class="inputClass" type="text" v-model="roomCardName"></div>
<!-- Bubble style: Temperature via primary textSrc/knxAddr -->
<template v-if="widget.cardStyle !== 1">
<div :class="rowClass"><label :class="labelClass">Temp. Format</label><input :class="inputClass" type="text" v-model="roomCardFormat"></div>
<div :class="rowClass"><label :class="labelClass">Temp. Quelle</label>
<select :class="inputClass" v-model.number="widget.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.roomcard)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="widget.textSrc > 0" :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
<div :class="rowClass"><label :class="labelClass">Klick-Aktion</label>
<select :class="inputClass" v-model.number="widget.action">
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
</select>
</div>
<div v-if="widget.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
<label :class="labelClass">Ziel Screen</label>
<select :class="inputClass" v-model.number="widget.targetScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<!-- Variable Text Lines (Tile style only) -->
<template v-if="widget.cardStyle === 1">
<h4 :class="headingClass">Textzeilen</h4>
<div :class="rowClass">
<label :class="labelClass">Anzahl</label>
<select :class="inputClass" v-model.number="textLineCount">
<option :value="0">Keine</option>
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
<option :value="4">4</option>
<option :value="5">5</option>
</select>
</div>
<div v-for="(line, idx) in textLines" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Zeile {{ idx + 1 }}</label>
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-textline-icon-picker', idx)">
<span v-if="line.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(line.icon) }}</span>
<span v-else>Kein Icon</span>
</button>
<select class="w-[70px] bg-white border border-border rounded-md px-1 py-1 text-[11px]" v-model.number="line.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}px</option>
</select>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Quelle</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="line.textSrc">
<option :value="0">Statisch</option>
<optgroup v-for="group in groupedSources(sourceOptions.roomcard)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div v-if="line.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Text</label>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" v-model="line.text" placeholder="z.B. 21.5°C">
</div>
<template v-else>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Format</label>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" v-model="line.text" placeholder="z.B. %.1f°C">
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[50px]">KNX</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="line.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</template>
</div>
</template>
<!-- Icon -->
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
<span v-else>Auswaehlen</span>
</button>
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
</div>
<template v-if="widget.iconCodepoint">
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="widget.iconSize">
<option v-for="(size, idx) in iconFontSizes" :key="idx" :value="idx">{{ size }}px</option>
</select>
</div>
<div v-if="widget.cardStyle === 1" :class="rowClass">
<label :class="labelClass">Icon X</label>
<input :class="inputClass" type="number" v-model.number="widget.iconPositionX" min="-100" max="400">
<span class="text-[10px] text-muted">px</span>
</div>
<div v-if="widget.cardStyle === 1" :class="rowClass">
<label :class="labelClass">Icon Y</label>
<input :class="inputClass" type="number" v-model.number="widget.iconPositionY" min="-100" max="400">
<span class="text-[10px] text-muted">px</span>
</div>
</template>
<!-- Card Style -->
<h4 :class="headingClass">Karten-Stil</h4>
<div :class="rowClass">
<label :class="labelClass">Layout</label>
<select :class="inputClass" v-model.number="widget.cardStyle">
<option :value="0">Bubble (rund)</option>
<option :value="1">Tile (rechteckig)</option>
</select>
</div>
<!-- Sub-Buttons -->
<h4 :class="headingClass">Sub-Buttons</h4>
<div :class="rowClass">
<label :class="labelClass">Anzahl</label>
<select :class="inputClass" v-model.number="subButtonCount">
<option :value="0">Keine</option>
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
<option :value="4">4</option>
<option :value="5">5</option>
<option :value="6">6</option>
</select>
</div>
<div v-if="subButtonCount > 0" :class="rowClass">
<label :class="labelClass">Button-Gr.</label>
<input :class="inputClass" type="number" min="30" max="80" v-model.number="widget.subButtonSize">
<span class="text-[10px] text-muted">px</span>
</div>
<div v-if="subButtonCount > 0" :class="rowClass">
<label :class="labelClass">Abstand</label>
<input :class="inputClass" type="number" min="40" max="200" v-model.number="widget.subButtonDistance">
<span class="text-[10px] text-muted">px</span>
</div>
<div v-if="subButtonCount > 0" :class="rowClass">
<label :class="labelClass">Btn Opacity</label>
<input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.subButtonOpacity">
</div>
<div v-for="(sb, idx) in subButtons" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Btn {{ idx + 1 }}</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.pos">
<option v-for="(label, pos) in SUBBUTTON_POSITION_LABELS" :key="pos" :value="Number(pos)">{{ label }}</option>
</select>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Icon</label>
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-subbutton-icon-picker', idx)">
<span v-if="sb.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(sb.icon) }}</span>
<span v-else>Kein Icon</span>
</button>
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Aktion</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.action">
<option :value="0">KNX Toggle</option>
<option :value="1">Navigation</option>
</select>
</div>
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">KNX R</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxRead">
<option :value="0">-- Keine --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }}
</option>
</select>
</div>
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">KNX W</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxWrite">
<option :value="0">-- Keine --</option>
<option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }}
</option>
</select>
</div>
<div v-if="sb.action === 1" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Ziel</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.target">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[50px]">Farben</label>
<span class="text-[10px]">An:</span>
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOn">
<span class="text-[10px]">Aus:</span>
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOff">
</div>
</div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, fontSizes, iconFontSizes, BUTTON_ACTIONS, SUBBUTTON_POSITION_LABELS } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, subHeadingClass, iconSelectClass, iconRemoveClass, colorInputClass } from '../shared/styles';
const props = defineProps({
widget: { type: Object, required: true }
});
defineEmits(['open-icon-picker', 'open-subbutton-icon-picker', 'open-textline-icon-picker']);
const store = useEditorStore();
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
const roomCardName = computed({
get() {
if (!props.widget?.text) return '';
const parts = props.widget.text.split('\n');
return parts[0] || '';
},
set(value) {
if (!props.widget) return;
const parts = props.widget.text ? props.widget.text.split('\n') : ['', ''];
parts[0] = value;
props.widget.text = parts.join('\n');
}
});
const roomCardFormat = computed({
get() {
if (!props.widget?.text) return '';
const parts = props.widget.text.split('\n');
return parts[1] || '';
},
set(value) {
if (!props.widget) return;
const parts = props.widget.text ? props.widget.text.split('\n') : ['', ''];
parts[1] = value;
props.widget.text = parts.join('\n');
}
});
const subButtons = computed(() => props.widget?.subButtons ?? []);
const textLines = computed(() => props.widget?.textLines ?? []);
const textLineCount = computed({
get() {
return textLines.value.length || 0;
},
set(value) {
if (!props.widget) return;
const target = Math.max(0, Math.min(value, 5));
if (!Array.isArray(props.widget.textLines)) {
props.widget.textLines = [];
}
while (props.widget.textLines.length < target) {
props.widget.textLines.push({
text: '',
textSrc: 0,
knxAddr: 0,
icon: 0,
fontSize: 1 // Default 18px
});
}
if (props.widget.textLines.length > target) {
props.widget.textLines = props.widget.textLines.slice(0, target);
}
}
});
const subButtonCount = computed({
get() {
return subButtons.value.length || 0;
},
set(value) {
if (!props.widget) return;
const target = Math.max(0, Math.min(value, 6));
if (!Array.isArray(props.widget.subButtons)) {
props.widget.subButtons = [];
}
while (props.widget.subButtons.length < target) {
props.widget.subButtons.push({
pos: props.widget.subButtons.length,
icon: 0,
knxRead: 0,
knxWrite: 0,
action: 0,
target: 0,
colorOn: '#FFCC00',
colorOff: '#666666'
});
}
if (props.widget.subButtons.length > target) {
props.widget.subButtons = props.widget.subButtons.slice(0, target);
}
}
});
function groupedSources(options) {
const allowed = new Set(options || []);
return textSourceGroups
.map((group) => ({
label: group.label,
values: group.values.filter((value) => allowed.has(value))
}))
.filter((group) => group.values.length > 0);
}
</script>

View File

@ -1,34 +0,0 @@
<template>
<div>
<!-- Tab Page -->
<h4 :class="headingClass">Tab Seite</h4>
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="widget.text"></div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
</script>

View File

@ -1,45 +0,0 @@
<template>
<div>
<!-- Tabs -->
<h4 :class="headingClass">Tabs</h4>
<div :class="rowClass"><label :class="labelClass">Position</label>
<select :class="inputClass" v-model.number="widget.iconPosition">
<option :value="0">Oben</option>
<option :value="1">Unten</option>
<option :value="2">Links</option>
<option :value="3">Rechts</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Tab Hoehe</label>
<input :class="inputClass" type="number" v-model.number="widget.iconSize" min="1" max="20">
<span class="text-[10px] text-muted ml-1">x10px</span>
</div>
<!-- Style -->
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
<h4 :class="headingClass">Rand</h4>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
<!-- Shadow -->
<h4 :class="headingClass">Schatten</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
</div>
</template>
<script setup>
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles';
defineProps({
widget: { type: Object, required: true }
});
</script>

View File

@ -1,67 +0,0 @@
<template>
<div
class="z-[1] select-none touch-none"
:class="[
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
extraClass
]"
:style="baseStyle"
@mousedown.stop="!noInteraction && $emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="!noInteraction && $emit('drag-start', { id: widget.id, event: $event })"
@click.stop="handleClick"
>
<slot></slot>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { fontSizes } from '../../../constants';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false },
extraClass: { type: String, default: '' },
extraStyle: { type: Object, default: () => ({}) },
noInteraction: { type: Boolean, default: false }
});
const emit = defineEmits(['select', 'drag-start', 'resize-start', 'click']);
const baseStyle = computed(() => {
const w = props.widget;
const s = props.scale;
return {
left: `${w.x * s}px`,
top: `${w.y * s}px`,
width: `${w.w * s}px`,
height: `${w.h * s}px`,
fontSize: `${(fontSizes[w.fontSize] || 14) * s}px`,
color: w.textColor,
position: 'absolute',
zIndex: 1,
cursor: 'move',
userSelect: 'none',
touchAction: 'none',
...props.extraStyle
};
});
function handleClick(event) {
emit('click', event);
emit('select');
}
</script>

View File

@ -1,11 +0,0 @@
// Shared CSS classes for settings components
export const rowClass = 'flex items-center gap-2.5 mb-2';
export const labelClass = 'w-[90px] text-[12px] text-muted';
export const inputClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent';
export const headingClass = 'mt-4 mb-2.5 text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]';
export const subHeadingClass = 'mt-3 mb-2 text-[11px] uppercase tracking-[0.06em] text-[#5a7f9a]';
export const noteClass = 'text-[11px] text-muted leading-tight';
export const iconSelectClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2.5 py-1.5 text-text text-[12px] flex items-center justify-center gap-1.5 cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2]';
export const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[12px] cursor-pointer hover:bg-[#f2cfcf]';
export const colorInputClass = 'h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0';
export const smallColorInputClass = 'h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0';

View File

@ -1,70 +0,0 @@
// Shared utility functions for widget rendering
import { fontSizes } from '../../../constants';
import { clamp, hexToRgba } from '../../../utils';
export { clamp, hexToRgba };
// Text alignment helpers
export function justifyForAlign(align) {
if (align === 0) return 'flex-start';
if (align === 2) return 'flex-end';
return 'center';
}
export function textAlignCss(align) {
if (align === 0) return 'left';
if (align === 2) return 'right';
return 'center';
}
// Base style for all widgets
export function getBaseStyle(widget, scale) {
return {
left: `${widget.x * scale}px`,
top: `${widget.y * scale}px`,
width: `${widget.w * scale}px`,
height: `${widget.h * scale}px`,
fontSize: `${(fontSizes[widget.fontSize] || 14) * scale}px`,
color: widget.textColor,
position: 'absolute',
zIndex: 1,
cursor: 'move',
userSelect: 'none',
touchAction: 'none'
};
}
// Shadow style helper
export function getShadowStyle(widget, scale) {
if (!widget.shadow || !widget.shadow.enabled) return {};
const sx = (widget.shadow.x || 0) * scale;
const sy = (widget.shadow.y || 0) * scale;
const blur = (widget.shadow.blur || 0) * scale;
const spread = (widget.shadow.spread || 0) * scale;
return {
boxShadow: `${sx}px ${sy}px ${blur}px ${spread}px ${widget.shadow.color}`
};
}
// Border style helper (0-255 opacity, width in widget px)
export function getBorderStyle(widget, scale) {
const width = Math.max(0, (widget.borderWidth || 0) * scale);
const alpha = clamp((widget.borderOpacity ?? 0) / 255, 0, 1);
if (width <= 0 || alpha <= 0) {
return { border: 'none' };
}
return {
border: `${width}px solid ${hexToRgba(widget.borderColor || '#ffffff', alpha)}`
};
}
// PowerNode text splitting
export function splitPowerNodeText(text) {
if (typeof text !== 'string') return { label: '', value: '' };
const parts = text.split('\n');
const label = parts[0] ?? '';
const value = parts.slice(1).join('\n');
return { label, value };
}

View File

@ -14,10 +14,7 @@ export const WIDGET_TYPES = {
POWERLINK: 8,
CHART: 9,
CLOCK: 10,
ROOMCARD: 11,
RECTANGLE: 12,
ARC: 13,
BUTTONMATRIX: 14
ROOMCARD: 11
};
export const ICON_POSITIONS = {
@ -51,10 +48,7 @@ export const TYPE_KEYS = {
8: 'powerlink',
9: 'chart',
10: 'clock',
11: 'roomcard',
12: 'rectangle',
13: 'arc',
14: 'buttonmatrix'
11: 'roomcard'
};
export const TYPE_LABELS = {
@ -69,10 +63,7 @@ export const TYPE_LABELS = {
powerlink: 'Power Link',
chart: 'Chart',
clock: 'Uhr (Analog)',
roomcard: 'Room Card',
rectangle: 'Rechteck',
arc: 'Arc',
buttonmatrix: 'Button Matrix'
roomcard: 'Room Card'
};
@ -116,10 +107,7 @@ export const sourceOptions = {
powerlink: [0, 1, 3, 5, 6, 7],
chart: [1, 3, 5, 6, 7],
clock: [11],
roomcard: [0, 1, 3, 5, 6, 7], // Temperature sources
rectangle: [0],
arc: [0, 1, 2, 3, 5, 6, 7],
buttonmatrix: [0]
roomcard: [0, 1, 3, 5, 6, 7] // Temperature sources
};
export const chartPeriods = [
@ -167,10 +155,6 @@ export const ICON_DEFAULTS = {
export const fontSizes = [14, 18, 22, 28, 36, 48];
// Icon fonts have additional larger sizes for decorative use
// Large icons use PSRAM for draw buffers (ESP32-P4 with 32MB PSRAM)
export const iconFontSizes = [14, 18, 22, 28, 36, 48, 64, 80, 96, 120, 150, 180, 220, 260];
export const defaultFormats = {
1: '%.1f °C',
2: '%s',
@ -217,7 +201,7 @@ export const WIDGET_DEFAULTS = {
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
isContainer: false,
isContainer: true,
textColor: '#FFFFFF',
bgColor: '#2E7DD1',
bgOpacity: 255,
@ -457,7 +441,7 @@ export const WIDGET_DEFAULTS = {
textColor: '#223447',
bgColor: '#FFFFFF',
bgOpacity: 255,
radius: 16, // For Tile style (Bubble ignores this)
radius: 100,
shadow: { enabled: true, x: 0, y: 4, blur: 12, spread: 0, color: '#00000022' },
isToggle: false,
knxAddrWrite: 0,
@ -470,98 +454,6 @@ export const WIDGET_DEFAULTS = {
iconGap: 8,
subButtonSize: 40, // Sub-button size in pixels
subButtonDistance: 80, // Distance from center in pixels
subButtonOpacity: 255, // Sub-button opacity (0-255)
cardStyle: 0, // 0=Bubble (round), 1=Tile (rectangular)
subButtons: [],
textLines: [] // Variable text lines with icon, text, textSrc, knxAddr, fontSize
},
rectangle: {
w: 220,
h: 140,
text: '',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#FFFFFF',
bgColor: '#2E7DD1',
bgOpacity: 180,
radius: 14,
borderWidth: 2,
borderColor: '#FFFFFF',
borderOpacity: 180,
shadow: { enabled: false, x: 2, y: 2, blur: 10, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0,
iconPositionX: 0,
iconPositionY: 0
},
arc: {
w: 180,
h: 180,
text: '75',
textSrc: 0,
fontSize: 2,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#5DD39E',
bgColor: '#274060',
bgOpacity: 180,
radius: 12,
borderWidth: 0,
borderColor: '#FFFFFF',
borderOpacity: 0,
arcMin: 0,
arcMax: 100,
arcUnit: 1,
arcShowValue: true,
arcScaleOffset: 0,
arcScaleColor: '#5DD39E',
arcValueColor: '#5DD39E',
arcValueFontSize: 2,
shadow: { enabled: false, x: 0, y: 0, blur: 8, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0,
iconPositionX: 0,
iconPositionY: 0
},
buttonmatrix: {
w: 240,
h: 150,
text: '1;2;3\n4;5;6\n7;8;9',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#FFFFFF',
bgColor: '#2A3A4A',
bgOpacity: 200,
radius: 10,
borderWidth: 1,
borderColor: '#FFFFFF',
borderOpacity: 48,
shadow: { enabled: false, x: 0, y: 0, blur: 8, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: BUTTON_ACTIONS.KNX,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0,
iconPositionX: 0,
iconPositionY: 0
subButtons: []
}
};

View File

@ -124,30 +124,55 @@ export const useEditorStore = defineStore('editor', () => {
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
});
function cleanupButtonLabels(screen) {
function ensureButtonLabels(screen) {
if (!screen || !Array.isArray(screen.widgets)) return;
const buttonIds = new Set(
screen.widgets.filter(w => w.type === WIDGET_TYPES.BUTTON).map(w => w.id)
);
if (!buttonIds.size) return;
const removeIds = new Set();
const hasLabelChild = new Set();
screen.widgets.forEach((w) => {
if (w.type === WIDGET_TYPES.LABEL && buttonIds.has(w.parentId)) {
removeIds.add(w.id);
if (w.type === WIDGET_TYPES.LABEL && w.parentId !== -1) {
hasLabelChild.add(w.parentId);
}
});
if (removeIds.size) {
screen.widgets = screen.widgets.filter(w => !removeIds.has(w.id));
}
// Update container flag for buttons based on remaining children
screen.widgets.forEach((w) => {
if (w.type !== WIDGET_TYPES.BUTTON) return;
const hasChild = screen.widgets.some(child => child.parentId === w.id);
w.isContainer = hasChild;
w.isContainer = true;
if (hasLabelChild.has(w.id)) return;
const defaults = WIDGET_DEFAULTS.label;
const labelText = w.text || WIDGET_DEFAULTS.button.text;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: defaults.textSrc,
text: labelText,
knxAddr: defaults.knxAddr,
fontSize: w.fontSize ?? defaults.fontSize,
textAlign: w.textAlign ?? TEXT_ALIGNS.CENTER,
textColor: w.textColor ?? defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: 0,
radius: 0,
shadow: { ...defaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition ?? defaults.iconPosition,
iconSize: w.iconSize ?? defaults.iconSize,
iconGap: w.iconGap ?? defaults.iconGap
};
screen.widgets.push(label);
hasLabelChild.add(w.id);
});
}
@ -222,7 +247,7 @@ export const useEditorStore = defineStore('editor', () => {
config.screens.forEach((screen) => {
normalizeScreen(screen, nextScreenId, nextWidgetId);
cleanupButtonLabels(screen);
ensureButtonLabels(screen);
// Also update max widget id
screen.widgets.forEach(w => {
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
@ -310,9 +335,6 @@ export const useEditorStore = defineStore('editor', () => {
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
case 'clock': typeValue = WIDGET_TYPES.CLOCK; break;
case 'roomcard': typeValue = WIDGET_TYPES.ROOMCARD; break;
case 'rectangle': typeValue = WIDGET_TYPES.RECTANGLE; break;
case 'arc': typeValue = WIDGET_TYPES.ARC; break;
case 'buttonmatrix': typeValue = WIDGET_TYPES.BUTTONMATRIX; break;
default: typeValue = WIDGET_TYPES.LABEL;
}
@ -378,17 +400,6 @@ export const useEditorStore = defineStore('editor', () => {
bgColor: defaults.bgColor,
bgOpacity: defaults.bgOpacity,
radius: defaults.radius,
borderWidth: defaults.borderWidth ?? 0,
borderColor: defaults.borderColor || '#ffffff',
borderOpacity: defaults.borderOpacity ?? 0,
arcMin: defaults.arcMin ?? 0,
arcMax: defaults.arcMax ?? 100,
arcUnit: defaults.arcUnit ?? 0,
arcShowValue: defaults.arcShowValue ?? true,
arcScaleOffset: defaults.arcScaleOffset ?? 0,
arcScaleColor: defaults.arcScaleColor ?? defaults.textColor,
arcValueColor: defaults.arcValueColor ?? defaults.textColor,
arcValueFontSize: defaults.arcValueFontSize ?? defaults.fontSize,
shadow: { ...defaults.shadow },
isToggle: defaults.isToggle,
knxAddrWrite: defaults.knxAddrWrite,
@ -397,9 +408,7 @@ export const useEditorStore = defineStore('editor', () => {
iconCodepoint: defaults.iconCodepoint || 0,
iconPosition: defaults.iconPosition || 0,
iconSize: defaults.iconSize || 1,
iconGap: defaults.iconGap || 8,
iconPositionX: defaults.iconPositionX || 8,
iconPositionY: defaults.iconPositionY || 8
iconGap: defaults.iconGap || 8
};
if (defaults.chart) {
@ -420,15 +429,47 @@ export const useEditorStore = defineStore('editor', () => {
w.conditions = [];
}
// RoomCard: initialize sub-buttons and text lines arrays
// RoomCard: initialize sub-buttons array
if (typeStr === 'roomcard') {
w.subButtons = [];
w.textLines = [];
}
activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id;
if (typeStr === 'button') {
const labelDefaults = WIDGET_DEFAULTS.label;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: labelDefaults.textSrc,
text: w.text,
knxAddr: labelDefaults.knxAddr,
fontSize: w.fontSize,
textAlign: TEXT_ALIGNS.CENTER,
textColor: w.textColor,
bgColor: labelDefaults.bgColor,
bgOpacity: 0,
radius: 0,
shadow: { ...labelDefaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition || 0,
iconSize: w.iconSize || 1,
iconGap: w.iconGap || 8
};
activeScreen.value.widgets.push(label);
}
// Auto-create pages for new TabView
if (typeStr === 'tabview') {
// Deselect to reset parent logic for recursive calls, or explicitly pass parent
@ -485,9 +526,7 @@ export const useEditorStore = defineStore('editor', () => {
iconCodepoint: defaults.iconCodepoint || 0,
iconPosition: defaults.iconPosition || 0,
iconSize: defaults.iconSize || 0,
iconGap: defaults.iconGap || 0,
iconPositionX: defaults.iconPositionX || 0,
iconPositionY: defaults.iconPositionY || 0,
iconGap: defaults.iconGap || 0
};
activeScreen.value.widgets.push(link);
@ -543,22 +582,7 @@ export const useEditorStore = defineStore('editor', () => {
}
});
const parentIds = new Set();
activeScreen.value.widgets.forEach((w) => {
if (deleteIds.includes(w.id) && w.parentId !== -1) {
parentIds.add(w.parentId);
}
});
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => !deleteIds.includes(w.id));
parentIds.forEach((pid) => {
const parent = activeScreen.value.widgets.find(w => w.id === pid);
if (parent && parent.type === WIDGET_TYPES.BUTTON) {
const hasChild = activeScreen.value.widgets.some(w => w.parentId === pid);
parent.isContainer = hasChild;
}
});
selectedWidgetId.value = null;
}
@ -576,70 +600,15 @@ export const useEditorStore = defineStore('editor', () => {
const widget = activeScreen.value.widgets.find(w => w.id === widgetId);
if (widget) {
const oldParentId = widget.parentId;
// Adjust position to be relative to new parent (simplified: reset to 0,0 or keep absolute?)
// Keeping absolute is hard because we don't know absolute pos easily here.
// Resetting to 10,10 is safer.
widget.x = 10;
widget.y = 10;
widget.parentId = newParentId;
if (oldParentId !== -1) {
const oldParent = activeScreen.value.widgets.find(w => w.id === oldParentId);
if (oldParent && oldParent.type === WIDGET_TYPES.BUTTON) {
const hasChild = activeScreen.value.widgets.some(w => w.parentId === oldParentId);
oldParent.isContainer = hasChild;
}
}
const newParent = activeScreen.value.widgets.find(w => w.id === newParentId);
if (newParent && newParent.type === WIDGET_TYPES.BUTTON) {
newParent.isContainer = true;
}
}
}
function findSiblingIndex(widgetId, direction) {
if (!activeScreen.value) return -1;
const widgets = activeScreen.value.widgets;
const idx = widgets.findIndex(w => w.id === widgetId);
if (idx < 0) return -1;
const parentId = widgets[idx].parentId;
if (direction === 'up') {
for (let i = idx - 1; i >= 0; i--) {
if (widgets[i].parentId === parentId && widgets[i].type !== WIDGET_TYPES.POWERLINK) {
return i;
}
}
} else {
for (let i = idx + 1; i < widgets.length; i++) {
if (widgets[i].parentId === parentId && widgets[i].type !== WIDGET_TYPES.POWERLINK) {
return i;
}
}
}
return -1;
}
function canMoveWidget(widgetId, direction) {
return findSiblingIndex(widgetId, direction) !== -1;
}
function moveWidget(widgetId, direction) {
if (!activeScreen.value) return;
const widgets = activeScreen.value.widgets;
const idx = widgets.findIndex(w => w.id === widgetId);
if (idx < 0) return;
const targetIdx = findSiblingIndex(widgetId, direction);
if (targetIdx < 0) return;
const tmp = widgets[idx];
widgets[idx] = widgets[targetIdx];
widgets[targetIdx] = tmp;
}
return {
config,
knxAddresses,
@ -665,8 +634,6 @@ export const useEditorStore = defineStore('editor', () => {
removePowerLink,
handlePowerNodeLink,
deleteWidget,
reparentWidget,
moveWidget,
canMoveWidget
reparentWidget
};
});

View File

@ -20,9 +20,6 @@ export function minSizeFor(widget) {
if (key === 'powerlink') return { w: 1, h: 1 };
if (key === 'chart') return { w: 160, h: 120 };
if (key === 'roomcard') return { w: 120, h: 120 };
if (key === 'rectangle') return { w: 40, h: 30 };
if (key === 'arc') return { w: 80, h: 80 };
if (key === 'buttonmatrix') return { w: 120, h: 60 };
return { w: 40, h: 20 };
}
@ -64,27 +61,6 @@ export function normalizeWidget(w, nextWidgetIdRef) {
if (w.subButtonDistance === undefined || w.subButtonDistance === null) {
w.subButtonDistance = defaults.subButtonDistance || 80;
}
// Ensure subButtonOpacity has a default
if (w.subButtonOpacity === undefined || w.subButtonOpacity === null) {
w.subButtonOpacity = defaults.subButtonOpacity || 255;
}
// Ensure cardStyle has a default
if (w.cardStyle === undefined || w.cardStyle === null) {
w.cardStyle = defaults.cardStyle || 0;
}
}
// TextLines: ensure each widget has its own array and each line has fontSize
if (defaults.textLines !== undefined) {
if (!Array.isArray(w.textLines)) {
w.textLines = [];
}
// Ensure each textLine has a fontSize
w.textLines.forEach(tl => {
if (tl.fontSize === undefined || tl.fontSize === null) {
tl.fontSize = 1; // Default 18px
}
});
}
if (!w.shadow) {
@ -97,17 +73,6 @@ export function normalizeWidget(w, nextWidgetIdRef) {
});
}
// Border style defaults (used by rectangle widget, harmless for others)
if (w.borderWidth === undefined || w.borderWidth === null) {
w.borderWidth = defaults.borderWidth ?? 0;
}
if (!w.borderColor) {
w.borderColor = defaults.borderColor || '#ffffff';
}
if (w.borderOpacity === undefined || w.borderOpacity === null) {
w.borderOpacity = defaults.borderOpacity ?? 0;
}
if (defaults.chart) {
const maxSeries = 3;
if (!w.chart) {
@ -153,14 +118,12 @@ export function normalizeWidget(w, nextWidgetIdRef) {
if (w.iconPosition === undefined) w.iconPosition = defaults.iconPosition || 0;
if (w.iconSize === undefined) w.iconSize = defaults.iconSize || 1;
if (w.iconGap === undefined) w.iconGap = defaults.iconGap || 8;
if (w.iconPositionX === undefined) w.iconPositionX = defaults.iconPositionX || 0;
if (w.iconPositionY === undefined) w.iconPositionY = defaults.iconPositionY || 0;
// Hierarchy
if (w.parentId === undefined) w.parentId = -1;
if (key === 'button' && (w.isContainer === undefined || w.isContainer === null)) {
w.isContainer = defaults.isContainer ?? false;
if (key === 'button') {
w.isContainer = true;
}
}