This commit is contained in:
Thomas Peterson 2026-01-26 09:26:11 +01:00
parent f7f3f8946c
commit ef66dfaa24
16 changed files with 606 additions and 70 deletions

View File

@ -5,6 +5,8 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
"widgets/LedWidget.cpp" "widgets/LedWidget.cpp"
"widgets/WidgetFactory.cpp" "widgets/WidgetFactory.cpp"
"widgets/IconWidget.cpp" "widgets/IconWidget.cpp"
"widgets/TabViewWidget.cpp"
"widgets/TabPageWidget.cpp"
"webserver/WebServer.cpp" "webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp" "webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp" "webserver/ConfigHandlers.cpp"

View File

@ -15,6 +15,8 @@ enum class WidgetType : uint8_t {
BUTTON = 1, BUTTON = 1,
LED = 2, LED = 2,
ICON = 3, ICON = 3,
TABVIEW = 4,
TABPAGE = 5,
}; };
enum class IconPosition : uint8_t { enum class IconPosition : uint8_t {

View File

@ -0,0 +1,43 @@
#include "TabPageWidget.hpp"
#include "esp_log.h"
static const char* TAG = "TabPageWidget";
TabPageWidget::TabPageWidget(const WidgetConfig& config)
: Widget(config)
{
}
TabPageWidget::~TabPageWidget() {
}
lv_obj_t* TabPageWidget::create(lv_obj_t* parent) {
// Parent MUST be a TabView
if (!parent || !lv_obj_check_type(parent, &lv_tabview_class)) {
ESP_LOGE(TAG, "Parent of TabPage must be a TabView!");
// Fallback: create a container so we don't crash
obj_ = lv_obj_create(parent ? parent : lv_scr_act());
return obj_;
}
obj_ = lv_tabview_add_tab(parent, config_.text);
// Pages fill the parent, so x/y/w/h are ignored usually,
// but LVGL handles layout.
ESP_LOGI(TAG, "Created TabPage '%s'", config_.text);
return obj_;
}
void TabPageWidget::applyStyle() {
if (obj_ == nullptr) return;
// Pages usually transparent, but we can style them
// Don't use applyCommonStyle fully as it sets pos/size which we don't want
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);
}
}

View File

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

View File

@ -0,0 +1,53 @@
#include "TabViewWidget.hpp"
#include "../WidgetManager.hpp"
#include "esp_log.h"
static const char* TAG = "TabViewWidget";
TabViewWidget::TabViewWidget(const WidgetConfig& config)
: Widget(config)
{
}
TabViewWidget::~TabViewWidget() {
}
lv_obj_t* TabViewWidget::create(lv_obj_t* parent) {
// Determine tab position based on config
// We can reuse iconPosition property: 0=Top, 1=Bottom, 2=Left, 3=Right
lv_dir_t tabPos = LV_DIR_TOP;
if (config_.iconPosition == 1) tabPos = LV_DIR_BOTTOM;
else if (config_.iconPosition == 2) tabPos = LV_DIR_LEFT;
else if (config_.iconPosition == 3) tabPos = LV_DIR_RIGHT;
// Use iconSize as tab height (e.g., 50px)
lv_coord_t tabHeight = config_.iconSize > 0 ? config_.iconSize * 10 : 50;
// LVGL v9 API: Only parent as argument
obj_ = lv_tabview_create(parent);
lv_tabview_set_tab_bar_position(obj_, tabPos);
lv_tabview_set_tab_bar_size(obj_, tabHeight);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_, config_.width > 0 ? config_.width : 300,
config_.height > 0 ? config_.height : 200);
ESP_LOGI(TAG, "Created TabView at %d,%d", config_.x, config_.y);
return obj_;
}
void TabViewWidget::applyStyle() {
if (obj_ == nullptr) return;
// Apply styling to the main container
applyCommonStyle();
// Style the tab buttons (Tab Bar in v9)
lv_obj_t* bar = lv_tabview_get_tab_bar(obj_);
lv_obj_set_style_bg_color(bar, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_text_color(bar, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
}

View File

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

View File

@ -3,6 +3,8 @@
#include "ButtonWidget.hpp" #include "ButtonWidget.hpp"
#include "LedWidget.hpp" #include "LedWidget.hpp"
#include "IconWidget.hpp" #include "IconWidget.hpp"
#include "TabViewWidget.hpp"
#include "TabPageWidget.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;
@ -16,6 +18,10 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
return std::make_unique<LedWidget>(config); return std::make_unique<LedWidget>(config);
case WidgetType::ICON: case WidgetType::ICON:
return std::make_unique<IconWidget>(config); return std::make_unique<IconWidget>(config);
case WidgetType::TABVIEW:
return std::make_unique<TabViewWidget>(config);
case WidgetType::TABPAGE:
return std::make_unique<TabPageWidget>(config);
default: default:
return nullptr; return nullptr;
} }

View File

@ -15,8 +15,8 @@
:scale="store.canvasScale" :scale="store.canvasScale"
:selected="store.selectedWidgetId === widget.id" :selected="store.selectedWidgetId === widget.id"
@select="store.selectedWidgetId = widget.id" @select="store.selectedWidgetId = widget.id"
@drag-start="startDrag(widget.id, $event)" @drag-start="startDrag($event)"
@resize-start="startResize(widget.id, $event)" @resize-start="startResize($event)"
/> />
</div> </div>
</div> </div>
@ -70,7 +70,8 @@ function snap(val) {
let dragState = null; let dragState = null;
let resizeState = null; let resizeState = null;
function startDrag(id, e) { function startDrag(payload) {
const { id, event: e } = payload;
if (e.target.closest('.resize-handle')) return; if (e.target.closest('.resize-handle')) return;
e.preventDefault(); e.preventDefault();
store.selectedWidgetId = id; store.selectedWidgetId = id;
@ -123,10 +124,20 @@ function drag(e) {
if (w.parentId !== -1) { if (w.parentId !== -1) {
const parent = store.activeScreen.widgets.find(p => p.id === w.parentId); const parent = store.activeScreen.widgets.find(p => p.id === w.parentId);
if (parent) { if (parent) {
if (parent.type === WIDGET_TYPES.TABPAGE) {
// Parent is a TabPage (w=0, h=0), so look at grandparent (TabView)
const grandParent = store.activeScreen.widgets.find(gp => gp.id === parent.parentId);
if (grandParent) {
maxW = grandParent.w;
maxH = grandParent.h;
// Adjust for tab bar? For simplicity use full size for now
}
} else {
maxW = parent.w; maxW = parent.w;
maxH = parent.h; maxH = parent.h;
} }
} }
}
w.x = Math.max(0, Math.min(maxW - w.w, Math.round(nx))); 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))); w.y = Math.max(0, Math.min(maxH - w.h, Math.round(ny)));
@ -141,7 +152,8 @@ function endDrag() {
document.removeEventListener('touchend', endDrag); document.removeEventListener('touchend', endDrag);
} }
function startResize(id, e) { function startResize(payload) {
const { id, event: e } = payload;
e.preventDefault(); e.preventDefault();
store.selectedWidgetId = id; store.selectedWidgetId = id;

View File

@ -22,6 +22,10 @@
<span class="element-title">Icon</span> <span class="element-title">Icon</span>
<span class="element-sub">Symbol</span> <span class="element-sub">Symbol</span>
</button> </button>
<button class="element-btn" @click="store.addWidget('tabview')">
<span class="element-title">Tabs</span>
<span class="element-sub">Container</span>
</button>
</div> </div>
</section> </section>

View File

@ -93,6 +93,27 @@
</div> </div>
</template> </template>
<template v-if="key === 'tabview'">
<h4>Tabs</h4>
<div class="prop-row"><label>Position</label>
<select v-model.number="w.iconPosition">
<option :value="0">Oben</option>
<option :value="1">Unten</option>
<option :value="2">Links</option>
<option :value="3">Rechts</option>
</select>
</div>
<div class="prop-row"><label>Tab Hoehe</label>
<input type="number" v-model.number="w.iconSize" min="1" max="20">
<span style="font-size:10px; color:var(--muted); margin-left:4px">x10px</span>
</div>
</template>
<template v-if="key === 'tabpage'">
<h4>Tab Seite</h4>
<div class="prop-row"><label>Titel</label><input type="text" v-model="w.text"></div>
</template>
<!-- Typography --> <!-- Typography -->
<template v-if="key === 'label' || key === 'button'"> <template v-if="key === 'label' || key === 'button'">
<h4>Typo</h4> <h4>Typo</h4>

View File

@ -2,19 +2,37 @@
<div class="tree-node"> <div class="tree-node">
<div <div
class="tree-item" class="tree-item"
:class="{ active: store.selectedWidgetId === node.id, hidden: !node.visible }" :class="{
active: store.selectedWidgetId === node.id,
hidden: !node.visible,
'drag-over': isDragOver
}"
@click.stop="store.selectedWidgetId = node.id" @click.stop="store.selectedWidgetId = node.id"
:style="{ paddingLeft: `${level * 12 + 10}px` }" :style="{ paddingLeft: `${level * 16 + 8}px` }"
draggable="true"
@dragstart="onDragStart($event, node)"
@dragover.prevent="onDragOver($event)"
@dragleave="isDragOver = false"
@drop.stop="onDrop($event, node)"
>
<span
class="tree-expander"
@click.stop="toggleExpand"
:style="{ visibility: node.children.length > 0 ? 'visible' : 'hidden' }"
> >
<span class="tree-icon" v-if="node.children.length > 0">
{{ expanded ? '▼' : '▶' }} {{ expanded ? '▼' : '▶' }}
</span> </span>
<span class="tree-tag">{{ TYPE_LABELS[typeKeyFor(node.type)] }}</span>
<span class="tree-icon-type material-symbols-outlined">{{ getIconForType(node.type) }}</span>
<div class="tree-content">
<span class="tree-name">{{ node.text || 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> <span class="tree-type">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
</div>
</div> </div>
<div class="tree-children" v-if="node.children.length > 0"> <div class="tree-children" v-if="node.children.length > 0 && expanded">
<div class="tree-guide" :style="{ left: `${level * 16 + 15}px` }"></div>
<TreeItem <TreeItem
v-for="child in node.children" v-for="child in node.children"
:key="child.id" :key="child.id"
@ -29,7 +47,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useEditorStore } from '../stores/editor'; import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils'; import { typeKeyFor } from '../utils';
import { TYPE_LABELS } from '../constants'; import { TYPE_LABELS, WIDGET_TYPES } from '../constants';
const props = defineProps({ const props = defineProps({
node: Object, node: Object,
@ -38,58 +56,151 @@ const props = defineProps({
const store = useEditorStore(); const store = useEditorStore();
const expanded = ref(true); const expanded = ref(true);
const isDragOver = ref(false);
function toggleExpand() {
expanded.value = !expanded.value;
}
function getIconForType(type) {
switch(type) {
case WIDGET_TYPES.LABEL: return 'text_fields';
case WIDGET_TYPES.BUTTON: return 'smart_button';
case WIDGET_TYPES.LED: return 'light_mode';
case WIDGET_TYPES.ICON: return 'image';
case WIDGET_TYPES.TABVIEW: return 'tab';
case WIDGET_TYPES.TABPAGE: return 'article';
default: return 'widgets';
}
}
function onDragStart(e, node) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', node.id.toString());
store.selectedWidgetId = node.id;
}
function onDragOver(e) {
// Only allow dropping on containers (Button, TabView, TabPage)
// Actually, dropping ON a widget means "put inside".
// Dropping BETWEEN is harder to implement in this simple tree.
// Let's allow dropping on anything that CAN accept children.
const type = props.node.type;
const canAccept = type === WIDGET_TYPES.BUTTON ||
type === WIDGET_TYPES.TABVIEW ||
type === WIDGET_TYPES.TABPAGE;
if (canAccept) {
isDragOver.value = true;
e.dataTransfer.dropEffect = 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
}
function onDrop(e, targetNode) {
isDragOver.value = false;
const draggedId = parseInt(e.dataTransfer.getData('text/plain'));
if (draggedId === targetNode.id) return; // Drop on self
// Check compatibility
const type = targetNode.type;
const canAccept = type === WIDGET_TYPES.BUTTON ||
type === WIDGET_TYPES.TABVIEW ||
type === WIDGET_TYPES.TABPAGE;
if (canAccept) {
store.reparentWidget(draggedId, targetNode.id);
}
}
</script> </script>
<style scoped> <style scoped>
.tree-node {
position: relative;
}
.tree-item { .tree-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
background: var(--panel-2); background: transparent;
border: 1px solid var(--border); border: 1px solid transparent;
border-radius: 6px; border-radius: 4px;
padding: 6px 10px; padding: 4px 6px;
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease; margin-bottom: 1px;
margin-bottom: 4px; user-select: none;
}
.tree-item:hover {
background: var(--panel-2);
} }
.tree-item.active { .tree-item.active {
border-color: var(--accent-2); background: rgba(125, 211, 176, 0.15);
background: rgba(125, 211, 176, 0.1); border-color: rgba(125, 211, 176, 0.3);
}
.tree-item.drag-over {
background: rgba(246, 193, 119, 0.2);
border: 1px dashed var(--accent);
} }
.tree-item.hidden { .tree-item.hidden {
opacity: 0.5; opacity: 0.5;
} }
.tree-tag { .tree-expander {
font-size: 10px; width: 16px;
padding: 2px 6px; height: 16px;
border-radius: 999px; display: flex;
background: rgba(125, 211, 176, 0.15); align-items: center;
color: var(--accent-2); justify-content: center;
text-transform: uppercase; font-size: 8px;
letter-spacing: 0.06em; color: var(--muted);
cursor: pointer;
}
.tree-expander:hover {
color: var(--text);
}
.tree-icon-type {
font-size: 16px;
color: var(--accent);
opacity: 0.8;
}
.tree-content {
display: flex;
flex-direction: column;
overflow: hidden;
} }
.tree-name { .tree-name {
flex: 1;
font-size: 12px; font-size: 12px;
white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.tree-id { .tree-type {
font-size: 10px; font-size: 9px;
color: var(--muted); color: var(--muted);
} }
.tree-icon { .tree-children {
font-size: 10px; position: relative;
color: var(--muted); }
width: 12px;
.tree-guide {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.05);
} }
</style> </style>

View File

@ -9,18 +9,43 @@
'widget-icon': isIcon 'widget-icon': isIcon
}" }"
:style="computedStyle" :style="computedStyle"
@mousedown="$emit('drag-start', $event)" @mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@touchstart="$emit('drag-start', $event)" @touchstart.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')" @click.stop="$emit('select')"
> >
<div
v-if="selected"
class="resize-handle"
@mousedown.stop="$emit('resize-start', $event)"
@touchstart.stop="$emit('resize-start', $event)"
></div>
<!-- Recursive Children --> <!-- Recursive Children -->
<!-- Special handling for TabView to render structure -->
<template v-if="isTabView">
<div class="tabview-container" :class="'pos-'+tabPosition" :style="tabViewStyle">
<div class="tab-buttons" :style="tabBtnsStyle">
<div
v-for="(child, idx) in children"
:key="child.id"
class="tab-btn"
:class="{ active: activePageId === child.id }"
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
>
{{ child.text }}
</div>
</div>
<div class="tab-content">
<WidgetElement
v-for="child in children"
:key="child.id"
:widget="child"
:scale="scale"
:selected="store.selectedWidgetId === child.id"
:style="{ display: activePageId === child.id ? 'block' : 'none' }"
@select="store.selectedWidgetId = child.id"
@drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)"
/>
</div>
</div>
</template>
<!-- Standard Recursive Children (for Buttons, Pages, etc) -->
<template v-else>
<WidgetElement <WidgetElement
v-for="child in children" v-for="child in children"
:key="child.id" :key="child.id"
@ -31,6 +56,7 @@
@drag-start="$emit('drag-start', $event)" @drag-start="$emit('drag-start', $event)"
@resize-start="$emit('resize-start', $event)" @resize-start="$emit('resize-start', $event)"
/> />
</template>
<!-- Icon-only Widget --> <!-- Icon-only Widget -->
<template v-if="isIcon"> <template v-if="isIcon">
@ -58,8 +84,16 @@
<!-- Standard (no icon) --> <!-- Standard (no icon) -->
<template v-else> <template v-else>
{{ widget.text }} <span v-if="!isTabView && !isTabPage">{{ widget.text }}</span>
</template> </template>
<!-- Resize Handle (at end to be on top) -->
<div
v-if="selected"
class="resize-handle"
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
></div>
</div> </div>
</template> </template>
@ -88,6 +122,37 @@ 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 isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
const isTabPage = computed(() => props.widget.type === WIDGET_TYPES.TABPAGE);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
// Find active tab index (client-side state only, maybe store in widget temporarily?)
// For designer simplicity: show all tabs content stacked, or just first one?
// Better: mimic LVGL. We need state. Let's use a local ref or just show the selected one in tree?
// To keep it simple: Show the active tab based on which child is selected or default to first.
import { ref } from 'vue';
const activeTabIndex = ref(0);
const activePageId = computed(() => {
// If a child page is selected, make it active
const selectedChild = children.value.find(c => store.selectedWidgetId === c.id);
if (selectedChild) return selectedChild.id;
// If a widget inside a page is selected
if (store.selectedWidget && store.selectedWidget.parentId !== -1) {
// Find ancestor page
let curr = store.selectedWidget;
while (curr && curr.parentId !== -1 && curr.parentId !== props.widget.id) {
curr = store.activeScreen.widgets.find(w => w.id === curr.parentId);
}
if (curr && curr.parentId === props.widget.id) return curr.id;
}
if (children.value.length > 0) return children.value[activeTabIndex.value]?.id;
return -1;
});
const hasIcon = computed(() => { const hasIcon = computed(() => {
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0; return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
@ -213,10 +278,44 @@ const computedStyle = computed(() => {
} else { } else {
style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)'; style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
} }
} else if (isTabView.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
// No flex here, we handle internal layout manually
} else if (isTabPage.value) {
style.position = 'relative'; // Relative to content area
style.width = '100%';
style.height = '100%';
style.left = '0';
style.top = '0';
} }
return style; return style;
}); });
const tabViewStyle = computed(() => {
return {
display: 'flex',
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row',
height: '100%',
width: '100%'
};
});
const tabBtnsStyle = computed(() => {
const s = props.scale;
const h = tabHeight.value * s;
const isVert = tabPosition.value === 0 || tabPosition.value === 1;
return {
[isVert ? 'height' : 'width']: `${h}px`,
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
display: 'flex',
flexDirection: isVert ? 'row' : 'column',
background: 'rgba(0,0,0,0.2)',
overflow: 'hidden'
};
});
</script> </script>
<style scoped> <style scoped>
@ -239,4 +338,43 @@ const computedStyle = computed(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* TabView Styles */
.tabview-container {
overflow: hidden;
}
.tab-content {
flex: 1;
position: relative;
overflow: hidden;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
font-size: 12px;
border-right: 1px solid rgba(255,255,255,0.1);
border-bottom: 1px solid rgba(255,255,255,0.1);
user-select: none;
}
.tab-btn.active {
background: rgba(255,255,255,0.1);
font-weight: bold;
border-bottom: 2px solid var(--accent);
}
.pos-2 .tab-btn, .pos-3 .tab-btn {
border-right: none;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.pos-2 .tab-btn.active, .pos-3 .tab-btn.active {
border-bottom: none;
border-right: 2px solid var(--accent);
}
</style> </style>

View File

@ -6,7 +6,9 @@ export const WIDGET_TYPES = {
LABEL: 0, LABEL: 0,
BUTTON: 1, BUTTON: 1,
LED: 2, LED: 2,
ICON: 3 ICON: 3,
TABVIEW: 4,
TABPAGE: 5
}; };
export const ICON_POSITIONS = { export const ICON_POSITIONS = {
@ -26,16 +28,21 @@ export const TYPE_KEYS = {
0: 'label', 0: 'label',
1: 'button', 1: 'button',
2: 'led', 2: 'led',
3: 'icon' 3: 'icon',
4: 'tabview',
5: 'tabpage'
}; };
export const TYPE_LABELS = { export const TYPE_LABELS = {
label: 'Label', label: 'Label',
button: 'Button', button: 'Button',
led: 'LED', led: 'LED',
icon: 'Icon' icon: 'Icon',
tabview: 'Tabs',
tabpage: 'Seite'
}; };
export const textSources = { export const textSources = {
0: 'Statisch', 0: 'Statisch',
1: 'KNX Temperatur', 1: 'KNX Temperatur',
@ -151,5 +158,47 @@ export const WIDGET_DEFAULTS = {
iconPosition: 0, iconPosition: 0,
iconSize: 3, iconSize: 3,
iconGap: 8 iconGap: 8
},
tabview: {
w: 400,
h: 300,
text: '',
textSrc: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: '#2a3543',
bgOpacity: 255,
radius: 8,
shadow: { enabled: false },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0, // 0=Top, 1=Bottom, 2=Left, 3=Right
iconSize: 5, // Used for Tab Height (x10) -> 50px
iconGap: 0
},
tabpage: {
w: 0, // Ignored
h: 0, // Ignored
text: 'Tab',
textSrc: 0,
fontSize: 1,
textColor: '#FFFFFF',
bgColor: '#1A1A2E',
bgOpacity: 0, // Transparent by default
radius: 0,
shadow: { enabled: false },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0
} }
}; };

View File

@ -188,8 +188,21 @@ export const useEditorStore = defineStore('editor', () => {
case 'button': typeValue = WIDGET_TYPES.BUTTON; break; case 'button': typeValue = WIDGET_TYPES.BUTTON; break;
case 'led': typeValue = WIDGET_TYPES.LED; break; case 'led': typeValue = WIDGET_TYPES.LED; break;
case 'icon': typeValue = WIDGET_TYPES.ICON; break; case 'icon': typeValue = WIDGET_TYPES.ICON; break;
case 'tabview': typeValue = WIDGET_TYPES.TABVIEW; break;
case 'tabpage': typeValue = WIDGET_TYPES.TABPAGE; break;
default: typeValue = WIDGET_TYPES.LABEL; default: typeValue = WIDGET_TYPES.LABEL;
} }
// Special case: Add Page to existing TabView
if (typeStr === 'tabview' && selectedWidgetId.value !== null) {
const sel = activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
if (sel && sel.type === WIDGET_TYPES.TABVIEW) {
// User clicked "Tabs" while a TabView is selected -> Add a Page
addWidget('tabpage');
return;
}
}
const defaults = WIDGET_DEFAULTS[typeStr]; const defaults = WIDGET_DEFAULTS[typeStr];
// Determine parent // Determine parent
@ -199,11 +212,16 @@ export const useEditorStore = defineStore('editor', () => {
if (selectedWidgetId.value !== null) { if (selectedWidgetId.value !== null) {
const parent = activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value); const parent = activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
// Only BUTTON can be a parent for now (as a container) if (parent) {
if (parent && parent.type === WIDGET_TYPES.BUTTON) { if (parent.type === WIDGET_TYPES.BUTTON || parent.type === WIDGET_TYPES.TABPAGE) {
parentId = parent.id; parentId = parent.id;
startX = 10; // Relative to parent startX = 10;
startY = 10; startY = 10;
} else if (parent.type === WIDGET_TYPES.TABVIEW && typeStr === 'tabpage') {
parentId = parent.id;
startX = 0;
startY = 0;
}
} }
} }
@ -237,14 +255,66 @@ export const useEditorStore = defineStore('editor', () => {
activeScreen.value.widgets.push(w); activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id; selectedWidgetId.value = w.id;
// Auto-create pages for new TabView
if (typeStr === 'tabview') {
// Deselect to reset parent logic for recursive calls, or explicitly pass parent
// Easier: just manually push 2 pages
const p1 = { ...WIDGET_DEFAULTS.tabpage, id: nextWidgetId.value++, parentId: w.id, text: 'Tab 1', type: WIDGET_TYPES.TABPAGE };
const p2 = { ...WIDGET_DEFAULTS.tabpage, id: nextWidgetId.value++, parentId: w.id, text: 'Tab 2', type: WIDGET_TYPES.TABPAGE };
activeScreen.value.widgets.push(p1);
activeScreen.value.widgets.push(p2);
}
} }
function deleteWidget() { function deleteWidget() {
if (!activeScreen.value || selectedWidgetId.value === null) return; if (!activeScreen.value || selectedWidgetId.value === null) return;
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => w.id !== selectedWidgetId.value);
// Also delete children recursively?
// For now, simpler: reparent children to root or delete them.
// Let's delete them to avoid orphans.
const idToDelete = selectedWidgetId.value;
const deleteIds = [idToDelete];
// Find all descendants
let found = true;
while (found) {
found = false;
activeScreen.value.widgets.forEach(w => {
if (deleteIds.includes(w.parentId) && !deleteIds.includes(w.id)) {
deleteIds.push(w.id);
found = true;
}
});
}
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => !deleteIds.includes(w.id));
selectedWidgetId.value = null; selectedWidgetId.value = null;
} }
function reparentWidget(widgetId, newParentId) {
if (!activeScreen.value) return;
// Prevent cycles: Check if newParentId is a descendant of widgetId
let curr = newParentId;
while (curr !== -1) {
if (curr === widgetId) return; // Cycle detected
const p = activeScreen.value.widgets.find(w => w.id === curr);
if (!p) break;
curr = p.parentId;
}
const widget = activeScreen.value.widgets.find(w => w.id === widgetId);
if (widget) {
// Adjust position to be relative to new parent (simplified: reset to 0,0 or keep absolute?)
// Keeping absolute is hard because we don't know absolute pos easily here.
// Resetting to 10,10 is safer.
widget.x = 10;
widget.y = 10;
widget.parentId = newParentId;
}
}
return { return {
config, config,
knxAddresses, knxAddresses,
@ -264,6 +334,7 @@ export const useEditorStore = defineStore('editor', () => {
addScreen, addScreen,
deleteScreen, deleteScreen,
addWidget, addWidget,
deleteWidget deleteWidget,
reparentWidget
}; };
}); });

View File

@ -402,7 +402,6 @@ body {
.canvas { .canvas {
position: relative; position: relative;
background: var(--canvas-bg); background: var(--canvas-bg);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden; overflow: hidden;
} }

View File

@ -14,6 +14,7 @@ export function minSizeFor(widget) {
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 }; if (key === 'icon') return { w: 24, h: 24 };
if (key === 'tabview') return { w: 100, h: 100 };
return { w: 40, h: 20 }; return { w: 40, h: 20 };
} }