This commit is contained in:
Thomas Peterson 2026-02-08 16:53:31 +01:00
parent adf0f26da0
commit 3dfd2b461d
7 changed files with 169 additions and 137 deletions

View File

@ -434,7 +434,7 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1); strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
cfg.fontSize = 1; cfg.fontSize = 1;
cfg.textAlign = static_cast<uint8_t>(TextAlign::CENTER); cfg.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
cfg.isContainer = true; cfg.isContainer = false;
cfg.textColor = {255, 255, 255}; cfg.textColor = {255, 255, 255};
cfg.bgColor = {33, 150, 243}; // Blue cfg.bgColor = {33, 150, 243}; // Blue
cfg.bgOpacity = 255; cfg.bgOpacity = 255;

View File

@ -89,58 +89,73 @@ static void latin1_to_utf8(const char* src, size_t src_len, char* dst, size_t ds
dst[di] = '\0'; dst[di] = '\0';
} }
static WidgetConfig makeButtonLabelChild(const WidgetConfig& button) { static bool color_equals(const Color& a, const Color& b) {
WidgetConfig label = WidgetConfig::createLabel(0, 0, 0, button.text); return a.r == b.r && a.g == b.g && a.b == b.b;
label.parentId = button.id;
if (button.width > 0) label.width = button.width;
if (button.height > 0) label.height = button.height;
label.fontSize = button.fontSize;
label.textAlign = button.textAlign;
label.textColor = button.textColor;
label.textSource = TextSource::STATIC;
label.bgOpacity = 0;
label.borderRadius = 0;
label.shadow.enabled = false;
// Preserve existing icon config if any
label.iconCodepoint = button.iconCodepoint;
label.iconPosition = button.iconPosition;
label.iconSize = button.iconSize;
label.iconGap = button.iconGap;
if (label.text[0] == '\0') {
strncpy(label.text, "Button", MAX_TEXT_LEN - 1);
label.text[MAX_TEXT_LEN - 1] = '\0';
}
return label;
} }
static void ensureButtonLabels(ScreenConfig& screen) { static bool is_auto_button_label(const WidgetConfig& button, const WidgetConfig& label) {
bool hasLabelChild[MAX_WIDGETS] = {}; if (label.type != WidgetType::LABEL) return false;
if (label.parentId < 0 || label.parentId != static_cast<int8_t>(button.id)) return false;
if (label.textSource != TextSource::STATIC) return false;
bool textMatches = (strncmp(label.text, button.text, MAX_TEXT_LEN) == 0);
if (!textMatches && button.text[0] == '\0') {
if (strncmp(label.text, "Button", MAX_TEXT_LEN) == 0 || label.text[0] == '\0') {
textMatches = true;
}
}
if (!textMatches) return false;
if (label.fontSize != button.fontSize) return false;
if (label.textAlign != button.textAlign) return false;
if (!color_equals(label.textColor, button.textColor)) return false;
if (label.bgOpacity != 0) return false;
if (label.borderRadius != 0) return false;
if (label.shadow.enabled) return false;
if (label.iconCodepoint != button.iconCodepoint) return false;
if (label.iconPosition != button.iconPosition) return false;
if (label.iconSize != button.iconSize) return false;
if (label.iconGap != button.iconGap) return false;
if (label.x != 0 || label.y != 0) return false;
return true;
}
static void normalizeButtons(ScreenConfig& screen) {
uint8_t removeIds[MAX_WIDGETS] = {};
uint8_t removeCount = 0;
// Remove legacy auto-generated label children for buttons
for (uint8_t i = 0; i < screen.widgetCount; i++) { for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& w = screen.widgets[i]; const WidgetConfig& w = screen.widgets[i];
if (w.type == WidgetType::LABEL && w.parentId >= 0 && w.parentId < MAX_WIDGETS) { if (w.type != WidgetType::LABEL || w.parentId < 0) continue;
hasLabelChild[w.parentId] = true;
WidgetConfig* parent = screen.findWidget(static_cast<uint8_t>(w.parentId));
if (!parent || parent->type != WidgetType::BUTTON) continue;
bool forceRemove = parent->text[0] != '\0';
if (forceRemove || is_auto_button_label(*parent, w)) {
if (removeCount < MAX_WIDGETS) {
removeIds[removeCount++] = w.id;
}
} }
} }
const uint8_t initialCount = screen.widgetCount; for (uint8_t i = 0; i < removeCount; i++) {
for (uint8_t i = 0; i < initialCount; i++) { screen.removeWidget(removeIds[i]);
}
// Update container flag based on remaining children
for (uint8_t i = 0; i < screen.widgetCount; i++) {
WidgetConfig& w = screen.widgets[i]; WidgetConfig& w = screen.widgets[i];
if (w.type != WidgetType::BUTTON) continue; if (w.type != WidgetType::BUTTON) continue;
w.isContainer = true; bool hasChild = false;
for (uint8_t j = 0; j < screen.widgetCount; j++) {
if (w.id < MAX_WIDGETS && hasLabelChild[w.id]) continue; const WidgetConfig& child = screen.widgets[j];
if (child.parentId >= 0 && child.parentId == static_cast<int8_t>(w.id)) {
WidgetConfig label = makeButtonLabelChild(w); hasChild = true;
int newId = screen.addWidget(label); break;
if (newId < 0) { }
ESP_LOGW(TAG, "No space to add label child for button %d", w.id);
w.isContainer = false;
continue;
}
if (w.id < MAX_WIDGETS) {
hasLabelChild[w.id] = true;
} }
w.isContainer = hasChild;
} }
} }
@ -188,7 +203,7 @@ void WidgetManager::createDefaultConfig() {
progBtn.bgColor = {200, 50, 50}; // Red progBtn.bgColor = {200, 50, 50}; // Red
screen.addWidget(progBtn); screen.addWidget(progBtn);
ensureButtonLabels(screen); normalizeButtons(screen);
config_->startScreenId = screen.id; config_->startScreenId = screen.id;
config_->standbyEnabled = false; config_->standbyEnabled = false;
@ -352,7 +367,7 @@ void WidgetManager::applyScreenLocked(uint8_t screenId) {
return; return;
} }
ensureButtonLabels(*screen); normalizeButtons(*screen);
if (modalContainer_) { if (modalContainer_) {
ESP_LOGI(TAG, "Closing modal first"); ESP_LOGI(TAG, "Closing modal first");
@ -2286,7 +2301,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
if (!parseWidgets(widgets, screen)) { if (!parseWidgets(widgets, screen)) {
screen.widgetCount = 0; screen.widgetCount = 0;
} }
ensureButtonLabels(screen); normalizeButtons(screen);
newConfig->screenCount++; newConfig->screenCount++;
} }
@ -2305,7 +2320,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON_Delete(root); cJSON_Delete(root);
return false; return false;
} }
ensureButtonLabels(screen); normalizeButtons(screen);
} }
cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen"); cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen");

View File

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

View File

@ -13,8 +13,56 @@
<label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="widget.text"> <label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="widget.text">
</div> </div>
<!-- Typography -->
<h4 :class="headingClass">Typo</h4>
<div :class="rowClass"><label :class="labelClass">Schriftgr.</label>
<select :class="inputClass" v-model.number="widget.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Ausrichtung</label>
<select :class="inputClass" v-model.number="widget.textAlign">
<option :value="0">Links</option>
<option :value="1">Zentriert</option>
<option :value="2">Rechts</option>
</select>
</div>
<!-- Icon -->
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
</div>
<template v-if="widget.iconCodepoint">
<div :class="rowClass">
<label :class="labelClass">Position</label>
<select :class="inputClass" v-model.number="widget.iconPosition">
<option :value="0">Links</option>
<option :value="1">Rechts</option>
<option :value="2">Oben</option>
<option :value="3">Unten</option>
</select>
</div>
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="widget.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div :class="rowClass">
<label :class="labelClass">Abstand</label>
<input :class="inputClass" type="number" v-model.number="widget.iconGap" min="0" max="50">
</div>
</template>
<!-- Style --> <!-- Style -->
<h4 :class="headingClass">Stil</h4> <h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div> <div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div> <div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div> <div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="widget.radius"></div>
@ -63,13 +111,15 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor'; import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, BUTTON_ACTIONS } from '../../../constants'; import { sourceOptions, textSources, textSourceGroups, BUTTON_ACTIONS, fontSizes } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass } from '../shared/styles'; import { rowClass, labelClass, inputClass, headingClass, colorInputClass, iconSelectClass, iconRemoveClass } from '../shared/styles';
defineProps({ defineProps({
widget: { type: Object, required: true } widget: { type: Object, required: true }
}); });
defineEmits(['open-icon-picker']);
const store = useEditorStore(); const store = useEditorStore();
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write)); const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));

View File

@ -217,7 +217,7 @@ export const WIDGET_DEFAULTS = {
textSrc: 0, textSrc: 0,
fontSize: 1, fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER, textAlign: TEXT_ALIGNS.CENTER,
isContainer: true, isContainer: false,
textColor: '#FFFFFF', textColor: '#FFFFFF',
bgColor: '#2E7DD1', bgColor: '#2E7DD1',
bgOpacity: 255, bgOpacity: 255,

View File

@ -124,60 +124,30 @@ export const useEditorStore = defineStore('editor', () => {
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value); return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
}); });
function ensureButtonLabels(screen) { function cleanupButtonLabels(screen) {
if (!screen || !Array.isArray(screen.widgets)) return; if (!screen || !Array.isArray(screen.widgets)) return;
const hasLabelChild = new Set(); const buttonIds = new Set(
screen.widgets.filter(w => w.type === WIDGET_TYPES.BUTTON).map(w => w.id)
);
if (!buttonIds.size) return;
const removeIds = new Set();
screen.widgets.forEach((w) => { screen.widgets.forEach((w) => {
if (w.type === WIDGET_TYPES.LABEL && w.parentId !== -1) { if (w.type === WIDGET_TYPES.LABEL && buttonIds.has(w.parentId)) {
hasLabelChild.add(w.parentId); removeIds.add(w.id);
} }
}); });
if (removeIds.size) {
screen.widgets = screen.widgets.filter(w => !removeIds.has(w.id));
}
// Update container flag for buttons based on remaining children
screen.widgets.forEach((w) => { screen.widgets.forEach((w) => {
if (w.type !== WIDGET_TYPES.BUTTON) return; if (w.type !== WIDGET_TYPES.BUTTON) return;
w.isContainer = true; const hasChild = screen.widgets.some(child => child.parentId === w.id);
w.isContainer = hasChild;
if (hasLabelChild.has(w.id)) return;
const defaults = WIDGET_DEFAULTS.label;
const labelText = w.text || WIDGET_DEFAULTS.button.text;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: defaults.textSrc,
text: labelText,
knxAddr: defaults.knxAddr,
fontSize: w.fontSize ?? defaults.fontSize,
textAlign: w.textAlign ?? TEXT_ALIGNS.CENTER,
textColor: w.textColor ?? defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: 0,
radius: 0,
borderWidth: 0,
borderColor: defaults.borderColor || '#ffffff',
borderOpacity: 0,
shadow: { ...defaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition ?? defaults.iconPosition,
iconSize: w.iconSize ?? defaults.iconSize,
iconGap: w.iconGap ?? defaults.iconGap,
iconPositionX: w.iconPositionX ?? defaults.iconPositionX,
iconPositionY: w.iconPositionY ?? defaults.iconPositionY,
};
screen.widgets.push(label);
hasLabelChild.add(w.id);
}); });
} }
@ -252,7 +222,7 @@ export const useEditorStore = defineStore('editor', () => {
config.screens.forEach((screen) => { config.screens.forEach((screen) => {
normalizeScreen(screen, nextScreenId, nextWidgetId); normalizeScreen(screen, nextScreenId, nextWidgetId);
ensureButtonLabels(screen); cleanupButtonLabels(screen);
// Also update max widget id // Also update max widget id
screen.widgets.forEach(w => { screen.widgets.forEach(w => {
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1); nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
@ -459,44 +429,6 @@ export const useEditorStore = defineStore('editor', () => {
activeScreen.value.widgets.push(w); activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id; selectedWidgetId.value = w.id;
if (typeStr === 'button') {
const labelDefaults = WIDGET_DEFAULTS.label;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: labelDefaults.textSrc,
text: w.text,
knxAddr: labelDefaults.knxAddr,
fontSize: w.fontSize,
textAlign: TEXT_ALIGNS.CENTER,
textColor: w.textColor,
bgColor: labelDefaults.bgColor,
bgOpacity: 0,
radius: 0,
borderWidth: 0,
borderColor: labelDefaults.borderColor || '#ffffff',
borderOpacity: 0,
shadow: { ...labelDefaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition || 0,
iconSize: w.iconSize || 1,
iconGap: w.iconGap || 8,
iconPositionX: w.iconPositionX || 8,
iconPositionY: w.iconPositionY || 8
};
activeScreen.value.widgets.push(label);
}
// Auto-create pages for new TabView // Auto-create pages for new TabView
if (typeStr === 'tabview') { if (typeStr === 'tabview') {
// Deselect to reset parent logic for recursive calls, or explicitly pass parent // Deselect to reset parent logic for recursive calls, or explicitly pass parent
@ -611,7 +543,22 @@ export const useEditorStore = defineStore('editor', () => {
} }
}); });
const parentIds = new Set();
activeScreen.value.widgets.forEach((w) => {
if (deleteIds.includes(w.id) && w.parentId !== -1) {
parentIds.add(w.parentId);
}
});
activeScreen.value.widgets = activeScreen.value.widgets.filter(w => !deleteIds.includes(w.id)); activeScreen.value.widgets = activeScreen.value.widgets.filter(w => !deleteIds.includes(w.id));
parentIds.forEach((pid) => {
const parent = activeScreen.value.widgets.find(w => w.id === pid);
if (parent && parent.type === WIDGET_TYPES.BUTTON) {
const hasChild = activeScreen.value.widgets.some(w => w.parentId === pid);
parent.isContainer = hasChild;
}
});
selectedWidgetId.value = null; selectedWidgetId.value = null;
} }
@ -629,12 +576,26 @@ export const useEditorStore = defineStore('editor', () => {
const widget = activeScreen.value.widgets.find(w => w.id === widgetId); const widget = activeScreen.value.widgets.find(w => w.id === widgetId);
if (widget) { if (widget) {
const oldParentId = widget.parentId;
// Adjust position to be relative to new parent (simplified: reset to 0,0 or keep absolute?) // 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. // Keeping absolute is hard because we don't know absolute pos easily here.
// Resetting to 10,10 is safer. // Resetting to 10,10 is safer.
widget.x = 10; widget.x = 10;
widget.y = 10; widget.y = 10;
widget.parentId = newParentId; widget.parentId = newParentId;
if (oldParentId !== -1) {
const oldParent = activeScreen.value.widgets.find(w => w.id === oldParentId);
if (oldParent && oldParent.type === WIDGET_TYPES.BUTTON) {
const hasChild = activeScreen.value.widgets.some(w => w.parentId === oldParentId);
oldParent.isContainer = hasChild;
}
}
const newParent = activeScreen.value.widgets.find(w => w.id === newParentId);
if (newParent && newParent.type === WIDGET_TYPES.BUTTON) {
newParent.isContainer = true;
}
} }
} }

View File

@ -159,8 +159,8 @@ export function normalizeWidget(w, nextWidgetIdRef) {
// Hierarchy // Hierarchy
if (w.parentId === undefined) w.parentId = -1; if (w.parentId === undefined) w.parentId = -1;
if (key === 'button') { if (key === 'button' && (w.isContainer === undefined || w.isContainer === null)) {
w.isContainer = true; w.isContainer = defaults.isContainer ?? false;
} }
} }