Fixes
This commit is contained in:
parent
1ea8bb7e12
commit
f7f3f8946c
@ -45,7 +45,9 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
|||||||
buf[pos++] = iconPosition;
|
buf[pos++] = iconPosition;
|
||||||
buf[pos++] = iconSize;
|
buf[pos++] = iconSize;
|
||||||
buf[pos++] = static_cast<uint8_t>(iconGap);
|
buf[pos++] = static_cast<uint8_t>(iconGap);
|
||||||
buf[pos++] = 0; // padding for alignment
|
|
||||||
|
// Hierarchy
|
||||||
|
buf[pos++] = static_cast<uint8_t>(parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetConfig::deserialize(const uint8_t* buf) {
|
void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||||
@ -89,12 +91,15 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
|||||||
iconPosition = buf[pos++];
|
iconPosition = buf[pos++];
|
||||||
iconSize = buf[pos++];
|
iconSize = buf[pos++];
|
||||||
iconGap = static_cast<int8_t>(buf[pos++]);
|
iconGap = static_cast<int8_t>(buf[pos++]);
|
||||||
pos++; // padding
|
|
||||||
|
// Hierarchy
|
||||||
|
parentId = static_cast<int8_t>(buf[pos++]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
WidgetConfig cfg = {};
|
WidgetConfig cfg = {};
|
||||||
cfg.id = id;
|
cfg.id = id;
|
||||||
|
cfg.parentId = -1; // Root
|
||||||
cfg.type = WidgetType::LABEL;
|
cfg.type = WidgetType::LABEL;
|
||||||
cfg.x = x;
|
cfg.x = x;
|
||||||
cfg.y = y;
|
cfg.y = y;
|
||||||
@ -129,6 +134,7 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
|
|||||||
const char* labelText, uint16_t knxAddrWrite, bool toggle) {
|
const char* labelText, uint16_t knxAddrWrite, bool toggle) {
|
||||||
WidgetConfig cfg = {};
|
WidgetConfig cfg = {};
|
||||||
cfg.id = id;
|
cfg.id = id;
|
||||||
|
cfg.parentId = -1; // Root
|
||||||
cfg.type = WidgetType::BUTTON;
|
cfg.type = WidgetType::BUTTON;
|
||||||
cfg.x = x;
|
cfg.x = x;
|
||||||
cfg.y = y;
|
cfg.y = y;
|
||||||
|
|||||||
@ -107,8 +107,11 @@ struct WidgetConfig {
|
|||||||
uint8_t iconSize; // Font size index (0-5), same as fontSize
|
uint8_t iconSize; // Font size index (0-5), same as fontSize
|
||||||
int8_t iconGap; // Gap between icon and text (px)
|
int8_t iconGap; // Gap between icon and text (px)
|
||||||
|
|
||||||
|
// Hierarchy
|
||||||
|
int8_t parentId; // ID of parent widget (-1 = root/screen)
|
||||||
|
|
||||||
// Serialization size (fixed for NVS storage)
|
// Serialization size (fixed for NVS storage)
|
||||||
static constexpr size_t SERIALIZED_SIZE = 76;
|
static constexpr size_t SERIALIZED_SIZE = 77;
|
||||||
|
|
||||||
void serialize(uint8_t* buf) const;
|
void serialize(uint8_t* buf) const;
|
||||||
void deserialize(const uint8_t* buf);
|
void deserialize(const uint8_t* buf);
|
||||||
|
|||||||
@ -388,28 +388,46 @@ void WidgetManager::showModalScreen(const ScreenConfig& screen) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::closeModal() {
|
void WidgetManager::closeModal() {
|
||||||
if (!modalContainer_) return;
|
printf("WM: closeModal Start. Container=%p\n", (void*)modalContainer_);
|
||||||
|
fflush(stdout);
|
||||||
|
if (!modalContainer_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (esp_lv_adapter_lock(-1) != ESP_OK) return;
|
if (esp_lv_adapter_lock(-1) != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "closeModal: Failed to lock LVGL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset all input devices BEFORE destroying widgets
|
// Reset input devices
|
||||||
lv_indev_t* indev = lv_indev_get_next(nullptr);
|
lv_indev_t* indev = lv_indev_get_next(nullptr);
|
||||||
while (indev) {
|
while (indev) {
|
||||||
lv_indev_reset(indev, nullptr);
|
lv_indev_reset(indev, nullptr);
|
||||||
indev = lv_indev_get_next(indev);
|
indev = lv_indev_get_next(indev);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First destroy C++ widgets (which deletes their LVGL objects)
|
// Destroy widgets first
|
||||||
|
printf("WM: closeModal destroying widgets...\n");
|
||||||
|
fflush(stdout);
|
||||||
destroyAllWidgets();
|
destroyAllWidgets();
|
||||||
|
|
||||||
if (modalDimmer_) {
|
if (modalDimmer_) {
|
||||||
lv_obj_delete(modalDimmer_);
|
if (lv_obj_is_valid(modalDimmer_)) lv_obj_delete(modalDimmer_);
|
||||||
|
modalDimmer_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lv_obj_is_valid(modalContainer_)) {
|
||||||
|
lv_obj_delete(modalContainer_);
|
||||||
|
} else {
|
||||||
|
printf("WM: Warning: modalContainer_ was invalid!\n");
|
||||||
|
fflush(stdout);
|
||||||
}
|
}
|
||||||
lv_obj_delete(modalContainer_);
|
|
||||||
esp_lv_adapter_unlock();
|
|
||||||
modalContainer_ = nullptr;
|
modalContainer_ = nullptr;
|
||||||
modalDimmer_ = nullptr;
|
|
||||||
modalScreenId_ = SCREEN_ID_NONE;
|
modalScreenId_ = SCREEN_ID_NONE;
|
||||||
|
|
||||||
|
esp_lv_adapter_unlock();
|
||||||
|
printf("WM: closeModal Complete\n");
|
||||||
|
fflush(stdout);
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::showScreen(uint8_t screenId) {
|
void WidgetManager::showScreen(uint8_t screenId) {
|
||||||
@ -436,10 +454,12 @@ void WidgetManager::showScreen(uint8_t screenId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target) {
|
void WidgetManager::handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target) {
|
||||||
ESP_LOGI(TAG, "handleButtonAction: button=%d action=%d targetScreen=%d type=%d",
|
printf("WM: handleButtonAction btn=%d act=%d type=%d\n", cfg.id, (int)cfg.action, (int)cfg.type);
|
||||||
cfg.id, static_cast<int>(cfg.action), cfg.targetScreen, static_cast<int>(cfg.type));
|
fflush(stdout);
|
||||||
|
|
||||||
if (cfg.type != WidgetType::BUTTON) {
|
if (cfg.type != WidgetType::BUTTON) {
|
||||||
|
printf("WM: Not a button!\n");
|
||||||
|
fflush(stdout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,14 +467,16 @@ void WidgetManager::handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target
|
|||||||
|
|
||||||
switch (cfg.action) {
|
switch (cfg.action) {
|
||||||
case ButtonAction::JUMP:
|
case ButtonAction::JUMP:
|
||||||
ESP_LOGI(TAG, "JUMP action: scheduling navigation to screen %d", cfg.targetScreen);
|
printf("WM: Action JUMP to %d\n", cfg.targetScreen);
|
||||||
|
fflush(stdout);
|
||||||
navAction_ = ButtonAction::JUMP;
|
navAction_ = ButtonAction::JUMP;
|
||||||
navTargetScreen_ = cfg.targetScreen;
|
navTargetScreen_ = cfg.targetScreen;
|
||||||
navPending_ = true;
|
navPending_ = true;
|
||||||
navRequestUs_ = esp_timer_get_time();
|
navRequestUs_ = esp_timer_get_time();
|
||||||
break;
|
break;
|
||||||
case ButtonAction::BACK:
|
case ButtonAction::BACK:
|
||||||
ESP_LOGI(TAG, "BACK action: scheduling navigation back");
|
printf("WM: Action BACK\n");
|
||||||
|
fflush(stdout);
|
||||||
navAction_ = ButtonAction::BACK;
|
navAction_ = ButtonAction::BACK;
|
||||||
navTargetScreen_ = SCREEN_ID_NONE;
|
navTargetScreen_ = SCREEN_ID_NONE;
|
||||||
navPending_ = true;
|
navPending_ = true;
|
||||||
@ -477,16 +499,43 @@ void WidgetManager::handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::goBack() {
|
void WidgetManager::goBack() {
|
||||||
|
printf("WM: goBack called. Modal=%p Active=%d Prev=%d\n",
|
||||||
|
(void*)modalContainer_, activeScreenId_, previousScreenId_);
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
if (modalContainer_) {
|
if (modalContainer_) {
|
||||||
|
printf("WM: Closing modal...\n");
|
||||||
|
fflush(stdout);
|
||||||
closeModal();
|
closeModal();
|
||||||
applyScreen(activeScreenId_);
|
printf("WM: Modal closed. Restoring screen %d\n", activeScreenId_);
|
||||||
|
fflush(stdout);
|
||||||
|
// Restore the active screen (which was in background)
|
||||||
|
if (config_.findScreen(activeScreenId_)) {
|
||||||
|
applyScreen(activeScreenId_);
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(TAG, "Active screen %d not found after closing modal!", activeScreenId_);
|
||||||
|
if (config_.findScreen(config_.startScreenId)) {
|
||||||
|
activeScreenId_ = config_.startScreenId;
|
||||||
|
applyScreen(activeScreenId_);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousScreenId_ != SCREEN_ID_NONE && previousScreenId_ != activeScreenId_) {
|
if (previousScreenId_ != SCREEN_ID_NONE && previousScreenId_ != activeScreenId_) {
|
||||||
activeScreenId_ = previousScreenId_;
|
printf("WM: Going back to screen %d\n", previousScreenId_);
|
||||||
previousScreenId_ = SCREEN_ID_NONE;
|
fflush(stdout);
|
||||||
applyScreen(activeScreenId_);
|
if (config_.findScreen(previousScreenId_)) {
|
||||||
|
activeScreenId_ = previousScreenId_;
|
||||||
|
previousScreenId_ = SCREEN_ID_NONE;
|
||||||
|
applyScreen(activeScreenId_);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "Previous screen %d not found", previousScreenId_);
|
||||||
|
previousScreenId_ = SCREEN_ID_NONE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printf("WM: No previous screen to go back to\n");
|
||||||
|
fflush(stdout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -562,9 +611,14 @@ void WidgetManager::onUserActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::destroyAllWidgets() {
|
void WidgetManager::destroyAllWidgets() {
|
||||||
for (auto& widget : widgets_) {
|
ESP_LOGI(TAG, "destroyAllWidgets: Start");
|
||||||
widget.reset();
|
for (size_t i = 0; i < widgets_.size(); i++) {
|
||||||
|
if (widgets_[i]) {
|
||||||
|
// ESP_LOGI(TAG, "Destroying widget %d", i);
|
||||||
|
widgets_[i].reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ESP_LOGI(TAG, "destroyAllWidgets: Complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* parent) {
|
void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* parent) {
|
||||||
@ -576,8 +630,11 @@ void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* paren
|
|||||||
screen.backgroundColor.b), 0);
|
screen.backgroundColor.b), 0);
|
||||||
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0);
|
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0);
|
||||||
|
|
||||||
|
// Pass 1: Create root widgets (parentId == -1)
|
||||||
for (uint8_t i = 0; i < screen.widgetCount; i++) {
|
for (uint8_t i = 0; i < screen.widgetCount; i++) {
|
||||||
const WidgetConfig& cfg = screen.widgets[i];
|
const WidgetConfig& cfg = screen.widgets[i];
|
||||||
|
if (cfg.parentId != -1) continue;
|
||||||
|
|
||||||
auto widget = WidgetFactory::create(cfg);
|
auto widget = WidgetFactory::create(cfg);
|
||||||
if (widget && cfg.id < MAX_WIDGETS) {
|
if (widget && cfg.id < MAX_WIDGETS) {
|
||||||
widget->create(parent);
|
widget->create(parent);
|
||||||
@ -585,6 +642,41 @@ void WidgetManager::createAllWidgets(const ScreenConfig& screen, lv_obj_t* paren
|
|||||||
widgets_[cfg.id] = std::move(widget);
|
widgets_[cfg.id] = std::move(widget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass 2: Create child widgets
|
||||||
|
// Simple 1-level depth support for now. For deeper nesting, we'd need a topological sort or multiple passes.
|
||||||
|
bool madeProgress = true;
|
||||||
|
int remainingPasses = 10; // Prevent infinite loops
|
||||||
|
|
||||||
|
while (madeProgress && remainingPasses > 0) {
|
||||||
|
madeProgress = false;
|
||||||
|
remainingPasses--;
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < screen.widgetCount; i++) {
|
||||||
|
const WidgetConfig& cfg = screen.widgets[i];
|
||||||
|
|
||||||
|
// Skip if already created
|
||||||
|
if (widgets_[cfg.id]) continue;
|
||||||
|
|
||||||
|
// Skip if it's a root widget (should be created in Pass 1, but if failed/skipped, ignore)
|
||||||
|
if (cfg.parentId == -1) continue;
|
||||||
|
|
||||||
|
// Check if parent exists
|
||||||
|
if (cfg.parentId >= 0 && cfg.parentId < MAX_WIDGETS && widgets_[cfg.parentId]) {
|
||||||
|
// Parent exists! Get its LVGL object
|
||||||
|
lv_obj_t* parentObj = widgets_[cfg.parentId]->getObj();
|
||||||
|
if (parentObj) {
|
||||||
|
auto widget = WidgetFactory::create(cfg);
|
||||||
|
if (widget && cfg.id < MAX_WIDGETS) {
|
||||||
|
widget->create(parentObj);
|
||||||
|
widget->applyStyle();
|
||||||
|
widgets_[cfg.id] = std::move(widget);
|
||||||
|
madeProgress = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::onKnxValue(uint16_t groupAddr, float value) {
|
void WidgetManager::onKnxValue(uint16_t groupAddr, float value) {
|
||||||
@ -776,6 +868,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
|||||||
cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition);
|
cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition);
|
||||||
cJSON_AddNumberToObject(widget, "iconSize", w.iconSize);
|
cJSON_AddNumberToObject(widget, "iconSize", w.iconSize);
|
||||||
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
|
cJSON_AddNumberToObject(widget, "iconGap", w.iconGap);
|
||||||
|
|
||||||
|
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
|
||||||
|
|
||||||
cJSON_AddItemToArray(widgets, widget);
|
cJSON_AddItemToArray(widgets, widget);
|
||||||
}
|
}
|
||||||
@ -923,6 +1017,13 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
|
|
||||||
cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap");
|
cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap");
|
||||||
if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint;
|
if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint;
|
||||||
|
|
||||||
|
cJSON* parentId = cJSON_GetObjectItem(widget, "parentId");
|
||||||
|
if (cJSON_IsNumber(parentId)) {
|
||||||
|
w.parentId = static_cast<int8_t>(parentId->valueint);
|
||||||
|
} else {
|
||||||
|
w.parentId = -1; // Default to root
|
||||||
|
}
|
||||||
|
|
||||||
screen.widgetCount++;
|
screen.widgetCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,9 +52,14 @@ int ButtonWidget::encodeUtf8(uint32_t codepoint, char* buf) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ButtonWidget::clickCallback(lv_event_t* e) {
|
void ButtonWidget::clickCallback(lv_event_t* e) {
|
||||||
ESP_LOGI(TAG, "clickCallback called");
|
printf("ButtonWidget::clickCallback\n");
|
||||||
|
fflush(stdout);
|
||||||
ButtonWidget* widget = static_cast<ButtonWidget*>(lv_event_get_user_data(e));
|
ButtonWidget* widget = static_cast<ButtonWidget*>(lv_event_get_user_data(e));
|
||||||
if (!widget) return;
|
if (!widget) {
|
||||||
|
printf("ButtonWidget: Widget is null!\n");
|
||||||
|
fflush(stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
lv_obj_t* target = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||||
WidgetManager::instance().handleButtonAction(widget->getConfig(), target);
|
WidgetManager::instance().handleButtonAction(widget->getConfig(), target);
|
||||||
}
|
}
|
||||||
@ -92,6 +97,7 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
|
|||||||
lv_obj_remove_style_all(contentContainer_);
|
lv_obj_remove_style_all(contentContainer_);
|
||||||
lv_obj_set_size(contentContainer_, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
lv_obj_set_size(contentContainer_, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||||
lv_obj_center(contentContainer_);
|
lv_obj_center(contentContainer_);
|
||||||
|
lv_obj_clear_flag(contentContainer_, LV_OBJ_FLAG_CLICKABLE); // Pass clicks to parent
|
||||||
|
|
||||||
// Create icon label
|
// Create icon label
|
||||||
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
|
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
|
||||||
@ -102,17 +108,20 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
|
|||||||
char iconText[5];
|
char iconText[5];
|
||||||
encodeUtf8(config_.iconCodepoint, iconText);
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
lv_label_set_text(iconLabel_, iconText);
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create text label
|
// Create text label
|
||||||
label_ = lv_label_create(contentContainer_);
|
label_ = lv_label_create(contentContainer_);
|
||||||
lv_label_set_text(label_, config_.text);
|
lv_label_set_text(label_, config_.text);
|
||||||
|
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
|
||||||
if (!iconFirst) {
|
if (!iconFirst) {
|
||||||
iconLabel_ = lv_label_create(contentContainer_);
|
iconLabel_ = lv_label_create(contentContainer_);
|
||||||
char iconText[5];
|
char iconText[5];
|
||||||
encodeUtf8(config_.iconCodepoint, iconText);
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
lv_label_set_text(iconLabel_, iconText);
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFlexLayout();
|
setupFlexLayout();
|
||||||
@ -121,6 +130,7 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
|
|||||||
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_);
|
||||||
|
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)",
|
ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)",
|
||||||
|
|||||||
@ -17,6 +17,7 @@ public:
|
|||||||
|
|
||||||
// Access to LVGL object
|
// Access to LVGL object
|
||||||
lv_obj_t* getLvglObject() const { return obj_; }
|
lv_obj_t* getLvglObject() const { return obj_; }
|
||||||
|
lv_obj_t* getObj() const { return obj_; } // Alias
|
||||||
|
|
||||||
// Widget ID
|
// Widget ID
|
||||||
uint8_t getId() const { return config_.id; }
|
uint8_t getId() const { return config_.id; }
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
@click.self="deselect"
|
@click.self="deselect"
|
||||||
>
|
>
|
||||||
<WidgetElement
|
<WidgetElement
|
||||||
v-for="widget in store.activeScreen?.widgets || []"
|
v-for="widget in rootWidgets"
|
||||||
:key="widget.id"
|
:key="widget.id"
|
||||||
:widget="widget"
|
:widget="widget"
|
||||||
:scale="store.canvasScale"
|
:scale="store.canvasScale"
|
||||||
@ -32,16 +32,41 @@ import { clamp, minSizeFor } from '../utils';
|
|||||||
|
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
|
|
||||||
|
const rootWidgets = computed(() => {
|
||||||
|
return store.activeScreen?.widgets.filter(w => w.parentId === -1) || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvasW = computed(() => {
|
||||||
|
if (store.activeScreen?.mode === 1) { // Modal
|
||||||
|
return store.activeScreen.modal.w || DISPLAY_W;
|
||||||
|
}
|
||||||
|
return DISPLAY_W;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvasH = computed(() => {
|
||||||
|
if (store.activeScreen?.mode === 1) { // Modal
|
||||||
|
return store.activeScreen.modal.h || DISPLAY_H;
|
||||||
|
}
|
||||||
|
return DISPLAY_H;
|
||||||
|
});
|
||||||
|
|
||||||
const canvasStyle = computed(() => ({
|
const canvasStyle = computed(() => ({
|
||||||
width: `${DISPLAY_W * store.canvasScale}px`,
|
width: `${canvasW.value * store.canvasScale}px`,
|
||||||
height: `${DISPLAY_H * store.canvasScale}px`,
|
height: `${canvasH.value * store.canvasScale}px`,
|
||||||
'--canvas-bg': store.activeScreen?.bgColor || '#1A1A2E'
|
'--canvas-bg': store.activeScreen?.bgColor || '#1A1A2E',
|
||||||
|
'--grid-size': `${store.gridSize * store.canvasScale}px`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function deselect() {
|
function deselect() {
|
||||||
store.selectedWidgetId = null;
|
store.selectedWidgetId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snap(val) {
|
||||||
|
if (!store.snapToGrid) return val;
|
||||||
|
const s = store.gridSize;
|
||||||
|
return Math.round(val / s) * s;
|
||||||
|
}
|
||||||
|
|
||||||
let dragState = null;
|
let dragState = null;
|
||||||
let resizeState = null;
|
let resizeState = null;
|
||||||
|
|
||||||
@ -83,8 +108,28 @@ function drag(e) {
|
|||||||
|
|
||||||
const w = store.activeScreen.widgets.find(x => x.id === dragState.id);
|
const w = store.activeScreen.widgets.find(x => x.id === dragState.id);
|
||||||
if (w) {
|
if (w) {
|
||||||
w.x = Math.max(0, Math.min(DISPLAY_W - w.w, Math.round(dragState.origX + dx)));
|
let nx = dragState.origX + dx;
|
||||||
w.y = Math.max(0, Math.min(DISPLAY_H - w.h, Math.round(dragState.origY + dy)));
|
let ny = dragState.origY + dy;
|
||||||
|
|
||||||
|
if (store.snapToGrid) {
|
||||||
|
nx = snap(nx);
|
||||||
|
ny = snap(ny);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine bounds
|
||||||
|
let maxW = canvasW.value;
|
||||||
|
let maxH = canvasH.value;
|
||||||
|
|
||||||
|
if (w.parentId !== -1) {
|
||||||
|
const parent = store.activeScreen.widgets.find(p => p.id === w.parentId);
|
||||||
|
if (parent) {
|
||||||
|
maxW = parent.w;
|
||||||
|
maxH = parent.h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.x = Math.max(0, Math.min(maxW - w.w, Math.round(nx)));
|
||||||
|
w.y = Math.max(0, Math.min(maxH - w.h, Math.round(ny)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,11 +180,19 @@ function resizeDrag(e) {
|
|||||||
if (!w) return;
|
if (!w) return;
|
||||||
|
|
||||||
const minSize = minSizeFor(w);
|
const minSize = minSizeFor(w);
|
||||||
const maxW = DISPLAY_W - w.x;
|
const maxW = canvasW.value - w.x;
|
||||||
const maxH = DISPLAY_H - w.y;
|
const maxH = canvasH.value - w.y;
|
||||||
|
|
||||||
let newW = Math.round(resizeState.origW + dx);
|
let rawW = resizeState.origW + dx;
|
||||||
let newH = Math.round(resizeState.origH + dy);
|
let rawH = resizeState.origH + dy;
|
||||||
|
|
||||||
|
if (store.snapToGrid) {
|
||||||
|
rawW = snap(rawW);
|
||||||
|
rawH = snap(rawH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newW = Math.round(rawW);
|
||||||
|
let newH = Math.round(rawH);
|
||||||
|
|
||||||
if (w.type === WIDGET_TYPES.LED) {
|
if (w.type === WIDGET_TYPES.LED) {
|
||||||
const maxSize = Math.min(maxW, maxH);
|
const maxSize = Math.min(maxW, maxH);
|
||||||
|
|||||||
@ -54,6 +54,19 @@
|
|||||||
<option :value="1">Modal</option>
|
<option :value="1">Modal</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="store.activeScreen.mode === 1">
|
||||||
|
<div class="control-row"><label>X</label><input type="number" v-model.number="store.activeScreen.modal.x"></div>
|
||||||
|
<div class="control-row"><label>Y</label><input type="number" v-model.number="store.activeScreen.modal.y"></div>
|
||||||
|
<div class="control-row"><label>Breite</label><input type="number" v-model.number="store.activeScreen.modal.w"></div>
|
||||||
|
<div class="control-row"><label>Hoehe</label><input type="number" v-model.number="store.activeScreen.modal.h"></div>
|
||||||
|
<div class="control-row"><label>Radius</label><input type="number" v-model.number="store.activeScreen.modal.radius"></div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" v-model="store.activeScreen.modal.dim">
|
||||||
|
<span>Hintergrund dimmen</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
<button class="btn ghost danger small" @click="store.deleteScreen">Screen loeschen</button>
|
<button class="btn ghost danger small" @click="store.deleteScreen">Screen loeschen</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -66,19 +79,13 @@
|
|||||||
<div class="tree">
|
<div class="tree">
|
||||||
<div class="tree-root">{{ store.activeScreen?.name }}</div>
|
<div class="tree-root">{{ store.activeScreen?.name }}</div>
|
||||||
<div class="tree-list">
|
<div class="tree-list">
|
||||||
<div v-if="!store.activeScreen?.widgets.length" class="tree-empty">Keine Widgets</div>
|
<div v-if="!store.widgetTree.length" class="tree-empty">Keine Widgets</div>
|
||||||
<div
|
<TreeItem
|
||||||
v-else
|
v-else
|
||||||
v-for="w in store.activeScreen.widgets"
|
v-for="node in store.widgetTree"
|
||||||
:key="w.id"
|
:key="node.id"
|
||||||
class="tree-item"
|
:node="node"
|
||||||
:class="{ active: store.selectedWidgetId === w.id, hidden: !w.visible }"
|
/>
|
||||||
@click="store.selectedWidgetId = w.id"
|
|
||||||
>
|
|
||||||
<span class="tree-tag">{{ TYPE_LABELS[typeKeyFor(w.type)] }}</span>
|
|
||||||
<span class="tree-name">{{ w.text || TYPE_LABELS[typeKeyFor(w.type)] }}</span>
|
|
||||||
<span class="tree-id">#{{ w.id }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -103,6 +110,14 @@
|
|||||||
<input type="checkbox" v-model="store.showGrid">
|
<input type="checkbox" v-model="store.showGrid">
|
||||||
<span>Grid anzeigen</span>
|
<span>Grid anzeigen</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="control-row" style="margin-top: 8px;">
|
||||||
|
<label>Grid Gr.</label>
|
||||||
|
<input type="number" min="5" max="100" v-model.number="store.gridSize">
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" v-model="store.snapToGrid">
|
||||||
|
<span>Am Grid ausrichten</span>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@ -136,6 +151,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
|
import TreeItem from './TreeItem.vue';
|
||||||
import { typeKeyFor } from '../utils';
|
import { typeKeyFor } from '../utils';
|
||||||
import { TYPE_LABELS } from '../constants';
|
import { TYPE_LABELS } from '../constants';
|
||||||
|
|
||||||
|
|||||||
95
web-interface/src/components/TreeItem.vue
Normal file
95
web-interface/src/components/TreeItem.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tree-node">
|
||||||
|
<div
|
||||||
|
class="tree-item"
|
||||||
|
:class="{ active: store.selectedWidgetId === node.id, hidden: !node.visible }"
|
||||||
|
@click.stop="store.selectedWidgetId = node.id"
|
||||||
|
:style="{ paddingLeft: `${level * 12 + 10}px` }"
|
||||||
|
>
|
||||||
|
<span class="tree-icon" v-if="node.children.length > 0">
|
||||||
|
{{ expanded ? '▼' : '▶' }}
|
||||||
|
</span>
|
||||||
|
<span class="tree-tag">{{ TYPE_LABELS[typeKeyFor(node.type)] }}</span>
|
||||||
|
<span class="tree-name">{{ node.text || TYPE_LABELS[typeKeyFor(node.type)] }}</span>
|
||||||
|
<span class="tree-id">#{{ node.id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tree-children" v-if="node.children.length > 0">
|
||||||
|
<TreeItem
|
||||||
|
v-for="child in node.children"
|
||||||
|
:key="child.id"
|
||||||
|
:node="child"
|
||||||
|
:level="level + 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useEditorStore } from '../stores/editor';
|
||||||
|
import { typeKeyFor } from '../utils';
|
||||||
|
import { TYPE_LABELS } from '../constants';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: Object,
|
||||||
|
level: { type: Number, default: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useEditorStore();
|
||||||
|
const expanded = ref(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.active {
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
background: rgba(125, 211, 176, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.hidden {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(125, 211, 176, 0.15);
|
||||||
|
color: var(--accent-2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-id {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -20,6 +20,18 @@
|
|||||||
@touchstart.stop="$emit('resize-start', $event)"
|
@touchstart.stop="$emit('resize-start', $event)"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
<!-- Recursive Children -->
|
||||||
|
<WidgetElement
|
||||||
|
v-for="child in children"
|
||||||
|
:key="child.id"
|
||||||
|
:widget="child"
|
||||||
|
:scale="scale"
|
||||||
|
:selected="store.selectedWidgetId === child.id"
|
||||||
|
@select="store.selectedWidgetId = child.id"
|
||||||
|
@drag-start="$emit('drag-start', $event)"
|
||||||
|
@resize-start="$emit('resize-start', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Icon-only Widget -->
|
<!-- Icon-only Widget -->
|
||||||
<template v-if="isIcon">
|
<template v-if="isIcon">
|
||||||
<span class="material-symbols-outlined icon-display" :style="iconOnlyStyle">
|
<span class="material-symbols-outlined icon-display" :style="iconOnlyStyle">
|
||||||
@ -53,6 +65,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useEditorStore } from '../stores/editor';
|
||||||
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
|
import { WIDGET_TYPES, fontSizes, ICON_POSITIONS } from '../constants';
|
||||||
import { clamp, hexToRgba } from '../utils';
|
import { clamp, hexToRgba } from '../utils';
|
||||||
|
|
||||||
@ -62,7 +75,14 @@ const props = defineProps({
|
|||||||
selected: { type: Boolean, default: false }
|
selected: { type: Boolean, default: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['select', 'drag-start', 'resize-start']);
|
const emit = defineEmits(['select', 'drag-start', 'resize-start']);
|
||||||
|
|
||||||
|
const store = useEditorStore();
|
||||||
|
|
||||||
|
const children = computed(() => {
|
||||||
|
if (!store.activeScreen) return [];
|
||||||
|
return store.activeScreen.widgets.filter(w => w.parentId === props.widget.id);
|
||||||
|
});
|
||||||
|
|
||||||
const 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);
|
||||||
|
|||||||
@ -15,6 +15,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
const activeScreenId = ref(0);
|
const activeScreenId = ref(0);
|
||||||
const canvasScale = ref(0.6);
|
const canvasScale = ref(0.6);
|
||||||
const showGrid = ref(true);
|
const showGrid = ref(true);
|
||||||
|
const snapToGrid = ref(true);
|
||||||
|
const gridSize = ref(20);
|
||||||
|
|
||||||
const nextScreenId = ref(0);
|
const nextScreenId = ref(0);
|
||||||
const nextWidgetId = ref(0);
|
const nextWidgetId = ref(0);
|
||||||
@ -23,6 +25,27 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
return config.screens.find(s => s.id === activeScreenId.value) || config.screens[0];
|
return config.screens.find(s => s.id === activeScreenId.value) || config.screens[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const widgetTree = computed(() => {
|
||||||
|
if (!activeScreen.value) return [];
|
||||||
|
const widgets = activeScreen.value.widgets;
|
||||||
|
|
||||||
|
// Map ID -> Widget
|
||||||
|
const map = {};
|
||||||
|
widgets.forEach(w => map[w.id] = { ...w, children: [] });
|
||||||
|
|
||||||
|
const roots = [];
|
||||||
|
widgets.forEach(w => {
|
||||||
|
const node = map[w.id];
|
||||||
|
if (w.parentId !== -1 && map[w.parentId]) {
|
||||||
|
map[w.parentId].children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
});
|
||||||
|
|
||||||
const selectedWidget = computed(() => {
|
const selectedWidget = computed(() => {
|
||||||
if (!activeScreen.value || selectedWidgetId.value === null) return null;
|
if (!activeScreen.value || selectedWidgetId.value === null) return null;
|
||||||
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
|
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
|
||||||
@ -133,6 +156,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
bgColor: '#1A1A2E',
|
bgColor: '#1A1A2E',
|
||||||
widgets: []
|
widgets: []
|
||||||
};
|
};
|
||||||
|
normalizeScreen(newScreen, null, nextWidgetId);
|
||||||
config.screens.push(newScreen);
|
config.screens.push(newScreen);
|
||||||
activeScreenId.value = id;
|
activeScreenId.value = id;
|
||||||
selectedWidgetId.value = null;
|
selectedWidgetId.value = null;
|
||||||
@ -168,11 +192,27 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
}
|
}
|
||||||
const defaults = WIDGET_DEFAULTS[typeStr];
|
const defaults = WIDGET_DEFAULTS[typeStr];
|
||||||
|
|
||||||
|
// Determine parent
|
||||||
|
let parentId = -1;
|
||||||
|
let startX = 120;
|
||||||
|
let startY = 120;
|
||||||
|
|
||||||
|
if (selectedWidgetId.value !== null) {
|
||||||
|
const parent = activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
|
||||||
|
// Only BUTTON can be a parent for now (as a container)
|
||||||
|
if (parent && parent.type === WIDGET_TYPES.BUTTON) {
|
||||||
|
parentId = parent.id;
|
||||||
|
startX = 10; // Relative to parent
|
||||||
|
startY = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const w = {
|
const w = {
|
||||||
id: nextWidgetId.value++,
|
id: nextWidgetId.value++,
|
||||||
|
parentId: parentId,
|
||||||
type: typeValue,
|
type: typeValue,
|
||||||
x: 120,
|
x: startX,
|
||||||
y: 120,
|
y: startY,
|
||||||
w: defaults.w,
|
w: defaults.w,
|
||||||
h: defaults.h,
|
h: defaults.h,
|
||||||
visible: true,
|
visible: true,
|
||||||
@ -212,7 +252,10 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
activeScreenId,
|
activeScreenId,
|
||||||
canvasScale,
|
canvasScale,
|
||||||
showGrid,
|
showGrid,
|
||||||
|
snapToGrid,
|
||||||
|
gridSize,
|
||||||
activeScreen,
|
activeScreen,
|
||||||
|
widgetTree,
|
||||||
selectedWidget,
|
selectedWidget,
|
||||||
loadKnxAddresses,
|
loadKnxAddresses,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
|||||||
@ -412,10 +412,10 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, rgba(255, 255, 255, 0.06) 1px, transparent 1px),
|
linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||||
background-size: 32px 32px;
|
background-size: var(--grid-size, 32px) var(--grid-size, 32px);
|
||||||
opacity: 0.3;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,6 +60,9 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
|||||||
if (w.iconPosition === undefined) w.iconPosition = defaults.iconPosition || 0;
|
if (w.iconPosition === undefined) w.iconPosition = defaults.iconPosition || 0;
|
||||||
if (w.iconSize === undefined) w.iconSize = defaults.iconSize || 1;
|
if (w.iconSize === undefined) w.iconSize = defaults.iconSize || 1;
|
||||||
if (w.iconGap === undefined) w.iconGap = defaults.iconGap || 8;
|
if (w.iconGap === undefined) w.iconGap = defaults.iconGap || 8;
|
||||||
|
|
||||||
|
// Hierarchy
|
||||||
|
if (w.parentId === undefined) w.parentId = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
|
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user