Backup
This commit is contained in:
parent
c9196fcaf2
commit
1ea8bb7e12
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.cache/clangd/index/IconWidget.cpp.C804601C628F2FF0.idx
Normal file
BIN
.cache/clangd/index/IconWidget.cpp.C804601C628F2FF0.idx
Normal file
Binary file not shown.
BIN
.cache/clangd/index/IconWidget.hpp.2B18DA23586DB313.idx
Normal file
BIN
.cache/clangd/index/IconWidget.hpp.2B18DA23586DB313.idx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -4,6 +4,7 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
|
|||||||
"widgets/ButtonWidget.cpp"
|
"widgets/ButtonWidget.cpp"
|
||||||
"widgets/LedWidget.cpp"
|
"widgets/LedWidget.cpp"
|
||||||
"widgets/WidgetFactory.cpp"
|
"widgets/WidgetFactory.cpp"
|
||||||
|
"widgets/IconWidget.cpp"
|
||||||
"webserver/WebServer.cpp"
|
"webserver/WebServer.cpp"
|
||||||
"webserver/StaticFileHandlers.cpp"
|
"webserver/StaticFileHandlers.cpp"
|
||||||
"webserver/ConfigHandlers.cpp"
|
"webserver/ConfigHandlers.cpp"
|
||||||
|
|||||||
@ -13,11 +13,14 @@
|
|||||||
namespace {
|
namespace {
|
||||||
static const char* TAG = "Fonts";
|
static const char* TAG = "Fonts";
|
||||||
static constexpr const char* kFontPath = "/sdcard/fonts/Montserrat-Medium.ttf";
|
static constexpr const char* kFontPath = "/sdcard/fonts/Montserrat-Medium.ttf";
|
||||||
|
static constexpr const char* kIconFontPath = "/sdcard/fonts/MaterialSymbolsOutlined.ttf";
|
||||||
static constexpr uint16_t kFontSizes[] = {14, 18, 22, 28, 36, 48};
|
static constexpr uint16_t kFontSizes[] = {14, 18, 22, 28, 36, 48};
|
||||||
static constexpr size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]);
|
static constexpr size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]);
|
||||||
|
|
||||||
static const lv_font_t* s_fonts[kFontCount] = {nullptr};
|
static const lv_font_t* s_fonts[kFontCount] = {nullptr};
|
||||||
|
static const lv_font_t* s_iconFonts[kFontCount] = {nullptr};
|
||||||
static bool s_initialized = false;
|
static bool s_initialized = false;
|
||||||
|
static bool s_iconFontAvailable = false;
|
||||||
|
|
||||||
const lv_font_t* fallbackFont(uint8_t sizeIndex) {
|
const lv_font_t* fallbackFont(uint8_t sizeIndex) {
|
||||||
switch (sizeIndex) {
|
switch (sizeIndex) {
|
||||||
@ -42,9 +45,9 @@ const lv_font_t* fallbackFont(uint8_t sizeIndex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
|
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
|
||||||
bool fontFileExists() {
|
bool fontFileExists(const char* path) {
|
||||||
struct stat st;
|
struct stat st;
|
||||||
return stat(kFontPath, &st) == 0;
|
return stat(path, &st) == 0;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
} // namespace
|
} // namespace
|
||||||
@ -59,7 +62,7 @@ void Fonts::init() {
|
|||||||
}
|
}
|
||||||
s_initialized = true;
|
s_initialized = true;
|
||||||
|
|
||||||
if (!fontFileExists()) {
|
if (!fontFileExists(kFontPath)) {
|
||||||
ESP_LOGW(TAG, "Font file not found: %s", kFontPath);
|
ESP_LOGW(TAG, "Font file not found: %s", kFontPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -104,7 +107,37 @@ void Fonts::init() {
|
|||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "Font size %u loaded successfully", kFontSizes[i]);
|
ESP_LOGI(TAG, "Font size %u loaded successfully", kFontSizes[i]);
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "Font initialization complete");
|
ESP_LOGI(TAG, "Text font initialization complete");
|
||||||
|
|
||||||
|
// Load icon font if available
|
||||||
|
if (fontFileExists(kIconFontPath)) {
|
||||||
|
ESP_LOGI(TAG, "Icon font file exists: %s", kIconFontPath);
|
||||||
|
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 = kFontSizes[i];
|
||||||
|
cfg.style = ESP_LV_ADAPTER_FT_FONT_STYLE_NORMAL;
|
||||||
|
cfg.mem = nullptr;
|
||||||
|
cfg.mem_size = 0;
|
||||||
|
|
||||||
|
esp_lv_adapter_ft_font_handle_t handle = nullptr;
|
||||||
|
esp_err_t ret = esp_lv_adapter_ft_font_init(&cfg, &handle);
|
||||||
|
if (ret != ESP_OK || handle == nullptr) {
|
||||||
|
ESP_LOGW(TAG, "Failed to load icon font size %u (err=%d)", cfg.size, ret);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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", kFontSizes[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "Icon font initialization complete");
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "Icon font file not found: %s", kIconFontPath);
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
ESP_LOGI(TAG, "FreeType disabled, using built-in fonts");
|
ESP_LOGI(TAG, "FreeType disabled, using built-in fonts");
|
||||||
#endif
|
#endif
|
||||||
@ -118,3 +151,27 @@ const lv_font_t* Fonts::bySizeIndex(uint8_t sizeIndex) {
|
|||||||
#endif
|
#endif
|
||||||
return fallbackFont(sizeIndex);
|
return fallbackFont(sizeIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lv_font_t* Fonts::iconFont(uint8_t sizeIndex) {
|
||||||
|
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
|
||||||
|
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 < kFontCount; ++i) {
|
||||||
|
if (s_iconFonts[i]) return s_iconFonts[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
// No icon font available - return text font as last resort
|
||||||
|
return fallbackFont(sizeIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Fonts::hasIconFont() {
|
||||||
|
#if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE
|
||||||
|
return s_iconFontAvailable;
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|||||||
@ -7,4 +7,8 @@ class Fonts {
|
|||||||
public:
|
public:
|
||||||
static void init();
|
static void init();
|
||||||
static const lv_font_t* bySizeIndex(uint8_t sizeIndex);
|
static const lv_font_t* bySizeIndex(uint8_t sizeIndex);
|
||||||
|
|
||||||
|
// Icon font support
|
||||||
|
static const lv_font_t* iconFont(uint8_t sizeIndex);
|
||||||
|
static bool hasIconFont();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -36,6 +36,16 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
|||||||
buf[pos++] = knxAddressWrite & 0xFF; buf[pos++] = (knxAddressWrite >> 8) & 0xFF;
|
buf[pos++] = knxAddressWrite & 0xFF; buf[pos++] = (knxAddressWrite >> 8) & 0xFF;
|
||||||
buf[pos++] = static_cast<uint8_t>(action);
|
buf[pos++] = static_cast<uint8_t>(action);
|
||||||
buf[pos++] = targetScreen;
|
buf[pos++] = targetScreen;
|
||||||
|
|
||||||
|
// Icon properties
|
||||||
|
buf[pos++] = iconCodepoint & 0xFF;
|
||||||
|
buf[pos++] = (iconCodepoint >> 8) & 0xFF;
|
||||||
|
buf[pos++] = (iconCodepoint >> 16) & 0xFF;
|
||||||
|
buf[pos++] = (iconCodepoint >> 24) & 0xFF;
|
||||||
|
buf[pos++] = iconPosition;
|
||||||
|
buf[pos++] = iconSize;
|
||||||
|
buf[pos++] = static_cast<uint8_t>(iconGap);
|
||||||
|
buf[pos++] = 0; // padding for alignment
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetConfig::deserialize(const uint8_t* buf) {
|
void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||||
@ -72,6 +82,14 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
|||||||
pos += 2;
|
pos += 2;
|
||||||
action = static_cast<ButtonAction>(buf[pos++]);
|
action = static_cast<ButtonAction>(buf[pos++]);
|
||||||
targetScreen = buf[pos++];
|
targetScreen = buf[pos++];
|
||||||
|
|
||||||
|
// Icon properties
|
||||||
|
iconCodepoint = buf[pos] | (buf[pos+1] << 8) | (buf[pos+2] << 16) | (buf[pos+3] << 24);
|
||||||
|
pos += 4;
|
||||||
|
iconPosition = buf[pos++];
|
||||||
|
iconSize = buf[pos++];
|
||||||
|
iconGap = static_cast<int8_t>(buf[pos++]);
|
||||||
|
pos++; // padding
|
||||||
}
|
}
|
||||||
|
|
||||||
WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) {
|
WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) {
|
||||||
@ -91,6 +109,11 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c
|
|||||||
cfg.bgOpacity = 0;
|
cfg.bgOpacity = 0;
|
||||||
cfg.borderRadius = 0;
|
cfg.borderRadius = 0;
|
||||||
cfg.shadow.enabled = false;
|
cfg.shadow.enabled = false;
|
||||||
|
// Icon defaults
|
||||||
|
cfg.iconCodepoint = 0;
|
||||||
|
cfg.iconPosition = 0;
|
||||||
|
cfg.iconSize = 1;
|
||||||
|
cfg.iconGap = 8;
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,6 +152,11 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
|
|||||||
cfg.knxAddressWrite = knxAddrWrite;
|
cfg.knxAddressWrite = knxAddrWrite;
|
||||||
cfg.action = ButtonAction::KNX;
|
cfg.action = ButtonAction::KNX;
|
||||||
cfg.targetScreen = 0;
|
cfg.targetScreen = 0;
|
||||||
|
// Icon defaults
|
||||||
|
cfg.iconCodepoint = 0;
|
||||||
|
cfg.iconPosition = 0;
|
||||||
|
cfg.iconSize = 1;
|
||||||
|
cfg.iconGap = 8;
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,14 @@ enum class WidgetType : uint8_t {
|
|||||||
LABEL = 0,
|
LABEL = 0,
|
||||||
BUTTON = 1,
|
BUTTON = 1,
|
||||||
LED = 2,
|
LED = 2,
|
||||||
// Future: GAUGE, IMAGE, ARC, etc.
|
ICON = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class IconPosition : uint8_t {
|
||||||
|
LEFT = 0,
|
||||||
|
RIGHT = 1,
|
||||||
|
TOP = 2,
|
||||||
|
BOTTOM = 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class ScreenMode : uint8_t {
|
enum class ScreenMode : uint8_t {
|
||||||
@ -94,8 +101,14 @@ struct WidgetConfig {
|
|||||||
ButtonAction action; // Button action (KNX, Jump, Back)
|
ButtonAction action; // Button action (KNX, Jump, Back)
|
||||||
uint8_t targetScreen; // Target screen ID for jump
|
uint8_t targetScreen; // Target screen ID for jump
|
||||||
|
|
||||||
|
// 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-5), same as fontSize
|
||||||
|
int8_t iconGap; // Gap between icon and text (px)
|
||||||
|
|
||||||
// Serialization size (fixed for NVS storage)
|
// Serialization size (fixed for NVS storage)
|
||||||
static constexpr size_t SERIALIZED_SIZE = 68;
|
static constexpr size_t SERIALIZED_SIZE = 76;
|
||||||
|
|
||||||
void serialize(uint8_t* buf) const;
|
void serialize(uint8_t* buf) const;
|
||||||
void deserialize(const uint8_t* buf);
|
void deserialize(const uint8_t* buf);
|
||||||
|
|||||||
@ -771,6 +771,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
|||||||
cJSON_AddNumberToObject(widget, "action", static_cast<int>(w.action));
|
cJSON_AddNumberToObject(widget, "action", static_cast<int>(w.action));
|
||||||
cJSON_AddNumberToObject(widget, "targetScreen", w.targetScreen);
|
cJSON_AddNumberToObject(widget, "targetScreen", w.targetScreen);
|
||||||
|
|
||||||
|
// Icon properties
|
||||||
|
cJSON_AddNumberToObject(widget, "iconCodepoint", w.iconCodepoint);
|
||||||
|
cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition);
|
||||||
|
cJSON_AddNumberToObject(widget, "iconSize", w.iconSize);
|
||||||
|
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
|
||||||
|
|
||||||
cJSON_AddItemToArray(widgets, widget);
|
cJSON_AddItemToArray(widgets, widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -905,6 +911,19 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
cJSON* targetScreen = cJSON_GetObjectItem(widget, "targetScreen");
|
cJSON* targetScreen = cJSON_GetObjectItem(widget, "targetScreen");
|
||||||
if (cJSON_IsNumber(targetScreen)) w.targetScreen = targetScreen->valueint;
|
if (cJSON_IsNumber(targetScreen)) w.targetScreen = targetScreen->valueint;
|
||||||
|
|
||||||
|
// Icon properties
|
||||||
|
cJSON* iconCodepoint = cJSON_GetObjectItem(widget, "iconCodepoint");
|
||||||
|
if (cJSON_IsNumber(iconCodepoint)) w.iconCodepoint = static_cast<uint32_t>(iconCodepoint->valuedouble);
|
||||||
|
|
||||||
|
cJSON* iconPosition = cJSON_GetObjectItem(widget, "iconPosition");
|
||||||
|
if (cJSON_IsNumber(iconPosition)) w.iconPosition = iconPosition->valueint;
|
||||||
|
|
||||||
|
cJSON* iconSize = cJSON_GetObjectItem(widget, "iconSize");
|
||||||
|
if (cJSON_IsNumber(iconSize)) w.iconSize = iconSize->valueint;
|
||||||
|
|
||||||
|
cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap");
|
||||||
|
if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint;
|
||||||
|
|
||||||
screen.widgetCount++;
|
screen.widgetCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,14 +9,20 @@
|
|||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
|
||||||
|
// Helper for min
|
||||||
|
template<typename T>
|
||||||
|
static inline T minVal(T a, T b) { return (a < b) ? a : b; }
|
||||||
|
|
||||||
static const char* TAG = "WebServer";
|
static const char* TAG = "WebServer";
|
||||||
|
|
||||||
// Embedded file manager HTML (from Flash)
|
// Embedded file manager HTML (from Flash)
|
||||||
extern const uint8_t filemanager_html_start[] asm("_binary_filemanager_html_start");
|
extern const uint8_t filemanager_html_start[] asm("_binary_filemanager_html_start");
|
||||||
extern const uint8_t filemanager_html_end[] asm("_binary_filemanager_html_end");
|
extern const uint8_t filemanager_html_end[] asm("_binary_filemanager_html_end");
|
||||||
|
|
||||||
// Maximum upload size (2 MB)
|
// Maximum upload size (16 MB) - streaming, so RAM usage is low
|
||||||
static constexpr size_t MAX_UPLOAD_SIZE = 2 * 1024 * 1024;
|
static constexpr size_t MAX_UPLOAD_SIZE = 16 * 1024 * 1024;
|
||||||
|
// Buffer size for streaming uploads
|
||||||
|
static constexpr size_t UPLOAD_BUFFER_SIZE = 4096;
|
||||||
|
|
||||||
// GET /files - Serve embedded file manager HTML
|
// GET /files - Serve embedded file manager HTML
|
||||||
esp_err_t WebServer::fileManagerHandler(httpd_req_t* req) {
|
esp_err_t WebServer::fileManagerHandler(httpd_req_t* req) {
|
||||||
@ -79,7 +85,7 @@ esp_err_t WebServer::filesListHandler(httpd_req_t* req) {
|
|||||||
return sendJsonObject(req, json);
|
return sendJsonObject(req, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/files/upload?path=/ - Upload file (multipart/form-data)
|
// POST /api/files/upload?path=/ - Upload file (multipart/form-data) with streaming
|
||||||
esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
|
esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
|
||||||
char path[128] = "/";
|
char path[128] = "/";
|
||||||
getQueryParam(req, "path", path, sizeof(path));
|
getQueryParam(req, "path", path, sizeof(path));
|
||||||
@ -89,7 +95,7 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req->content_len > MAX_UPLOAD_SIZE) {
|
if (req->content_len > MAX_UPLOAD_SIZE) {
|
||||||
return sendJsonError(req, "File too large (max 2MB)");
|
return sendJsonError(req, "File too large (max 16MB)");
|
||||||
}
|
}
|
||||||
|
|
||||||
char contentType[128] = "";
|
char contentType[128] = "";
|
||||||
@ -103,17 +109,12 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
|
|||||||
}
|
}
|
||||||
boundaryStart += 9;
|
boundaryStart += 9;
|
||||||
|
|
||||||
// Skip leading quote if present
|
if (*boundaryStart == '"') boundaryStart++;
|
||||||
if (*boundaryStart == '"') {
|
|
||||||
boundaryStart++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy boundary and remove trailing quote if present
|
|
||||||
char boundaryValue[72];
|
char boundaryValue[72];
|
||||||
strncpy(boundaryValue, boundaryStart, sizeof(boundaryValue) - 1);
|
strncpy(boundaryValue, boundaryStart, sizeof(boundaryValue) - 1);
|
||||||
boundaryValue[sizeof(boundaryValue) - 1] = '\0';
|
boundaryValue[sizeof(boundaryValue) - 1] = '\0';
|
||||||
|
|
||||||
// Remove trailing quote or any trailing whitespace/semicolon
|
|
||||||
char* endPtr = boundaryValue;
|
char* endPtr = boundaryValue;
|
||||||
while (*endPtr && *endPtr != '"' && *endPtr != ';' && *endPtr != ' ' && *endPtr != '\r' && *endPtr != '\n') {
|
while (*endPtr && *endPtr != '"' && *endPtr != ';' && *endPtr != ' ' && *endPtr != '\r' && *endPtr != '\n') {
|
||||||
endPtr++;
|
endPtr++;
|
||||||
@ -121,34 +122,59 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
|
|||||||
*endPtr = '\0';
|
*endPtr = '\0';
|
||||||
|
|
||||||
char boundary[80];
|
char boundary[80];
|
||||||
snprintf(boundary, sizeof(boundary), "--%s", boundaryValue);
|
snprintf(boundary, sizeof(boundary), "\r\n--%s", boundaryValue);
|
||||||
|
size_t boundaryLen = strlen(boundary);
|
||||||
|
|
||||||
size_t contentLen = req->content_len;
|
// Allocate streaming buffer
|
||||||
char* fullData = new char[contentLen + 1];
|
char* buffer = new char[UPLOAD_BUFFER_SIZE];
|
||||||
if (!fullData) {
|
if (!buffer) {
|
||||||
return sendJsonError(req, "Out of memory");
|
return sendJsonError(req, "Out of memory");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_t contentLen = req->content_len;
|
||||||
size_t totalReceived = 0;
|
size_t totalReceived = 0;
|
||||||
while (totalReceived < contentLen) {
|
size_t bufferFilled = 0;
|
||||||
int ret = httpd_req_recv(req, fullData + totalReceived, contentLen - totalReceived);
|
|
||||||
|
// Phase 1: Read header to find filename and data start
|
||||||
|
// Headers are typically < 512 bytes, read up to 2KB to be safe
|
||||||
|
static constexpr size_t HEADER_MAX = 2048;
|
||||||
|
char header[HEADER_MAX];
|
||||||
|
size_t headerLen = 0;
|
||||||
|
char* dataStartInHeader = nullptr;
|
||||||
|
|
||||||
|
while (headerLen < HEADER_MAX && totalReceived < contentLen) {
|
||||||
|
int ret = httpd_req_recv(req, header + headerLen,
|
||||||
|
minVal(HEADER_MAX - headerLen, contentLen - totalReceived));
|
||||||
if (ret <= 0) {
|
if (ret <= 0) {
|
||||||
delete[] fullData;
|
delete[] buffer;
|
||||||
return sendJsonError(req, "Receive failed");
|
return sendJsonError(req, "Receive failed");
|
||||||
}
|
}
|
||||||
|
headerLen += ret;
|
||||||
totalReceived += ret;
|
totalReceived += ret;
|
||||||
}
|
|
||||||
fullData[totalReceived] = '\0';
|
|
||||||
|
|
||||||
char* filenameStart = strstr(fullData, "filename=\"");
|
// Look for end of headers (\r\n\r\n)
|
||||||
|
dataStartInHeader = (char*)memmem(header, headerLen, "\r\n\r\n", 4);
|
||||||
|
if (dataStartInHeader) {
|
||||||
|
dataStartInHeader += 4; // Skip past \r\n\r\n
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataStartInHeader) {
|
||||||
|
delete[] buffer;
|
||||||
|
return sendJsonError(req, "Invalid multipart header");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract filename from header
|
||||||
|
char* filenameStart = strstr(header, "filename=\"");
|
||||||
if (!filenameStart) {
|
if (!filenameStart) {
|
||||||
delete[] fullData;
|
delete[] buffer;
|
||||||
return sendJsonError(req, "No filename found");
|
return sendJsonError(req, "No filename found");
|
||||||
}
|
}
|
||||||
filenameStart += 10;
|
filenameStart += 10;
|
||||||
char* filenameEnd = strchr(filenameStart, '"');
|
char* filenameEnd = strchr(filenameStart, '"');
|
||||||
if (!filenameEnd) {
|
if (!filenameEnd || filenameEnd > dataStartInHeader) {
|
||||||
delete[] fullData;
|
delete[] buffer;
|
||||||
return sendJsonError(req, "Invalid filename");
|
return sendJsonError(req, "Invalid filename");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,32 +184,7 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
|
|||||||
memcpy(filename, filenameStart, filenameLen);
|
memcpy(filename, filenameStart, filenameLen);
|
||||||
filename[filenameLen] = '\0';
|
filename[filenameLen] = '\0';
|
||||||
|
|
||||||
char* dataStart = strstr(filenameEnd, "\r\n\r\n");
|
// Prepare target path
|
||||||
if (!dataStart) {
|
|
||||||
delete[] fullData;
|
|
||||||
return sendJsonError(req, "Invalid multipart format");
|
|
||||||
}
|
|
||||||
dataStart += 4;
|
|
||||||
|
|
||||||
// Use memmem for binary-safe search (fonts/images may contain null bytes)
|
|
||||||
char endBoundary[84];
|
|
||||||
snprintf(endBoundary, sizeof(endBoundary), "\r\n%s", boundary);
|
|
||||||
size_t endBoundaryLen = strlen(endBoundary);
|
|
||||||
size_t searchLen = totalReceived - (dataStart - fullData);
|
|
||||||
|
|
||||||
char* dataEnd = (char*)memmem(dataStart, searchLen, endBoundary, endBoundaryLen);
|
|
||||||
if (!dataEnd) {
|
|
||||||
// Try without leading \r\n
|
|
||||||
size_t boundaryLen = strlen(boundary);
|
|
||||||
dataEnd = (char*)memmem(dataStart, searchLen, boundary, boundaryLen);
|
|
||||||
}
|
|
||||||
if (!dataEnd) {
|
|
||||||
delete[] fullData;
|
|
||||||
return sendJsonError(req, "End boundary not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t fileSize = dataEnd - dataStart;
|
|
||||||
|
|
||||||
char resolvedDir[384];
|
char resolvedDir[384];
|
||||||
char targetDirPath[256];
|
char targetDirPath[256];
|
||||||
snprintf(targetDirPath, sizeof(targetDirPath), "%.7s%.127s", SdCard::MOUNT_POINT, path);
|
snprintf(targetDirPath, sizeof(targetDirPath), "%.7s%.127s", SdCard::MOUNT_POINT, path);
|
||||||
@ -196,32 +197,110 @@ esp_err_t WebServer::filesUploadHandler(httpd_req_t* req) {
|
|||||||
char fullPath[384];
|
char fullPath[384];
|
||||||
snprintf(fullPath, sizeof(fullPath), "%.250s/%.120s", actualDir, filename);
|
snprintf(fullPath, sizeof(fullPath), "%.250s/%.120s", actualDir, filename);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Uploading: %s (%zu bytes)", fullPath, fileSize);
|
ESP_LOGI(TAG, "Streaming upload: %s (total content: %zu bytes)", fullPath, contentLen);
|
||||||
|
|
||||||
|
// Create directory if needed
|
||||||
struct stat dirStat;
|
struct stat dirStat;
|
||||||
if (stat(actualDir, &dirStat) != 0) {
|
if (stat(actualDir, &dirStat) != 0) {
|
||||||
if (mkdir(actualDir, 0755) != 0 && errno != EEXIST) {
|
if (mkdir(actualDir, 0755) != 0 && errno != EEXIST) {
|
||||||
delete[] fullData;
|
delete[] buffer;
|
||||||
return sendJsonError(req, "Cannot create directory");
|
return sendJsonError(req, "Cannot create directory");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open file for writing
|
||||||
FILE* f = fopen(fullPath, "wb");
|
FILE* f = fopen(fullPath, "wb");
|
||||||
if (!f) {
|
if (!f) {
|
||||||
ESP_LOGE(TAG, "Cannot create file: %s (errno=%d)", fullPath, errno);
|
ESP_LOGE(TAG, "Cannot create file: %s (errno=%d)", fullPath, errno);
|
||||||
delete[] fullData;
|
delete[] buffer;
|
||||||
return sendJsonError(req, "Cannot create file");
|
return sendJsonError(req, "Cannot create file");
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t written = fwrite(dataStart, 1, fileSize, f);
|
// Calculate how much file data is already in our header buffer
|
||||||
fclose(f);
|
size_t headerDataLen = headerLen - (dataStartInHeader - header);
|
||||||
delete[] fullData;
|
size_t fileSize = 0;
|
||||||
|
|
||||||
if (written != fileSize) {
|
// Copy initial file data from header buffer to streaming buffer
|
||||||
return sendJsonError(req, "Write failed");
|
memcpy(buffer, dataStartInHeader, headerDataLen);
|
||||||
|
bufferFilled = headerDataLen;
|
||||||
|
|
||||||
|
// Phase 2: Stream remaining data, looking for end boundary
|
||||||
|
bool boundaryFound = false;
|
||||||
|
|
||||||
|
while (totalReceived < contentLen || bufferFilled > 0) {
|
||||||
|
// Try to receive more data if buffer has space
|
||||||
|
if (totalReceived < contentLen && bufferFilled < UPLOAD_BUFFER_SIZE) {
|
||||||
|
size_t toRead = minVal(UPLOAD_BUFFER_SIZE - bufferFilled, contentLen - totalReceived);
|
||||||
|
int ret = httpd_req_recv(req, buffer + bufferFilled, toRead);
|
||||||
|
if (ret < 0) {
|
||||||
|
fclose(f);
|
||||||
|
unlink(fullPath);
|
||||||
|
delete[] buffer;
|
||||||
|
return sendJsonError(req, "Receive failed");
|
||||||
|
}
|
||||||
|
if (ret > 0) {
|
||||||
|
bufferFilled += ret;
|
||||||
|
totalReceived += ret;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Upload complete: %s", fullPath);
|
// Check for boundary in buffer
|
||||||
|
char* boundaryPos = nullptr;
|
||||||
|
if (bufferFilled >= boundaryLen) {
|
||||||
|
boundaryPos = (char*)memmem(buffer, bufferFilled, boundary, boundaryLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundaryPos) {
|
||||||
|
// Found end boundary - write data before it
|
||||||
|
size_t dataToWrite = boundaryPos - buffer;
|
||||||
|
if (dataToWrite > 0) {
|
||||||
|
size_t written = fwrite(buffer, 1, dataToWrite, f);
|
||||||
|
if (written != dataToWrite) {
|
||||||
|
fclose(f);
|
||||||
|
unlink(fullPath);
|
||||||
|
delete[] buffer;
|
||||||
|
return sendJsonError(req, "Write failed");
|
||||||
|
}
|
||||||
|
fileSize += written;
|
||||||
|
}
|
||||||
|
boundaryFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No boundary found - write safe portion (keep boundaryLen-1 bytes for boundary detection)
|
||||||
|
if (totalReceived >= contentLen) {
|
||||||
|
// All data received, write everything (should not happen normally)
|
||||||
|
size_t written = fwrite(buffer, 1, bufferFilled, f);
|
||||||
|
fileSize += written;
|
||||||
|
bufferFilled = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t safeToWrite = (bufferFilled > boundaryLen) ? (bufferFilled - boundaryLen + 1) : 0;
|
||||||
|
if (safeToWrite > 0) {
|
||||||
|
size_t written = fwrite(buffer, 1, safeToWrite, f);
|
||||||
|
if (written != safeToWrite) {
|
||||||
|
fclose(f);
|
||||||
|
unlink(fullPath);
|
||||||
|
delete[] buffer;
|
||||||
|
return sendJsonError(req, "Write failed");
|
||||||
|
}
|
||||||
|
fileSize += written;
|
||||||
|
|
||||||
|
// Shift remaining data to front of buffer
|
||||||
|
memmove(buffer, buffer + safeToWrite, bufferFilled - safeToWrite);
|
||||||
|
bufferFilled -= safeToWrite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose(f);
|
||||||
|
delete[] buffer;
|
||||||
|
|
||||||
|
if (!boundaryFound) {
|
||||||
|
ESP_LOGW(TAG, "Upload finished without finding boundary (file may be truncated)");
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Upload complete: %s (%zu bytes)", fullPath, fileSize);
|
||||||
|
|
||||||
cJSON* json = cJSON_CreateObject();
|
cJSON* json = cJSON_CreateObject();
|
||||||
cJSON_AddBoolToObject(json, "success", true);
|
cJSON_AddBoolToObject(json, "success", true);
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
#include "ButtonWidget.hpp"
|
#include "ButtonWidget.hpp"
|
||||||
#include "../WidgetManager.hpp"
|
#include "../WidgetManager.hpp"
|
||||||
|
#include "../Fonts.hpp"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
|
|
||||||
static const char* TAG = "ButtonWidget";
|
static const char* TAG = "ButtonWidget";
|
||||||
|
|
||||||
ButtonWidget::ButtonWidget(const WidgetConfig& config)
|
ButtonWidget::ButtonWidget(const WidgetConfig& config)
|
||||||
: Widget(config)
|
: Widget(config)
|
||||||
|
, contentContainer_(nullptr)
|
||||||
, label_(nullptr)
|
, label_(nullptr)
|
||||||
|
, iconLabel_(nullptr)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ButtonWidget::~ButtonWidget() {
|
ButtonWidget::~ButtonWidget() {
|
||||||
@ -16,7 +18,37 @@ ButtonWidget::~ButtonWidget() {
|
|||||||
if (obj_) {
|
if (obj_) {
|
||||||
lv_obj_remove_event_cb(obj_, clickCallback);
|
lv_obj_remove_event_cb(obj_, clickCallback);
|
||||||
}
|
}
|
||||||
|
contentContainer_ = nullptr;
|
||||||
label_ = nullptr;
|
label_ = nullptr;
|
||||||
|
iconLabel_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ButtonWidget::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 ButtonWidget::clickCallback(lv_event_t* e) {
|
void ButtonWidget::clickCallback(lv_event_t* e) {
|
||||||
@ -27,20 +59,72 @@ void ButtonWidget::clickCallback(lv_event_t* e) {
|
|||||||
WidgetManager::instance().handleButtonAction(widget->getConfig(), target);
|
WidgetManager::instance().handleButtonAction(widget->getConfig(), target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ButtonWidget::setupFlexLayout() {
|
||||||
|
if (contentContainer_ == nullptr) return;
|
||||||
|
|
||||||
|
// Determine flex direction based on icon position
|
||||||
|
bool isVertical = (config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP) ||
|
||||||
|
config_.iconPosition == static_cast<uint8_t>(IconPosition::BOTTOM));
|
||||||
|
|
||||||
|
lv_obj_set_flex_flow(contentContainer_,
|
||||||
|
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(contentContainer_,
|
||||||
|
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
|
||||||
|
// Set gap between icon and text
|
||||||
|
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
|
||||||
|
lv_obj_set_style_pad_gap(contentContainer_, gap, 0);
|
||||||
|
}
|
||||||
|
|
||||||
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
|
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
|
||||||
obj_ = lv_btn_create(parent);
|
obj_ = lv_btn_create(parent);
|
||||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||||
lv_obj_set_size(obj_, config_.width > 0 ? config_.width : 100,
|
lv_obj_set_size(obj_, config_.width > 0 ? config_.width : 100,
|
||||||
config_.height > 0 ? config_.height : 50);
|
config_.height > 0 ? config_.height : 50);
|
||||||
|
|
||||||
// Test: free function callback
|
|
||||||
lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this);
|
lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this);
|
||||||
|
|
||||||
|
bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont();
|
||||||
|
|
||||||
|
if (hasIcon) {
|
||||||
|
// Create container for flex layout
|
||||||
|
contentContainer_ = lv_obj_create(obj_);
|
||||||
|
lv_obj_remove_style_all(contentContainer_);
|
||||||
|
lv_obj_set_size(contentContainer_, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_center(contentContainer_);
|
||||||
|
|
||||||
|
// Create icon label
|
||||||
|
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
|
||||||
|
config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP));
|
||||||
|
|
||||||
|
if (iconFirst) {
|
||||||
|
iconLabel_ = lv_label_create(contentContainer_);
|
||||||
|
char iconText[5];
|
||||||
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create text label
|
||||||
|
label_ = lv_label_create(contentContainer_);
|
||||||
|
lv_label_set_text(label_, config_.text);
|
||||||
|
|
||||||
|
if (!iconFirst) {
|
||||||
|
iconLabel_ = lv_label_create(contentContainer_);
|
||||||
|
char iconText[5];
|
||||||
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFlexLayout();
|
||||||
|
} else {
|
||||||
|
// Simple button without icon
|
||||||
label_ = lv_label_create(obj_);
|
label_ = lv_label_create(obj_);
|
||||||
lv_label_set_text(label_, config_.text);
|
lv_label_set_text(label_, config_.text);
|
||||||
lv_obj_center(label_);
|
lv_obj_center(label_);
|
||||||
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Created button '%s' at %d,%d", config_.text, config_.x, config_.y);
|
ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)",
|
||||||
|
config_.text, config_.x, config_.y, (unsigned long)config_.iconCodepoint);
|
||||||
return obj_;
|
return obj_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,6 +140,15 @@ void ButtonWidget::applyStyle() {
|
|||||||
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||||
lv_obj_set_style_text_font(label_, getFontBySize(config_.fontSize), 0);
|
lv_obj_set_style_text_font(label_, getFontBySize(config_.fontSize), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply icon style
|
||||||
|
if (iconLabel_ != nullptr) {
|
||||||
|
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
|
||||||
|
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||||
|
|
||||||
|
uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : config_.fontSize;
|
||||||
|
lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ButtonWidget::isChecked() const {
|
bool ButtonWidget::isChecked() const {
|
||||||
|
|||||||
@ -14,7 +14,11 @@ public:
|
|||||||
bool isChecked() const;
|
bool isChecked() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
lv_obj_t* contentContainer_ = nullptr;
|
||||||
lv_obj_t* label_ = nullptr;
|
lv_obj_t* label_ = nullptr;
|
||||||
|
lv_obj_t* iconLabel_ = nullptr;
|
||||||
|
|
||||||
|
void setupFlexLayout();
|
||||||
|
static int encodeUtf8(uint32_t codepoint, char* buf);
|
||||||
static void clickCallback(lv_event_t* e);
|
static void clickCallback(lv_event_t* e);
|
||||||
};
|
};
|
||||||
|
|||||||
112
main/widgets/IconWidget.cpp
Normal file
112
main/widgets/IconWidget.cpp
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
#include "IconWidget.hpp"
|
||||||
|
#include "../Fonts.hpp"
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
static const char* TAG = "IconWidget";
|
||||||
|
|
||||||
|
IconWidget::IconWidget(const WidgetConfig& config)
|
||||||
|
: Widget(config)
|
||||||
|
, iconLabel_(nullptr)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int IconWidget::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;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* IconWidget::create(lv_obj_t* parent) {
|
||||||
|
// Create container for the icon
|
||||||
|
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 > 0 ? config_.width : 48,
|
||||||
|
config_.height > 0 ? config_.height : 48);
|
||||||
|
|
||||||
|
// Create icon label
|
||||||
|
iconLabel_ = lv_label_create(obj_);
|
||||||
|
|
||||||
|
if (config_.iconCodepoint > 0) {
|
||||||
|
char iconText[5];
|
||||||
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(iconLabel_, "?");
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_center(iconLabel_);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Created icon widget (codepoint: 0x%lx) at %d,%d",
|
||||||
|
(unsigned long)config_.iconCodepoint, config_.x, config_.y);
|
||||||
|
return obj_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void IconWidget::applyStyle() {
|
||||||
|
if (obj_ == nullptr) return;
|
||||||
|
|
||||||
|
// Background
|
||||||
|
if (config_.bgOpacity > 0) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border radius
|
||||||
|
if (config_.borderRadius > 0) {
|
||||||
|
lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply icon style
|
||||||
|
if (iconLabel_ != nullptr) {
|
||||||
|
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
|
||||||
|
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||||
|
|
||||||
|
// Use icon font
|
||||||
|
uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : config_.fontSize;
|
||||||
|
if (Fonts::hasIconFont()) {
|
||||||
|
lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0);
|
||||||
|
} else {
|
||||||
|
// Fallback to text font (won't show correct icon, but won't crash)
|
||||||
|
lv_obj_set_style_text_font(iconLabel_, Fonts::bySizeIndex(sizeIdx), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void IconWidget::onKnxSwitch(bool value) {
|
||||||
|
if (iconLabel_ == nullptr) return;
|
||||||
|
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
|
||||||
|
|
||||||
|
// Change icon color based on switch state
|
||||||
|
if (value) {
|
||||||
|
// Use text color for "on" state (default)
|
||||||
|
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
|
||||||
|
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||||
|
} else {
|
||||||
|
// Dim color for "off" state (50% brightness)
|
||||||
|
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
|
||||||
|
config_.textColor.r / 2, config_.textColor.g / 2, config_.textColor.b / 2), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
main/widgets/IconWidget.hpp
Normal file
20
main/widgets/IconWidget.hpp
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Widget.hpp"
|
||||||
|
|
||||||
|
class IconWidget : public Widget {
|
||||||
|
public:
|
||||||
|
explicit IconWidget(const WidgetConfig& config);
|
||||||
|
|
||||||
|
lv_obj_t* create(lv_obj_t* parent) override;
|
||||||
|
void applyStyle() override;
|
||||||
|
|
||||||
|
// KNX updates (can change icon color based on state)
|
||||||
|
void onKnxSwitch(bool value) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
lv_obj_t* iconLabel_ = nullptr;
|
||||||
|
|
||||||
|
// Helper to encode unicode codepoint to UTF-8
|
||||||
|
static int encodeUtf8(uint32_t codepoint, char* buf);
|
||||||
|
};
|
||||||
@ -1,42 +1,166 @@
|
|||||||
#include "LabelWidget.hpp"
|
#include "LabelWidget.hpp"
|
||||||
|
#include "../Fonts.hpp"
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
LabelWidget::LabelWidget(const WidgetConfig& config)
|
LabelWidget::LabelWidget(const WidgetConfig& config)
|
||||||
: Widget(config)
|
: Widget(config)
|
||||||
|
, container_(nullptr)
|
||||||
|
, textLabel_(nullptr)
|
||||||
|
, iconLabel_(nullptr)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
|
int LabelWidget::encodeUtf8(uint32_t codepoint, char* buf) {
|
||||||
obj_ = lv_label_create(parent);
|
if (codepoint < 0x80) {
|
||||||
lv_label_set_text(obj_, config_.text);
|
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 LabelWidget::setupFlexLayout() {
|
||||||
|
if (container_ == nullptr) return;
|
||||||
|
|
||||||
|
// Determine flex direction based on icon position
|
||||||
|
bool isVertical = (config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP) ||
|
||||||
|
config_.iconPosition == static_cast<uint8_t>(IconPosition::BOTTOM));
|
||||||
|
|
||||||
|
lv_obj_set_flex_flow(container_,
|
||||||
|
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(container_,
|
||||||
|
LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
|
||||||
|
// Set gap between icon and text
|
||||||
|
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
|
||||||
|
lv_obj_set_style_pad_gap(container_, gap, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
|
||||||
|
bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont();
|
||||||
|
|
||||||
|
if (hasIcon) {
|
||||||
|
// Create container object for flex layout
|
||||||
|
obj_ = lv_obj_create(parent);
|
||||||
|
lv_obj_remove_style_all(obj_);
|
||||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||||
if (config_.width > 0 && config_.height > 0) {
|
if (config_.width > 0 && config_.height > 0) {
|
||||||
lv_obj_set_size(obj_, config_.width, config_.height);
|
lv_obj_set_size(obj_, config_.width, config_.height);
|
||||||
|
} else {
|
||||||
|
lv_obj_set_size(obj_, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
container_ = obj_;
|
||||||
|
|
||||||
|
// Create elements in correct order based on icon position
|
||||||
|
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
|
||||||
|
config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP));
|
||||||
|
|
||||||
|
if (iconFirst) {
|
||||||
|
iconLabel_ = lv_label_create(container_);
|
||||||
|
char iconText[5];
|
||||||
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
}
|
||||||
|
|
||||||
|
textLabel_ = lv_label_create(container_);
|
||||||
|
lv_label_set_text(textLabel_, config_.text);
|
||||||
|
|
||||||
|
if (!iconFirst) {
|
||||||
|
iconLabel_ = lv_label_create(container_);
|
||||||
|
char iconText[5];
|
||||||
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFlexLayout();
|
||||||
|
} else {
|
||||||
|
// Simple label without icon
|
||||||
|
obj_ = lv_label_create(parent);
|
||||||
|
textLabel_ = obj_;
|
||||||
|
lv_label_set_text(obj_, config_.text);
|
||||||
|
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||||
|
if (config_.width > 0 && config_.height > 0) {
|
||||||
|
lv_obj_set_size(obj_, config_.width, config_.height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return obj_;
|
return obj_;
|
||||||
}
|
}
|
||||||
|
|
||||||
void LabelWidget::onKnxValue(float value) {
|
void LabelWidget::applyStyle() {
|
||||||
if (obj_ == nullptr) return;
|
if (obj_ == nullptr) return;
|
||||||
|
|
||||||
|
// Apply background if container
|
||||||
|
if (container_ != nullptr && config_.bgOpacity > 0) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply text style
|
||||||
|
if (textLabel_ != nullptr) {
|
||||||
|
lv_obj_set_style_text_color(textLabel_, lv_color_make(
|
||||||
|
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||||
|
lv_obj_set_style_text_font(textLabel_, Fonts::bySizeIndex(config_.fontSize), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply icon style
|
||||||
|
if (iconLabel_ != nullptr) {
|
||||||
|
lv_obj_set_style_text_color(iconLabel_, lv_color_make(
|
||||||
|
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
||||||
|
|
||||||
|
uint8_t sizeIdx = config_.iconSize < 6 ? config_.iconSize : config_.fontSize;
|
||||||
|
lv_obj_set_style_text_font(iconLabel_, Fonts::iconFont(sizeIdx), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For simple label (no container), apply common style
|
||||||
|
if (container_ == nullptr) {
|
||||||
|
applyCommonStyle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LabelWidget::onKnxValue(float value) {
|
||||||
|
lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
|
||||||
|
if (label == nullptr) return;
|
||||||
if (config_.textSource != TextSource::KNX_DPT_TEMP) return;
|
if (config_.textSource != TextSource::KNX_DPT_TEMP) return;
|
||||||
|
|
||||||
char buf[32];
|
char buf[32];
|
||||||
snprintf(buf, sizeof(buf), config_.text, value);
|
snprintf(buf, sizeof(buf), config_.text, value);
|
||||||
lv_label_set_text(obj_, buf);
|
lv_label_set_text(label, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
void LabelWidget::onKnxSwitch(bool value) {
|
void LabelWidget::onKnxSwitch(bool value) {
|
||||||
if (obj_ == nullptr) return;
|
lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
|
||||||
|
if (label == nullptr) return;
|
||||||
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
|
if (config_.textSource != TextSource::KNX_DPT_SWITCH) return;
|
||||||
|
|
||||||
lv_label_set_text(obj_, value ? "EIN" : "AUS");
|
lv_label_set_text(label, value ? "EIN" : "AUS");
|
||||||
}
|
}
|
||||||
|
|
||||||
void LabelWidget::onKnxText(const char* text) {
|
void LabelWidget::onKnxText(const char* text) {
|
||||||
if (obj_ == nullptr) return;
|
lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
|
||||||
|
if (label == nullptr) return;
|
||||||
if (config_.textSource != TextSource::KNX_DPT_TEXT) return;
|
if (config_.textSource != TextSource::KNX_DPT_TEXT) return;
|
||||||
|
|
||||||
lv_label_set_text(obj_, text);
|
lv_label_set_text(label, text);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,18 @@ public:
|
|||||||
explicit LabelWidget(const WidgetConfig& config);
|
explicit LabelWidget(const WidgetConfig& config);
|
||||||
|
|
||||||
lv_obj_t* create(lv_obj_t* parent) override;
|
lv_obj_t* create(lv_obj_t* parent) override;
|
||||||
|
void applyStyle() override;
|
||||||
|
|
||||||
// KNX updates
|
// KNX updates
|
||||||
void onKnxValue(float value) override;
|
void onKnxValue(float value) override;
|
||||||
void onKnxSwitch(bool value) override;
|
void onKnxSwitch(bool value) override;
|
||||||
void onKnxText(const char* text) override;
|
void onKnxText(const char* text) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
lv_obj_t* container_ = nullptr;
|
||||||
|
lv_obj_t* textLabel_ = nullptr;
|
||||||
|
lv_obj_t* iconLabel_ = nullptr;
|
||||||
|
|
||||||
|
void setupFlexLayout();
|
||||||
|
static int encodeUtf8(uint32_t codepoint, char* buf);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
#include "LabelWidget.hpp"
|
#include "LabelWidget.hpp"
|
||||||
#include "ButtonWidget.hpp"
|
#include "ButtonWidget.hpp"
|
||||||
#include "LedWidget.hpp"
|
#include "LedWidget.hpp"
|
||||||
|
#include "IconWidget.hpp"
|
||||||
|
|
||||||
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||||
if (!config.visible) return nullptr;
|
if (!config.visible) return nullptr;
|
||||||
@ -13,6 +14,8 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
|||||||
return std::make_unique<ButtonWidget>(config);
|
return std::make_unique<ButtonWidget>(config);
|
||||||
case WidgetType::LED:
|
case WidgetType::LED:
|
||||||
return std::make_unique<LedWidget>(config);
|
return std::make_unique<LedWidget>(config);
|
||||||
|
case WidgetType::ICON:
|
||||||
|
return std::make_unique<IconWidget>(config);
|
||||||
default:
|
default:
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
sdcard_content/fonts/MaterialSymbolsOutlined.ttf
Normal file
BIN
sdcard_content/fonts/MaterialSymbolsOutlined.ttf
Normal file
Binary file not shown.
232
web-interface/src/components/IconPicker.vue
Normal file
232
web-interface/src/components/IconPicker.vue
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<div class="icon-picker-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="icon-picker">
|
||||||
|
<div class="icon-picker-header">
|
||||||
|
<h3>Icon auswaehlen</h3>
|
||||||
|
<button class="close-btn" @click="$emit('close')">x</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-picker-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="search"
|
||||||
|
placeholder="Icon suchen..."
|
||||||
|
ref="searchInput"
|
||||||
|
@keydown.esc="$emit('close')"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-picker-current" v-if="modelValue">
|
||||||
|
<span class="current-label">Aktuell:</span>
|
||||||
|
<span class="material-symbols-outlined current-icon">{{ String.fromCodePoint(modelValue) }}</span>
|
||||||
|
<span class="current-name">{{ currentIconName }}</span>
|
||||||
|
<button class="remove-btn" @click="selectIcon(0)">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-grid">
|
||||||
|
<button
|
||||||
|
v-for="icon in filteredIcons"
|
||||||
|
:key="icon.codepoint"
|
||||||
|
class="icon-btn"
|
||||||
|
:class="{ selected: icon.codepoint === modelValue }"
|
||||||
|
@click="selectIcon(icon.codepoint)"
|
||||||
|
:title="icon.name"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined">{{ String.fromCodePoint(icon.codepoint) }}</span>
|
||||||
|
<span class="icon-name">{{ icon.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredIcons.length === 0" class="no-results">
|
||||||
|
Keine Icons gefunden fuer "{{ search }}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { MATERIAL_ICONS, searchIcons, getIconByCodepoint } from '../icons';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Number, default: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'close']);
|
||||||
|
|
||||||
|
const search = ref('');
|
||||||
|
const searchInput = ref(null);
|
||||||
|
|
||||||
|
const filteredIcons = computed(() => searchIcons(search.value));
|
||||||
|
|
||||||
|
const currentIconName = computed(() => {
|
||||||
|
if (!props.modelValue) return '';
|
||||||
|
const icon = getIconByCodepoint(props.modelValue);
|
||||||
|
return icon ? icon.name : `0x${props.modelValue.toString(16)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectIcon(codepoint) {
|
||||||
|
emit('update:modelValue', codepoint);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (searchInput.value) {
|
||||||
|
searchInput.value.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker-search {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker-search input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker-search input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-picker-current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.4);
|
||||||
|
color: #ffd1d1;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.selected {
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
background: rgba(125, 211, 176, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn .material-symbols-outlined {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn .icon-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -18,6 +18,10 @@
|
|||||||
<span class="element-title">LED</span>
|
<span class="element-title">LED</span>
|
||||||
<span class="element-sub">Status</span>
|
<span class="element-sub">Status</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="element-btn" @click="store.addWidget('icon')">
|
||||||
|
<span class="element-title">Icon</span>
|
||||||
|
<span class="element-sub">Symbol</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Icon Widget -->
|
||||||
|
<template v-if="key === 'icon'">
|
||||||
|
<h4>Icon</h4>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Icon</label>
|
||||||
|
<button class="icon-select-btn" @click="showIconPicker = true">
|
||||||
|
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
||||||
|
<span v-else>Auswaehlen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Groesse</label>
|
||||||
|
<select v-model.number="w.iconSize">
|
||||||
|
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row"><label>Modus</label>
|
||||||
|
<select v-model.number="w.textSrc">
|
||||||
|
<option v-for="opt in sourceOptions.icon" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="w.textSrc === 2" class="prop-row">
|
||||||
|
<label>KNX Objekt</label>
|
||||||
|
<select v-model.number="w.knxAddr">
|
||||||
|
<option :value="0">-- Waehlen --</option>
|
||||||
|
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
|
||||||
|
GO{{ addr.index }} ({{ addr.addrStr }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Typography -->
|
<!-- Typography -->
|
||||||
<template v-if="key === 'label' || key === 'button'">
|
<template v-if="key === 'label' || key === 'button'">
|
||||||
<h4>Typo</h4>
|
<h4>Typo</h4>
|
||||||
@ -71,12 +103,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Icon for Label/Button -->
|
||||||
|
<template v-if="key === 'label' || key === 'button'">
|
||||||
|
<h4>Icon</h4>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Icon</label>
|
||||||
|
<button class="icon-select-btn" @click="showIconPicker = true">
|
||||||
|
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
||||||
|
<span v-else>Kein Icon</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="w.iconCodepoint" class="icon-remove-btn" @click="w.iconCodepoint = 0">x</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="w.iconCodepoint">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Position</label>
|
||||||
|
<select v-model.number="w.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="prop-row">
|
||||||
|
<label>Icon-Gr.</label>
|
||||||
|
<select v-model.number="w.iconSize">
|
||||||
|
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Abstand</label>
|
||||||
|
<input type="number" v-model.number="w.iconGap" min="0" max="50">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Style -->
|
<!-- Style -->
|
||||||
<template v-if="key === 'led'">
|
<template v-if="key === 'led'">
|
||||||
<h4>Stil</h4>
|
<h4>Stil</h4>
|
||||||
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.bgColor"></div>
|
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.bgColor"></div>
|
||||||
<div class="prop-row"><label>Helligkeit</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
|
<div class="prop-row"><label>Helligkeit</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="key === 'icon'">
|
||||||
|
<h4>Stil</h4>
|
||||||
|
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.textColor"></div>
|
||||||
|
<div class="prop-row"><label>Hintergrund</label><input type="color" v-model="w.bgColor"></div>
|
||||||
|
<div class="prop-row"><label>Deckkraft</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
|
||||||
|
<div class="prop-row"><label>Radius</label><input type="number" v-model.number="w.radius"></div>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<h4>Stil</h4>
|
<h4>Stil</h4>
|
||||||
<div class="prop-row"><label>Textfarbe</label><input type="color" v-model="w.textColor"></div>
|
<div class="prop-row"><label>Textfarbe</label><input type="color" v-model="w.textColor"></div>
|
||||||
@ -86,12 +159,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Shadow/Glow -->
|
<!-- Shadow/Glow -->
|
||||||
|
<template v-if="key !== 'icon'">
|
||||||
<h4>{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
|
<h4>{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
|
||||||
<div class="prop-row"><label>Aktiv</label><input type="checkbox" v-model="w.shadow.enabled"></div>
|
<div class="prop-row"><label>Aktiv</label><input type="checkbox" v-model="w.shadow.enabled"></div>
|
||||||
<div class="prop-row" v-if="key !== 'led'"><label>X</label><input type="number" v-model.number="w.shadow.x"></div>
|
<div class="prop-row" v-if="key !== 'led'"><label>X</label><input type="number" v-model.number="w.shadow.x"></div>
|
||||||
<div class="prop-row" v-if="key !== 'led'"><label>Y</label><input type="number" v-model.number="w.shadow.y"></div>
|
<div class="prop-row" v-if="key !== 'led'"><label>Y</label><input type="number" v-model.number="w.shadow.y"></div>
|
||||||
<div class="prop-row"><label>Blur</label><input type="number" v-model.number="w.shadow.blur"></div>
|
<div class="prop-row"><label>Blur</label><input type="number" v-model.number="w.shadow.blur"></div>
|
||||||
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.shadow.color"></div>
|
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.shadow.color"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Button Actions -->
|
<!-- Button Actions -->
|
||||||
<template v-if="key === 'button'">
|
<template v-if="key === 'button'">
|
||||||
@ -127,18 +202,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Icon Picker Modal -->
|
||||||
|
<IconPicker
|
||||||
|
v-if="showIconPicker"
|
||||||
|
v-model="w.iconCodepoint"
|
||||||
|
@close="showIconPicker = false"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
import { typeKeyFor } from '../utils';
|
import { typeKeyFor } from '../utils';
|
||||||
import { sourceOptions, textSources, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants';
|
import { sourceOptions, textSources, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants';
|
||||||
|
import IconPicker from './IconPicker.vue';
|
||||||
|
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
const w = computed(() => store.selectedWidget);
|
const w = computed(() => store.selectedWidget);
|
||||||
const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label');
|
const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label');
|
||||||
|
const showIconPicker = ref(false);
|
||||||
|
|
||||||
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
||||||
|
|
||||||
@ -150,3 +234,41 @@ function handleTextSrcChange() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-select-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-select-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-select-btn .material-symbols-outlined {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-remove-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.4);
|
||||||
|
background: transparent;
|
||||||
|
color: #ffd1d1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
selected: selected,
|
selected: selected,
|
||||||
'widget-label': isLabel,
|
'widget-label': isLabel,
|
||||||
'widget-button': isButton,
|
'widget-button': isButton,
|
||||||
'widget-led': isLed
|
'widget-led': isLed,
|
||||||
|
'widget-icon': isIcon
|
||||||
}"
|
}"
|
||||||
:style="computedStyle"
|
:style="computedStyle"
|
||||||
@mousedown="$emit('drag-start', $event)"
|
@mousedown="$emit('drag-start', $event)"
|
||||||
@ -19,13 +20,40 @@
|
|||||||
@touchstart.stop="$emit('resize-start', $event)"
|
@touchstart.stop="$emit('resize-start', $event)"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
<!-- Icon-only Widget -->
|
||||||
|
<template v-if="isIcon">
|
||||||
|
<span class="material-symbols-outlined icon-display" :style="iconOnlyStyle">
|
||||||
|
{{ iconChar }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Label/Button with Icon -->
|
||||||
|
<template v-else-if="hasIcon">
|
||||||
|
<div class="widget-content" :style="contentStyle">
|
||||||
|
<span
|
||||||
|
v-if="iconPosition === 0 || iconPosition === 2"
|
||||||
|
class="material-symbols-outlined widget-icon"
|
||||||
|
:style="iconStyle"
|
||||||
|
>{{ iconChar }}</span>
|
||||||
|
<span class="widget-text">{{ widget.text }}</span>
|
||||||
|
<span
|
||||||
|
v-if="iconPosition === 1 || iconPosition === 3"
|
||||||
|
class="material-symbols-outlined widget-icon"
|
||||||
|
:style="iconStyle"
|
||||||
|
>{{ iconChar }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Standard (no icon) -->
|
||||||
|
<template v-else>
|
||||||
{{ widget.text }}
|
{{ widget.text }}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { WIDGET_TYPES, fontSizes } from '../constants';
|
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
|
||||||
import { clamp, hexToRgba } from '../utils';
|
import { clamp, hexToRgba } from '../utils';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -39,6 +67,56 @@ defineEmits(['select', 'drag-start', 'resize-start']);
|
|||||||
const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
|
const isLabel = computed(() => props.widget.type === WIDGET_TYPES.LABEL);
|
||||||
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
|
const isButton = computed(() => props.widget.type === WIDGET_TYPES.BUTTON);
|
||||||
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
|
const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
|
||||||
|
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
|
||||||
|
|
||||||
|
const hasIcon = computed(() => {
|
||||||
|
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 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: 'center',
|
||||||
|
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 computedStyle = computed(() => {
|
const computedStyle = computed(() => {
|
||||||
const w = props.widget;
|
const w = props.widget;
|
||||||
@ -58,11 +136,27 @@ const computedStyle = computed(() => {
|
|||||||
touchAction: 'none'
|
touchAction: 'none'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLabel.value) {
|
if (isIcon.value) {
|
||||||
|
// Icon widget
|
||||||
|
style.display = 'flex';
|
||||||
|
style.alignItems = 'center';
|
||||||
|
style.justifyContent = 'center';
|
||||||
if (w.bgOpacity > 0) {
|
if (w.bgOpacity > 0) {
|
||||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||||
style.background = hexToRgba(w.bgColor, alpha);
|
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);
|
||||||
|
}
|
||||||
|
if (hasIcon.value) {
|
||||||
|
style.display = 'flex';
|
||||||
|
style.alignItems = 'center';
|
||||||
|
}
|
||||||
} else if (isButton.value) {
|
} else if (isButton.value) {
|
||||||
style.background = w.bgColor;
|
style.background = w.bgColor;
|
||||||
style.borderRadius = `${w.radius * s}px`;
|
style.borderRadius = `${w.radius * s}px`;
|
||||||
@ -104,3 +198,25 @@ const computedStyle = computed(() => {
|
|||||||
return style;
|
return style;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.widget-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-text {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -5,7 +5,15 @@ export const MAX_SCREENS = 8;
|
|||||||
export const WIDGET_TYPES = {
|
export const WIDGET_TYPES = {
|
||||||
LABEL: 0,
|
LABEL: 0,
|
||||||
BUTTON: 1,
|
BUTTON: 1,
|
||||||
LED: 2
|
LED: 2,
|
||||||
|
ICON: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ICON_POSITIONS = {
|
||||||
|
LEFT: 0,
|
||||||
|
RIGHT: 1,
|
||||||
|
TOP: 2,
|
||||||
|
BOTTOM: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BUTTON_ACTIONS = {
|
export const BUTTON_ACTIONS = {
|
||||||
@ -17,13 +25,15 @@ export const BUTTON_ACTIONS = {
|
|||||||
export const TYPE_KEYS = {
|
export const TYPE_KEYS = {
|
||||||
0: 'label',
|
0: 'label',
|
||||||
1: 'button',
|
1: 'button',
|
||||||
2: 'led'
|
2: 'led',
|
||||||
|
3: 'icon'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TYPE_LABELS = {
|
export const TYPE_LABELS = {
|
||||||
label: 'Label',
|
label: 'Label',
|
||||||
button: 'Button',
|
button: 'Button',
|
||||||
led: 'LED'
|
led: 'LED',
|
||||||
|
icon: 'Icon'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const textSources = {
|
export const textSources = {
|
||||||
@ -37,7 +47,15 @@ export const textSources = {
|
|||||||
export const sourceOptions = {
|
export const sourceOptions = {
|
||||||
label: [0, 1, 2, 3, 4],
|
label: [0, 1, 2, 3, 4],
|
||||||
button: [0],
|
button: [0],
|
||||||
led: [0, 2]
|
led: [0, 2],
|
||||||
|
icon: [0, 2]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ICON_DEFAULTS = {
|
||||||
|
iconCodepoint: 0,
|
||||||
|
iconPosition: 0,
|
||||||
|
iconSize: 1,
|
||||||
|
iconGap: 8
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fontSizes = [14, 18, 22, 28, 36, 48];
|
export const fontSizes = [14, 18, 22, 28, 36, 48];
|
||||||
@ -65,7 +83,11 @@ export const WIDGET_DEFAULTS = {
|
|||||||
knxAddrWrite: 0,
|
knxAddrWrite: 0,
|
||||||
knxAddr: 0,
|
knxAddr: 0,
|
||||||
action: BUTTON_ACTIONS.KNX,
|
action: BUTTON_ACTIONS.KNX,
|
||||||
targetScreen: 0
|
targetScreen: 0,
|
||||||
|
iconCodepoint: 0,
|
||||||
|
iconPosition: 0,
|
||||||
|
iconSize: 1,
|
||||||
|
iconGap: 8
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
w: 130,
|
w: 130,
|
||||||
@ -82,7 +104,11 @@ export const WIDGET_DEFAULTS = {
|
|||||||
knxAddrWrite: 0,
|
knxAddrWrite: 0,
|
||||||
knxAddr: 0,
|
knxAddr: 0,
|
||||||
action: BUTTON_ACTIONS.KNX,
|
action: BUTTON_ACTIONS.KNX,
|
||||||
targetScreen: 0
|
targetScreen: 0,
|
||||||
|
iconCodepoint: 0,
|
||||||
|
iconPosition: 0,
|
||||||
|
iconSize: 1,
|
||||||
|
iconGap: 8
|
||||||
},
|
},
|
||||||
led: {
|
led: {
|
||||||
w: 60,
|
w: 60,
|
||||||
@ -99,6 +125,31 @@ export const WIDGET_DEFAULTS = {
|
|||||||
knxAddrWrite: 0,
|
knxAddrWrite: 0,
|
||||||
knxAddr: 0,
|
knxAddr: 0,
|
||||||
action: BUTTON_ACTIONS.KNX,
|
action: BUTTON_ACTIONS.KNX,
|
||||||
targetScreen: 0
|
targetScreen: 0,
|
||||||
|
iconCodepoint: 0,
|
||||||
|
iconPosition: 0,
|
||||||
|
iconSize: 1,
|
||||||
|
iconGap: 8
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
w: 48,
|
||||||
|
h: 48,
|
||||||
|
text: '',
|
||||||
|
textSrc: 0,
|
||||||
|
fontSize: 3,
|
||||||
|
textColor: '#FFFFFF',
|
||||||
|
bgColor: '#0E1217',
|
||||||
|
bgOpacity: 0,
|
||||||
|
radius: 0,
|
||||||
|
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
|
||||||
|
isToggle: false,
|
||||||
|
knxAddrWrite: 0,
|
||||||
|
knxAddr: 0,
|
||||||
|
action: BUTTON_ACTIONS.KNX,
|
||||||
|
targetScreen: 0,
|
||||||
|
iconCodepoint: 0xe88a,
|
||||||
|
iconPosition: 0,
|
||||||
|
iconSize: 3,
|
||||||
|
iconGap: 8
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
296
web-interface/src/icons.js
Normal file
296
web-interface/src/icons.js
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
// Material Symbols Outlined icon list
|
||||||
|
// Codepoints from https://github.com/google/material-design-icons
|
||||||
|
// Format: { name, codepoint, tags (for search) }
|
||||||
|
|
||||||
|
export const MATERIAL_ICONS = [
|
||||||
|
// Home & Building
|
||||||
|
{ name: 'home', codepoint: 0xe88a, tags: ['haus', 'start', 'main'] },
|
||||||
|
{ name: 'cottage', codepoint: 0xe587, tags: ['haus', 'huette'] },
|
||||||
|
{ name: 'apartment', codepoint: 0xea40, tags: ['wohnung', 'gebaeude'] },
|
||||||
|
{ name: 'meeting_room', codepoint: 0xeb4f, tags: ['raum', 'zimmer', 'tuer'] },
|
||||||
|
{ name: 'door_front', codepoint: 0xeffc, tags: ['tuer', 'eingang'] },
|
||||||
|
{ name: 'door_back', codepoint: 0xeffb, tags: ['tuer', 'hinten'] },
|
||||||
|
{ name: 'door_sliding', codepoint: 0xeffd, tags: ['tuer', 'schiebe'] },
|
||||||
|
{ name: 'window', codepoint: 0xf11f, tags: ['fenster'] },
|
||||||
|
{ name: 'garage', codepoint: 0xf011, tags: ['garage', 'auto'] },
|
||||||
|
{ name: 'stairs', codepoint: 0xf1a9, tags: ['treppe', 'stufen'] },
|
||||||
|
{ name: 'roofing', codepoint: 0xf201, tags: ['dach'] },
|
||||||
|
{ name: 'fence', codepoint: 0xf1f6, tags: ['zaun', 'garten'] },
|
||||||
|
{ name: 'yard', codepoint: 0xf1f5, tags: ['garten', 'hof'] },
|
||||||
|
|
||||||
|
// Lighting
|
||||||
|
{ name: 'lightbulb', codepoint: 0xe0f0, tags: ['licht', 'lampe', 'bulb', 'gluehbirne'] },
|
||||||
|
{ name: 'light', codepoint: 0xf02a, tags: ['licht', 'lampe'] },
|
||||||
|
{ name: 'light_mode', codepoint: 0xe518, tags: ['licht', 'hell', 'tag', 'sonne'] },
|
||||||
|
{ name: 'dark_mode', codepoint: 0xe51c, tags: ['dunkel', 'nacht', 'mond'] },
|
||||||
|
{ name: 'wb_sunny', codepoint: 0xe430, tags: ['sonne', 'hell', 'tag'] },
|
||||||
|
{ name: 'nightlight', codepoint: 0xefda, tags: ['nachtlicht', 'mond'] },
|
||||||
|
{ name: 'fluorescent', codepoint: 0xf00d, tags: ['leuchtstoff', 'roehre'] },
|
||||||
|
{ name: 'highlight', codepoint: 0xe25f, tags: ['highlight', 'spot'] },
|
||||||
|
{ name: 'wb_incandescent', codepoint: 0xe42e, tags: ['gluehlampe'] },
|
||||||
|
{ name: 'tungsten', codepoint: 0xf07a, tags: ['wolfram', 'lampe'] },
|
||||||
|
{ name: 'ceiling', codepoint: 0xf1ff, tags: ['decke', 'deckenlampe'] },
|
||||||
|
{ name: 'floor_lamp', codepoint: 0xf009, tags: ['stehlampe', 'boden'] },
|
||||||
|
{ name: 'table_lamp', codepoint: 0xf00a, tags: ['tischlampe'] },
|
||||||
|
{ name: 'wall_lamp', codepoint: 0xf00b, tags: ['wandlampe'] },
|
||||||
|
{ name: 'outdoor_lamp', codepoint: 0xf1d4, tags: ['aussenlampe', 'garten'] },
|
||||||
|
|
||||||
|
// Climate & Temperature
|
||||||
|
{ name: 'thermostat', codepoint: 0xf076, tags: ['temperatur', 'heizung', 'klima'] },
|
||||||
|
{ name: 'thermostat_auto', codepoint: 0xf077, tags: ['temperatur', 'auto', 'heizung'] },
|
||||||
|
{ name: 'device_thermostat', codepoint: 0xe1ff, tags: ['temperatur', 'regler'] },
|
||||||
|
{ name: 'heat', codepoint: 0xf1b3, tags: ['heizung', 'waerme'] },
|
||||||
|
{ name: 'ac_unit', codepoint: 0xeb3b, tags: ['klimaanlage', 'kuehlung', 'ac'] },
|
||||||
|
{ name: 'air', codepoint: 0xefd8, tags: ['luft', 'wind', 'lueftung'] },
|
||||||
|
{ name: 'mode_fan', codepoint: 0xf00e, tags: ['ventilator', 'luefter', 'fan'] },
|
||||||
|
{ name: 'mode_fan_off', codepoint: 0xec17, tags: ['ventilator', 'aus'] },
|
||||||
|
{ name: 'hvac', codepoint: 0xf10e, tags: ['heizung', 'lueftung', 'klima'] },
|
||||||
|
{ name: 'water_drop', codepoint: 0xe798, tags: ['wasser', 'tropfen', 'feuchtigkeit'] },
|
||||||
|
{ name: 'humidity_percentage', codepoint: 0xf87e, tags: ['feuchtigkeit', 'prozent'] },
|
||||||
|
{ name: 'humidity_high', codepoint: 0xf163, tags: ['feuchtigkeit', 'hoch'] },
|
||||||
|
{ name: 'humidity_low', codepoint: 0xf164, tags: ['feuchtigkeit', 'niedrig'] },
|
||||||
|
{ name: 'humidity_mid', codepoint: 0xf165, tags: ['feuchtigkeit', 'mittel'] },
|
||||||
|
{ name: 'dew_point', codepoint: 0xf879, tags: ['taupunkt'] },
|
||||||
|
|
||||||
|
// Blinds & Covers
|
||||||
|
{ name: 'blinds', codepoint: 0xe286, tags: ['jalousie', 'rollo', 'beschattung'] },
|
||||||
|
{ name: 'blinds_closed', codepoint: 0xec1f, tags: ['jalousie', 'geschlossen'] },
|
||||||
|
{ name: 'vertical_shades', codepoint: 0xf1cb, tags: ['lamellen', 'vertikal'] },
|
||||||
|
{ name: 'vertical_shades_closed', codepoint: 0xf1ca, tags: ['lamellen', 'geschlossen'] },
|
||||||
|
{ name: 'roller_shades', codepoint: 0xec12, tags: ['rollo', 'screen'] },
|
||||||
|
{ name: 'roller_shades_closed', codepoint: 0xec11, tags: ['rollo', 'geschlossen'] },
|
||||||
|
{ name: 'curtains', codepoint: 0xec1e, tags: ['vorhang', 'gardine'] },
|
||||||
|
{ name: 'curtains_closed', codepoint: 0xec1d, tags: ['vorhang', 'geschlossen'] },
|
||||||
|
|
||||||
|
// Power & Energy
|
||||||
|
{ name: 'power_settings_new', codepoint: 0xe8ac, tags: ['power', 'strom', 'an', 'aus'] },
|
||||||
|
{ name: 'power', codepoint: 0xe63c, tags: ['strom', 'energie'] },
|
||||||
|
{ name: 'power_off', codepoint: 0xe646, tags: ['aus', 'strom'] },
|
||||||
|
{ name: 'bolt', codepoint: 0xea0b, tags: ['blitz', 'strom', 'energie'] },
|
||||||
|
{ name: 'electric_bolt', codepoint: 0xec1c, tags: ['blitz', 'strom'] },
|
||||||
|
{ name: 'electric_meter', codepoint: 0xec1b, tags: ['zaehler', 'strom'] },
|
||||||
|
{ name: 'outlet', codepoint: 0xe9ba, tags: ['steckdose', 'dose'] },
|
||||||
|
{ name: 'electrical_services', codepoint: 0xf102, tags: ['elektrik', 'strom'] },
|
||||||
|
{ name: 'battery_full', codepoint: 0xe1a4, tags: ['batterie', 'voll', 'akku'] },
|
||||||
|
{ name: 'battery_charging_full', codepoint: 0xe1a3, tags: ['batterie', 'laden'] },
|
||||||
|
{ name: 'battery_0_bar', codepoint: 0xebdc, tags: ['batterie', 'leer'] },
|
||||||
|
{ name: 'solar_power', codepoint: 0xec0f, tags: ['solar', 'pv', 'photovoltaik'] },
|
||||||
|
{ name: 'energy_savings_leaf', codepoint: 0xf1ae, tags: ['energie', 'sparen', 'eco'] },
|
||||||
|
{ name: 'ev_station', codepoint: 0xe56d, tags: ['elektro', 'auto', 'laden'] },
|
||||||
|
|
||||||
|
// Security & Safety
|
||||||
|
{ name: 'lock', codepoint: 0xe897, tags: ['schloss', 'sicherheit', 'gesperrt'] },
|
||||||
|
{ name: 'lock_open', codepoint: 0xe898, tags: ['schloss', 'offen', 'entsperrt'] },
|
||||||
|
{ name: 'key', codepoint: 0xe73c, tags: ['schluessel'] },
|
||||||
|
{ name: 'vpn_key', codepoint: 0xe62f, tags: ['schluessel', 'sicherheit'] },
|
||||||
|
{ name: 'security', codepoint: 0xe32a, tags: ['sicherheit', 'schutz'] },
|
||||||
|
{ name: 'shield', codepoint: 0xe9e0, tags: ['schild', 'schutz'] },
|
||||||
|
{ name: 'verified_user', codepoint: 0xe8e8, tags: ['sicher', 'verifiziert'] },
|
||||||
|
{ name: 'gpp_good', codepoint: 0xf1f8, tags: ['schutz', 'gut'] },
|
||||||
|
{ name: 'gpp_bad', codepoint: 0xf1f7, tags: ['schutz', 'schlecht', 'alarm'] },
|
||||||
|
{ name: 'crisis_alert', codepoint: 0xebe9, tags: ['alarm', 'warnung', 'notfall'] },
|
||||||
|
{ name: 'emergency', codepoint: 0xe1eb, tags: ['notfall', 'alarm'] },
|
||||||
|
{ name: 'warning', codepoint: 0xe002, tags: ['warnung', 'achtung'] },
|
||||||
|
{ name: 'error', codepoint: 0xe000, tags: ['fehler', 'error'] },
|
||||||
|
{ name: 'notifications', codepoint: 0xe7f4, tags: ['benachrichtigung', 'glocke'] },
|
||||||
|
{ name: 'notifications_active', codepoint: 0xe7f7, tags: ['benachrichtigung', 'aktiv'] },
|
||||||
|
{ name: 'notifications_off', codepoint: 0xe7f6, tags: ['benachrichtigung', 'aus'] },
|
||||||
|
|
||||||
|
// Sensors & Detection
|
||||||
|
{ name: 'sensors', codepoint: 0xe51e, tags: ['sensor', 'erkennung'] },
|
||||||
|
{ name: 'sensors_off', codepoint: 0xe51f, tags: ['sensor', 'aus'] },
|
||||||
|
{ name: 'motion_sensor_active', codepoint: 0xf090, tags: ['bewegung', 'aktiv'] },
|
||||||
|
{ name: 'motion_sensor_idle', codepoint: 0xf091, tags: ['bewegung', 'inaktiv'] },
|
||||||
|
{ name: 'motion_sensor_alert', codepoint: 0xf092, tags: ['bewegung', 'alarm'] },
|
||||||
|
{ name: 'nest_cam_outdoor', codepoint: 0xf8bd, tags: ['kamera', 'aussen'] },
|
||||||
|
{ name: 'videocam', codepoint: 0xe04b, tags: ['kamera', 'video'] },
|
||||||
|
{ name: 'videocam_off', codepoint: 0xe04c, tags: ['kamera', 'aus'] },
|
||||||
|
{ name: 'smoke_detector', codepoint: 0xf099, tags: ['rauch', 'rauchmelder'] },
|
||||||
|
{ name: 'detector_smoke', codepoint: 0xf239, tags: ['rauch', 'melder'] },
|
||||||
|
{ name: 'co2', codepoint: 0xe7b1, tags: ['co2', 'luft', 'qualitaet'] },
|
||||||
|
{ name: 'flood', codepoint: 0xebe6, tags: ['wasser', 'flut', 'leck'] },
|
||||||
|
|
||||||
|
// Media & Entertainment
|
||||||
|
{ name: 'tv', codepoint: 0xe333, tags: ['fernseher', 'television'] },
|
||||||
|
{ name: 'connected_tv', codepoint: 0xe998, tags: ['smart', 'tv', 'fernseher'] },
|
||||||
|
{ name: 'speaker', codepoint: 0xe32d, tags: ['lautsprecher', 'audio'] },
|
||||||
|
{ name: 'speaker_group', codepoint: 0xe32e, tags: ['lautsprecher', 'gruppe'] },
|
||||||
|
{ name: 'volume_up', codepoint: 0xe050, tags: ['laut', 'lauter', 'volume'] },
|
||||||
|
{ name: 'volume_down', codepoint: 0xe04d, tags: ['leise', 'leiser', 'volume'] },
|
||||||
|
{ name: 'volume_mute', codepoint: 0xe04e, tags: ['stumm', 'mute'] },
|
||||||
|
{ name: 'volume_off', codepoint: 0xe04f, tags: ['aus', 'stumm'] },
|
||||||
|
{ name: 'play_arrow', codepoint: 0xe037, tags: ['play', 'abspielen'] },
|
||||||
|
{ name: 'pause', codepoint: 0xe034, tags: ['pause', 'anhalten'] },
|
||||||
|
{ name: 'stop', codepoint: 0xe047, tags: ['stop', 'anhalten'] },
|
||||||
|
{ name: 'skip_next', codepoint: 0xe044, tags: ['naechstes', 'weiter'] },
|
||||||
|
{ name: 'skip_previous', codepoint: 0xe045, tags: ['vorheriges', 'zurueck'] },
|
||||||
|
{ name: 'music_note', codepoint: 0xe405, tags: ['musik', 'note'] },
|
||||||
|
{ name: 'radio', codepoint: 0xe03e, tags: ['radio'] },
|
||||||
|
|
||||||
|
// Appliances
|
||||||
|
{ name: 'kitchen', codepoint: 0xeb47, tags: ['kueche'] },
|
||||||
|
{ name: 'coffee_maker', codepoint: 0xefe1, tags: ['kaffee', 'maschine'] },
|
||||||
|
{ name: 'coffee', codepoint: 0xefef, tags: ['kaffee', 'tasse'] },
|
||||||
|
{ name: 'microwave', codepoint: 0xf204, tags: ['mikrowelle'] },
|
||||||
|
{ name: 'oven', codepoint: 0xf07e, tags: ['ofen', 'backofen', 'herd'] },
|
||||||
|
{ name: 'countertops', codepoint: 0xf1f4, tags: ['arbeitsplatte', 'kueche'] },
|
||||||
|
{ name: 'dishwasher', codepoint: 0xf02d, tags: ['spuelmaschine', 'geschirr'] },
|
||||||
|
{ name: 'local_laundry_service', codepoint: 0xe54a, tags: ['waschmaschine', 'waesche'] },
|
||||||
|
{ name: 'dry_cleaning', codepoint: 0xea58, tags: ['trockner', 'reinigung'] },
|
||||||
|
{ name: 'iron', codepoint: 0xe583, tags: ['buegeln', 'buegeleisen'] },
|
||||||
|
{ name: 'vacuum', codepoint: 0xf1d2, tags: ['staubsauger'] },
|
||||||
|
|
||||||
|
// Navigation & Arrows
|
||||||
|
{ name: 'arrow_back', codepoint: 0xe5c4, tags: ['zurueck', 'links', 'pfeil'] },
|
||||||
|
{ name: 'arrow_forward', codepoint: 0xe5c8, tags: ['weiter', 'rechts', 'pfeil'] },
|
||||||
|
{ name: 'arrow_upward', codepoint: 0xe5d8, tags: ['hoch', 'oben', 'pfeil'] },
|
||||||
|
{ name: 'arrow_downward', codepoint: 0xe5db, tags: ['runter', 'unten', 'pfeil'] },
|
||||||
|
{ name: 'expand_less', codepoint: 0xe5ce, tags: ['weniger', 'hoch', 'pfeil'] },
|
||||||
|
{ name: 'expand_more', codepoint: 0xe5cf, tags: ['mehr', 'runter', 'pfeil'] },
|
||||||
|
{ name: 'chevron_left', codepoint: 0xe5cb, tags: ['links', 'pfeil'] },
|
||||||
|
{ name: 'chevron_right', codepoint: 0xe5cc, tags: ['rechts', 'pfeil'] },
|
||||||
|
{ name: 'keyboard_arrow_up', codepoint: 0xe316, tags: ['hoch', 'pfeil'] },
|
||||||
|
{ name: 'keyboard_arrow_down', codepoint: 0xe313, tags: ['runter', 'pfeil'] },
|
||||||
|
{ name: 'keyboard_arrow_left', codepoint: 0xe314, tags: ['links', 'pfeil'] },
|
||||||
|
{ name: 'keyboard_arrow_right', codepoint: 0xe315, tags: ['rechts', 'pfeil'] },
|
||||||
|
{ name: 'menu', codepoint: 0xe5d2, tags: ['menu', 'hamburger'] },
|
||||||
|
{ name: 'close', codepoint: 0xe5cd, tags: ['schliessen', 'x'] },
|
||||||
|
{ name: 'refresh', codepoint: 0xe5d5, tags: ['aktualisieren', 'refresh'] },
|
||||||
|
{ name: 'sync', codepoint: 0xe627, tags: ['sync', 'synchronisieren'] },
|
||||||
|
|
||||||
|
// Status & Indicators
|
||||||
|
{ name: 'check', codepoint: 0xe5ca, tags: ['check', 'ok', 'fertig'] },
|
||||||
|
{ name: 'check_circle', codepoint: 0xe86c, tags: ['check', 'ok', 'kreis'] },
|
||||||
|
{ name: 'cancel', codepoint: 0xe5c9, tags: ['abbrechen', 'cancel'] },
|
||||||
|
{ name: 'block', codepoint: 0xe14b, tags: ['blockiert', 'verboten'] },
|
||||||
|
{ name: 'add', codepoint: 0xe145, tags: ['plus', 'hinzufuegen'] },
|
||||||
|
{ name: 'remove', codepoint: 0xe15b, tags: ['minus', 'entfernen'] },
|
||||||
|
{ name: 'add_circle', codepoint: 0xe147, tags: ['plus', 'kreis'] },
|
||||||
|
{ name: 'remove_circle', codepoint: 0xe15c, tags: ['minus', 'kreis'] },
|
||||||
|
{ name: 'help', codepoint: 0xe887, tags: ['hilfe', 'fragezeichen'] },
|
||||||
|
{ name: 'info', codepoint: 0xe88e, tags: ['info', 'information'] },
|
||||||
|
{ name: 'pending', codepoint: 0xef64, tags: ['warten', 'pending'] },
|
||||||
|
{ name: 'schedule', codepoint: 0xe8b5, tags: ['zeit', 'uhr', 'timer'] },
|
||||||
|
{ name: 'timer', codepoint: 0xe425, tags: ['timer', 'stoppuhr'] },
|
||||||
|
{ name: 'hourglass_empty', codepoint: 0xe88b, tags: ['sanduhr', 'warten'] },
|
||||||
|
|
||||||
|
// Rooms
|
||||||
|
{ name: 'bed', codepoint: 0xefeb, tags: ['bett', 'schlafzimmer'] },
|
||||||
|
{ name: 'king_bed', codepoint: 0xea45, tags: ['bett', 'gross'] },
|
||||||
|
{ name: 'single_bed', codepoint: 0xea48, tags: ['bett', 'einzelbett'] },
|
||||||
|
{ name: 'bedroom_parent', codepoint: 0xf885, tags: ['schlafzimmer', 'eltern'] },
|
||||||
|
{ name: 'bedroom_child', codepoint: 0xf884, tags: ['schlafzimmer', 'kind'] },
|
||||||
|
{ name: 'bedroom_baby', codepoint: 0xf883, tags: ['schlafzimmer', 'baby'] },
|
||||||
|
{ name: 'living', codepoint: 0xf07b, tags: ['wohnzimmer', 'sofa'] },
|
||||||
|
{ name: 'chair', codepoint: 0xefed, tags: ['stuhl', 'sitzen'] },
|
||||||
|
{ name: 'chair_alt', codepoint: 0xefee, tags: ['stuhl'] },
|
||||||
|
{ name: 'dining', codepoint: 0xeff4, tags: ['esszimmer', 'essen'] },
|
||||||
|
{ name: 'bathroom', codepoint: 0xefdd, tags: ['bad', 'badezimmer'] },
|
||||||
|
{ name: 'bathtub', codepoint: 0xea41, tags: ['badewanne', 'bad'] },
|
||||||
|
{ name: 'shower', codepoint: 0xf061, tags: ['dusche', 'bad'] },
|
||||||
|
{ name: 'hot_tub', codepoint: 0xeb46, tags: ['whirlpool', 'spa'] },
|
||||||
|
{ name: 'soap', codepoint: 0xf1b2, tags: ['seife', 'waschen'] },
|
||||||
|
|
||||||
|
// Weather
|
||||||
|
{ name: 'wb_cloudy', codepoint: 0xe42d, tags: ['wolke', 'bewoelkt'] },
|
||||||
|
{ name: 'cloud', codepoint: 0xe2bd, tags: ['wolke'] },
|
||||||
|
{ name: 'cloudy', codepoint: 0xf171, tags: ['bewoelkt'] },
|
||||||
|
{ name: 'partly_cloudy_day', codepoint: 0xf172, tags: ['teilweise', 'bewoelkt', 'tag'] },
|
||||||
|
{ name: 'partly_cloudy_night', codepoint: 0xf174, tags: ['teilweise', 'bewoelkt', 'nacht'] },
|
||||||
|
{ name: 'rainy', codepoint: 0xf176, tags: ['regen', 'regnerisch'] },
|
||||||
|
{ name: 'thunderstorm', codepoint: 0xebdb, tags: ['gewitter', 'blitz'] },
|
||||||
|
{ name: 'snowing', codepoint: 0xf17c, tags: ['schnee', 'winter'] },
|
||||||
|
{ name: 'foggy', codepoint: 0xf175, tags: ['nebel', 'neblig'] },
|
||||||
|
{ name: 'sunny', codepoint: 0xf177, tags: ['sonne', 'sonnig'] },
|
||||||
|
{ name: 'weather_mix', codepoint: 0xf879, tags: ['wetter', 'gemischt'] },
|
||||||
|
|
||||||
|
// Settings & Configuration
|
||||||
|
{ name: 'settings', codepoint: 0xe8b8, tags: ['einstellungen', 'zahnrad'] },
|
||||||
|
{ name: 'tune', codepoint: 0xe429, tags: ['anpassen', 'regler'] },
|
||||||
|
{ name: 'build', codepoint: 0xe869, tags: ['werkzeug', 'schraubenschluessel'] },
|
||||||
|
{ name: 'construction', codepoint: 0xea3c, tags: ['baustelle', 'werkzeug'] },
|
||||||
|
{ name: 'handyman', codepoint: 0xf10b, tags: ['handwerker', 'werkzeug'] },
|
||||||
|
{ name: 'miscellaneous_services', codepoint: 0xf10c, tags: ['dienste', 'verschiedenes'] },
|
||||||
|
{ name: 'edit', codepoint: 0xe3c9, tags: ['bearbeiten', 'stift'] },
|
||||||
|
{ name: 'delete', codepoint: 0xe872, tags: ['loeschen', 'papierkorb'] },
|
||||||
|
{ name: 'save', codepoint: 0xe161, tags: ['speichern'] },
|
||||||
|
{ name: 'content_copy', codepoint: 0xe14d, tags: ['kopieren'] },
|
||||||
|
{ name: 'content_paste', codepoint: 0xe14f, tags: ['einfuegen'] },
|
||||||
|
|
||||||
|
// People
|
||||||
|
{ name: 'person', codepoint: 0xe7fd, tags: ['person', 'benutzer'] },
|
||||||
|
{ name: 'people', codepoint: 0xe7fb, tags: ['personen', 'gruppe'] },
|
||||||
|
{ name: 'group', codepoint: 0xe7ef, tags: ['gruppe', 'team'] },
|
||||||
|
{ name: 'family_restroom', codepoint: 0xe59d, tags: ['familie'] },
|
||||||
|
{ name: 'child_care', codepoint: 0xeb41, tags: ['kind', 'baby'] },
|
||||||
|
{ name: 'elderly', codepoint: 0xf21a, tags: ['aelter', 'senior'] },
|
||||||
|
{ name: 'pets', codepoint: 0xe91d, tags: ['haustier', 'tier', 'pfote'] },
|
||||||
|
|
||||||
|
// Connectivity
|
||||||
|
{ name: 'wifi', codepoint: 0xe63e, tags: ['wlan', 'wireless', 'netzwerk'] },
|
||||||
|
{ name: 'wifi_off', codepoint: 0xe648, tags: ['wlan', 'aus'] },
|
||||||
|
{ name: 'signal_wifi_4_bar', codepoint: 0xe1d8, tags: ['wlan', 'signal', 'stark'] },
|
||||||
|
{ name: 'signal_wifi_0_bar', codepoint: 0xf067, tags: ['wlan', 'signal', 'schwach'] },
|
||||||
|
{ name: 'bluetooth', codepoint: 0xe1a7, tags: ['bluetooth'] },
|
||||||
|
{ name: 'bluetooth_connected', codepoint: 0xe1a8, tags: ['bluetooth', 'verbunden'] },
|
||||||
|
{ name: 'bluetooth_disabled', codepoint: 0xe1a9, tags: ['bluetooth', 'aus'] },
|
||||||
|
{ name: 'link', codepoint: 0xe157, tags: ['link', 'verbindung'] },
|
||||||
|
{ name: 'link_off', codepoint: 0xe16f, tags: ['link', 'getrennt'] },
|
||||||
|
{ name: 'sync_alt', codepoint: 0xea18, tags: ['sync', 'austausch'] },
|
||||||
|
{ name: 'cloud_sync', codepoint: 0xeb5a, tags: ['cloud', 'sync'] },
|
||||||
|
{ name: 'cloud_done', codepoint: 0xe2bf, tags: ['cloud', 'fertig'] },
|
||||||
|
{ name: 'cloud_off', codepoint: 0xe2c0, tags: ['cloud', 'offline'] },
|
||||||
|
|
||||||
|
// Misc useful
|
||||||
|
{ name: 'favorite', codepoint: 0xe87d, tags: ['favorit', 'herz', 'liebe'] },
|
||||||
|
{ name: 'favorite_border', codepoint: 0xe87e, tags: ['favorit', 'herz', 'rand'] },
|
||||||
|
{ name: 'star', codepoint: 0xe838, tags: ['stern', 'favorit'] },
|
||||||
|
{ name: 'star_border', codepoint: 0xe83a, tags: ['stern', 'rand'] },
|
||||||
|
{ name: 'thumb_up', codepoint: 0xe8dc, tags: ['daumen', 'hoch', 'gut'] },
|
||||||
|
{ name: 'thumb_down', codepoint: 0xe8db, tags: ['daumen', 'runter', 'schlecht'] },
|
||||||
|
{ name: 'eco', codepoint: 0xea35, tags: ['eco', 'blatt', 'natur'] },
|
||||||
|
{ name: 'recycling', codepoint: 0xe760, tags: ['recycling', 'umwelt'] },
|
||||||
|
{ name: 'local_fire_department', codepoint: 0xef55, tags: ['feuer', 'feuerwehr', 'flamme'] },
|
||||||
|
{ name: 'whatshot', codepoint: 0xe80e, tags: ['heiss', 'feuer', 'flamme'] },
|
||||||
|
{ name: 'terrain', codepoint: 0xe564, tags: ['berg', 'natur'] },
|
||||||
|
{ name: 'park', codepoint: 0xea63, tags: ['park', 'baum', 'natur'] },
|
||||||
|
{ name: 'grass', codepoint: 0xf205, tags: ['gras', 'rasen', 'garten'] },
|
||||||
|
{ name: 'spa', codepoint: 0xeb4c, tags: ['spa', 'wellness', 'blume'] },
|
||||||
|
{ name: 'local_florist', codepoint: 0xe546, tags: ['blume', 'pflanze'] },
|
||||||
|
{ name: 'pool', codepoint: 0xeb48, tags: ['pool', 'schwimmen', 'wasser'] },
|
||||||
|
{ name: 'water', codepoint: 0xf084, tags: ['wasser', 'wellen'] },
|
||||||
|
{ name: 'waves', codepoint: 0xe176, tags: ['wellen', 'wasser'] },
|
||||||
|
{ name: 'oil_barrel', codepoint: 0xec15, tags: ['oel', 'fass', 'heizoel'] },
|
||||||
|
{ name: 'gas_meter', codepoint: 0xec19, tags: ['gas', 'zaehler'] },
|
||||||
|
{ name: 'propane', codepoint: 0xec14, tags: ['propan', 'gas'] },
|
||||||
|
{ name: 'propane_tank', codepoint: 0xec13, tags: ['propan', 'tank'] },
|
||||||
|
{ name: 'heat_pump', codepoint: 0xec18, tags: ['waermepumpe', 'heizung'] },
|
||||||
|
{ name: 'mode_night', codepoint: 0xf036, tags: ['nacht', 'mond', 'dunkel'] },
|
||||||
|
{ name: 'bedtime', codepoint: 0xef44, tags: ['schlafenszeit', 'nacht'] },
|
||||||
|
{ name: 'alarm', codepoint: 0xe855, tags: ['wecker', 'alarm'] },
|
||||||
|
{ name: 'alarm_on', codepoint: 0xe856, tags: ['wecker', 'an'] },
|
||||||
|
{ name: 'alarm_off', codepoint: 0xe857, tags: ['wecker', 'aus'] },
|
||||||
|
{ name: 'snooze', codepoint: 0xe046, tags: ['schlummern', 'wecker'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to search icons
|
||||||
|
export function searchIcons(query) {
|
||||||
|
if (!query || query.length === 0) {
|
||||||
|
return MATERIAL_ICONS;
|
||||||
|
}
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return MATERIAL_ICONS.filter(icon =>
|
||||||
|
icon.name.toLowerCase().includes(q) ||
|
||||||
|
icon.tags.some(tag => tag.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get icon by codepoint
|
||||||
|
export function getIconByCodepoint(codepoint) {
|
||||||
|
return MATERIAL_ICONS.find(icon => icon.codepoint === codepoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert codepoint to character
|
||||||
|
export function codepointToChar(codepoint) {
|
||||||
|
return String.fromCodePoint(codepoint);
|
||||||
|
}
|
||||||
@ -158,7 +158,14 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
function addWidget(typeStr) {
|
function addWidget(typeStr) {
|
||||||
if (!activeScreen.value) return;
|
if (!activeScreen.value) return;
|
||||||
|
|
||||||
const typeValue = typeStr === 'label' ? WIDGET_TYPES.LABEL : (typeStr === 'button' ? WIDGET_TYPES.BUTTON : WIDGET_TYPES.LED);
|
let typeValue;
|
||||||
|
switch (typeStr) {
|
||||||
|
case 'label': typeValue = WIDGET_TYPES.LABEL; break;
|
||||||
|
case 'button': typeValue = WIDGET_TYPES.BUTTON; break;
|
||||||
|
case 'led': typeValue = WIDGET_TYPES.LED; break;
|
||||||
|
case 'icon': typeValue = WIDGET_TYPES.ICON; break;
|
||||||
|
default: typeValue = WIDGET_TYPES.LABEL;
|
||||||
|
}
|
||||||
const defaults = WIDGET_DEFAULTS[typeStr];
|
const defaults = WIDGET_DEFAULTS[typeStr];
|
||||||
|
|
||||||
const w = {
|
const w = {
|
||||||
@ -181,7 +188,11 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
isToggle: defaults.isToggle,
|
isToggle: defaults.isToggle,
|
||||||
knxAddrWrite: defaults.knxAddrWrite,
|
knxAddrWrite: defaults.knxAddrWrite,
|
||||||
action: defaults.action,
|
action: defaults.action,
|
||||||
targetScreen: defaults.targetScreen
|
targetScreen: defaults.targetScreen,
|
||||||
|
iconCodepoint: defaults.iconCodepoint || 0,
|
||||||
|
iconPosition: defaults.iconPosition || 0,
|
||||||
|
iconSize: defaults.iconSize || 1,
|
||||||
|
iconGap: defaults.iconGap || 8
|
||||||
};
|
};
|
||||||
|
|
||||||
activeScreen.value.widgets.push(w);
|
activeScreen.value.widgets.push(w);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1419;
|
--bg: #0f1419;
|
||||||
@ -580,3 +581,28 @@ body {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Material Symbols Icon Font */
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-feature-settings: 'liga';
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function minSizeFor(widget) {
|
|||||||
const key = typeKeyFor(widget.type);
|
const key = typeKeyFor(widget.type);
|
||||||
if (key === 'button') return { w: 60, h: 30 };
|
if (key === 'button') return { w: 60, h: 30 };
|
||||||
if (key === 'led') return { w: 20, h: 20 };
|
if (key === 'led') return { w: 20, h: 20 };
|
||||||
|
if (key === 'icon') return { w: 24, h: 24 };
|
||||||
return { w: 40, h: 20 };
|
return { w: 40, h: 20 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +54,12 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
|||||||
// Action defaults
|
// Action defaults
|
||||||
if (w.action === undefined) w.action = 0; // BUTTON_ACTIONS.KNX
|
if (w.action === undefined) w.action = 0; // BUTTON_ACTIONS.KNX
|
||||||
if (w.targetScreen === undefined) w.targetScreen = 0;
|
if (w.targetScreen === undefined) w.targetScreen = 0;
|
||||||
|
|
||||||
|
// Icon defaults
|
||||||
|
if (w.iconCodepoint === undefined) w.iconCodepoint = defaults.iconCodepoint || 0;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
|
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user