This commit is contained in:
Thomas Peterson 2026-01-25 21:24:40 +01:00
parent 1ea8bb7e12
commit f7f3f8946c
12 changed files with 403 additions and 52 deletions

View File

@ -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;

View File

@ -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);

View File

@ -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_);
}
lv_obj_delete(modalContainer_);
esp_lv_adapter_unlock();
modalContainer_ = nullptr;
modalDimmer_ = nullptr; modalDimmer_ = nullptr;
}
if (lv_obj_is_valid(modalContainer_)) {
lv_obj_delete(modalContainer_);
} else {
printf("WM: Warning: modalContainer_ was invalid!\n");
fflush(stdout);
}
modalContainer_ = 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();
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_); 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_) {
printf("WM: Going back to screen %d\n", previousScreenId_);
fflush(stdout);
if (config_.findScreen(previousScreenId_)) {
activeScreenId_ = previousScreenId_; activeScreenId_ = previousScreenId_;
previousScreenId_ = SCREEN_ID_NONE; previousScreenId_ = SCREEN_ID_NONE;
applyScreen(activeScreenId_); 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) {
@ -777,6 +869,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
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);
} }
@ -924,6 +1018,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++;
} }

View File

@ -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)",

View File

@ -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; }

View File

@ -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);

View File

@ -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';

View 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>

View File

@ -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);

View File

@ -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,

View File

@ -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;
} }

View File

@ -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) {