#include "RoomCardWidget.hpp" #include "../Fonts.hpp" #include "../WidgetManager.hpp" #include #include #include #ifndef M_PI #define M_PI 3.14159265358979323846 #endif RoomCardWidget::RoomCardWidget(const WidgetConfig& config) : Widget(config) { roomName_[0] = '\0'; tempFormat_[0] = '\0'; subtitle_[0] = '\0'; humidityFormat_[0] = '\0'; for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { subButtonStates_[i] = false; } } RoomCardWidget::~RoomCardWidget() { // Sub-buttons are on screen parent, not on obj_, so delete them explicitly for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { if (subButtonObjs_[i] && lv_obj_is_valid(subButtonObjs_[i])) { lv_obj_delete(subButtonObjs_[i]); subButtonObjs_[i] = nullptr; } } } void RoomCardWidget::clearLvglObject() { // Clear sub-button pointers (they'll be deleted when screen is cleaned) for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { subButtonObjs_[i] = nullptr; subButtonIcons_[i] = nullptr; } bubble_ = nullptr; roomIcon_ = nullptr; roomLabel_ = nullptr; tempLabel_ = nullptr; subtitleLabel_ = nullptr; humidityLabel_ = nullptr; decorIcon_ = nullptr; obj_ = nullptr; } int RoomCardWidget::encodeUtf8(uint32_t codepoint, char* buf) { if (codepoint < 0x80) { buf[0] = static_cast(codepoint); buf[1] = '\0'; return 1; } else if (codepoint < 0x800) { buf[0] = static_cast(0xC0 | (codepoint >> 6)); buf[1] = static_cast(0x80 | (codepoint & 0x3F)); buf[2] = '\0'; return 2; } else if (codepoint < 0x10000) { buf[0] = static_cast(0xE0 | (codepoint >> 12)); buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); buf[2] = static_cast(0x80 | (codepoint & 0x3F)); buf[3] = '\0'; return 3; } else if (codepoint < 0x110000) { buf[0] = static_cast(0xF0 | (codepoint >> 18)); buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); buf[3] = static_cast(0x80 | (codepoint & 0x3F)); buf[4] = '\0'; return 4; } buf[0] = '\0'; return 0; } void RoomCardWidget::parseText() { roomName_[0] = '\0'; tempFormat_[0] = '\0'; subtitle_[0] = '\0'; humidityFormat_[0] = '\0'; // Format for Bubble: "RoomName\nTempFormat" // Format for Tile: "RoomName\nSubtitle" (temp/humidity via text2/text3) const char* text = config_.text; if (text && text[0] != '\0') { const char* newline = strchr(text, '\n'); if (newline) { size_t nameLen = static_cast(newline - text); if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1; memcpy(roomName_, text, nameLen); roomName_[nameLen] = '\0'; if (config_.cardStyle == 0) { // Bubble: second line is temp format strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1); tempFormat_[MAX_TEXT_LEN - 1] = '\0'; } else { // Tile: second line is subtitle strncpy(subtitle_, newline + 1, MAX_TEXT_LEN - 1); subtitle_[MAX_TEXT_LEN - 1] = '\0'; } } else { strncpy(roomName_, text, MAX_TEXT_LEN - 1); roomName_[MAX_TEXT_LEN - 1] = '\0'; } } // For Tile style, use text2 for temp format and text3 for humidity if (config_.cardStyle == 1) { if (config_.text2[0] != '\0') { strncpy(tempFormat_, config_.text2, MAX_FORMAT_LEN - 1); tempFormat_[MAX_FORMAT_LEN - 1] = '\0'; } if (config_.text3[0] != '\0') { strncpy(humidityFormat_, config_.text3, MAX_FORMAT_LEN - 1); humidityFormat_[MAX_FORMAT_LEN - 1] = '\0'; } } } void RoomCardWidget::calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y) { int16_t centerX = config_.width / 2; int16_t centerY = config_.height / 2; // Sub-button size (configurable, default 40) int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; // Sub-buttons orbit radius in pixels (default 80) int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80; // Calculate angle: 0=TOP, going clockwise, 8 positions = 45 degrees each float angle = static_cast(static_cast(pos)) * (M_PI / 4.0f) - (M_PI / 2.0f); // Calculate position (center of sub-button) x = centerX + static_cast(orbitRadius * cosf(angle)) - subBtnSize / 2; y = centerY + static_cast(orbitRadius * sinf(angle)) - subBtnSize / 2; } void RoomCardWidget::calculateSubButtonPositionTile(uint8_t index, int16_t& x, int16_t& y) { // For Tile layout: buttons are stacked vertically on the right side int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; int16_t gap = 10; // Gap between buttons int16_t padding = 12; // Padding from edge x = config_.width - subBtnSize - padding; y = padding + index * (subBtnSize + gap); } lv_obj_t* RoomCardWidget::create(lv_obj_t* parent) { parseText(); // Store parent for sub-buttons (they're created on screen level to avoid clipping) screenParent_ = parent; // Create main container obj_ = lv_obj_create(parent); lv_obj_remove_style_all(obj_); lv_obj_set_pos(obj_, config_.x, config_.y); lv_obj_set_size(obj_, config_.width, config_.height); lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE); // Add click handler for navigation lv_obj_add_event_cb(obj_, bubbleClickCallback, LV_EVENT_CLICKED, this); // Create layout based on card style if (config_.cardStyle == 1) { createTileLayout(); createSubButtonsTile(); } else { createBubbleLayout(); createSubButtons(); } return obj_; } void RoomCardWidget::createBubbleLayout() { // Calculate bubble size (80% of widget size, circular) int16_t minSide = config_.width < config_.height ? config_.width : config_.height; int16_t bubbleSize = (minSide * 80) / 100; // Create bubble container (centered) bubble_ = lv_obj_create(obj_); lv_obj_remove_style_all(bubble_); lv_obj_set_size(bubble_, bubbleSize, bubbleSize); lv_obj_center(bubble_); lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE); // Circular shape lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0); lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0); // Set up flex layout for bubble content lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_all(bubble_, 8, 0); lv_obj_set_style_pad_gap(bubble_, 2, 0); // Room icon if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { roomIcon_ = lv_label_create(bubble_); lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE); char iconText[5]; encodeUtf8(config_.iconCodepoint, iconText); lv_label_set_text(roomIcon_, iconText); } // Temperature label tempLabel_ = lv_label_create(bubble_); lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE); lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : ""); // Room name label if (roomName_[0] != '\0') { roomLabel_ = lv_label_create(bubble_); lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE); lv_label_set_text(roomLabel_, roomName_); } // Click handler for navigation lv_obj_add_event_cb(bubble_, bubbleClickCallback, LV_EVENT_CLICKED, this); } void RoomCardWidget::createTileLayout() { // Tile style: rectangular card with rounded corners lv_obj_set_style_radius(obj_, config_.borderRadius > 0 ? config_.borderRadius : 16, 0); lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0); lv_obj_set_style_bg_color(obj_, lv_color_hex(config_.bgColor.toLvColor()), 0); lv_obj_set_style_clip_corner(obj_, true, 0); int16_t padding = 16; // Room name (top-left, large) roomLabel_ = lv_label_create(obj_); lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE); lv_label_set_text(roomLabel_, roomName_); lv_obj_set_pos(roomLabel_, padding, padding); // Subtitle (below room name, smaller) if (subtitle_[0] != '\0') { subtitleLabel_ = lv_label_create(obj_); lv_obj_clear_flag(subtitleLabel_, LV_OBJ_FLAG_CLICKABLE); lv_label_set_text(subtitleLabel_, subtitle_); lv_obj_set_pos(subtitleLabel_, padding, padding + 28); } // Temperature row (with thermometer icon) if (tempFormat_[0] != '\0') { // Container for temp icon + value lv_obj_t* tempRow = lv_obj_create(obj_); lv_obj_remove_style_all(tempRow); lv_obj_set_size(tempRow, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_pos(tempRow, padding, padding + 56); lv_obj_set_flex_flow(tempRow, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(tempRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_gap(tempRow, 4, 0); lv_obj_clear_flag(tempRow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(tempRow, LV_OBJ_FLAG_CLICKABLE); // Thermometer icon (U+E1FF or similar) if (Fonts::hasIconFont()) { lv_obj_t* tempIcon = lv_label_create(tempRow); lv_obj_clear_flag(tempIcon, LV_OBJ_FLAG_CLICKABLE); char iconText[5]; encodeUtf8(0xf076, iconText); // thermometer icon lv_label_set_text(tempIcon, iconText); } tempLabel_ = lv_label_create(tempRow); lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE); lv_label_set_text(tempLabel_, "--"); } // Humidity row (with water drop icon) if (humidityFormat_[0] != '\0') { lv_obj_t* humRow = lv_obj_create(obj_); lv_obj_remove_style_all(humRow); lv_obj_set_size(humRow, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_pos(humRow, padding + 80, padding + 56); // Next to temp lv_obj_set_flex_flow(humRow, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(humRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_gap(humRow, 4, 0); lv_obj_clear_flag(humRow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(humRow, LV_OBJ_FLAG_CLICKABLE); // Water drop icon if (Fonts::hasIconFont()) { lv_obj_t* humIcon = lv_label_create(humRow); lv_obj_clear_flag(humIcon, LV_OBJ_FLAG_CLICKABLE); char iconText[5]; encodeUtf8(0xe798, iconText); // humidity/water icon lv_label_set_text(humIcon, iconText); } humidityLabel_ = lv_label_create(humRow); lv_obj_clear_flag(humidityLabel_, LV_OBJ_FLAG_CLICKABLE); lv_label_set_text(humidityLabel_, "--"); } // Large decorative icon (bottom-left, partially visible) if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { decorIcon_ = lv_label_create(obj_); lv_obj_clear_flag(decorIcon_, LV_OBJ_FLAG_CLICKABLE); char iconText[5]; encodeUtf8(config_.iconCodepoint, iconText); lv_label_set_text(decorIcon_, iconText); // Position at bottom-left, slightly outside lv_obj_set_pos(decorIcon_, -20, config_.height - 100); } } void RoomCardWidget::createSubButtons() { // Sub-button size (configurable, default 40) int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { const SubButtonConfig& cfg = config_.subButtons[i]; if (!cfg.enabled) continue; // Create sub-button on screen parent (not on widget container) to avoid clipping lv_obj_t* btn = lv_obj_create(screenParent_); lv_obj_remove_style_all(btn); lv_obj_set_size(btn, subBtnSize, subBtnSize); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); // Circular shape lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0); // Position using circle geometry (relative to widget center) int16_t relX, relY; calculateSubButtonPosition(cfg.position, relX, relY); // Convert to absolute screen position int16_t absX = config_.x + relX; int16_t absY = config_.y + relY; lv_obj_set_pos(btn, absX, absY); // Create icon if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) { lv_obj_t* icon = lv_label_create(btn); lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); char iconText[5]; encodeUtf8(cfg.iconCodepoint, iconText); lv_label_set_text(icon, iconText); lv_obj_center(icon); subButtonIcons_[i] = icon; } // Store index in user_data for click handler lv_obj_set_user_data(btn, reinterpret_cast(static_cast(i))); lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this); subButtonObjs_[i] = btn; subButtonStates_[i] = false; // Apply initial color (OFF state) updateSubButtonColor(i); } } void RoomCardWidget::createSubButtonsTile() { // For Tile layout: buttons are positioned on the right side, vertically stacked int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { const SubButtonConfig& cfg = config_.subButtons[i]; if (!cfg.enabled) continue; // Create sub-button on screen parent lv_obj_t* btn = lv_obj_create(screenParent_); lv_obj_remove_style_all(btn); lv_obj_set_size(btn, subBtnSize, subBtnSize); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); // Circular shape lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0); // Position on right side int16_t relX, relY; calculateSubButtonPositionTile(i, relX, relY); int16_t absX = config_.x + relX; int16_t absY = config_.y + relY; lv_obj_set_pos(btn, absX, absY); // Create icon if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) { lv_obj_t* icon = lv_label_create(btn); lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); char iconText[5]; encodeUtf8(cfg.iconCodepoint, iconText); lv_label_set_text(icon, iconText); lv_obj_center(icon); subButtonIcons_[i] = icon; } // Store index in user_data for click handler lv_obj_set_user_data(btn, reinterpret_cast(static_cast(i))); lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this); subButtonObjs_[i] = btn; subButtonStates_[i] = false; // Apply initial color (OFF state) updateSubButtonColor(i); } } void RoomCardWidget::applyStyle() { if (!obj_) return; lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor()); if (config_.cardStyle == 1) { // Tile style // Apply border if shadow is enabled (used as accent border) if (config_.shadow.enabled) { lv_obj_set_style_border_width(obj_, 3, 0); lv_obj_set_style_border_color(obj_, lv_color_hex(config_.shadow.color.toLvColor()), 0); lv_obj_set_style_border_opa(obj_, 255, 0); } // Room name - large font if (roomLabel_) { const lv_font_t* nameFont = Fonts::bySizeIndex(config_.fontSize); lv_obj_set_style_text_font(roomLabel_, nameFont, 0); lv_obj_set_style_text_color(roomLabel_, textColor, 0); } // Subtitle - smaller, dimmed if (subtitleLabel_) { const lv_font_t* subFont = Fonts::bySizeIndex(config_.fontSize > 1 ? config_.fontSize - 2 : 0); lv_obj_set_style_text_font(subtitleLabel_, subFont, 0); lv_obj_set_style_text_color(subtitleLabel_, textColor, 0); lv_obj_set_style_text_opa(subtitleLabel_, 150, 0); } // Temp and humidity - medium font const lv_font_t* valueFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0); const lv_font_t* iconFont = Fonts::iconFont(1); // Small icons if (tempLabel_) { lv_obj_set_style_text_font(tempLabel_, valueFont, 0); lv_obj_set_style_text_color(tempLabel_, textColor, 0); // Style the icon in the same row lv_obj_t* parent = lv_obj_get_parent(tempLabel_); if (parent && lv_obj_get_child_count(parent) > 0) { lv_obj_t* iconLabel = lv_obj_get_child(parent, 0); if (iconLabel && iconFont) { lv_obj_set_style_text_font(iconLabel, iconFont, 0); lv_obj_set_style_text_color(iconLabel, textColor, 0); } } } if (humidityLabel_) { lv_obj_set_style_text_font(humidityLabel_, valueFont, 0); lv_obj_set_style_text_color(humidityLabel_, textColor, 0); lv_obj_t* parent = lv_obj_get_parent(humidityLabel_); if (parent && lv_obj_get_child_count(parent) > 0) { lv_obj_t* iconLabel = lv_obj_get_child(parent, 0); if (iconLabel && iconFont) { lv_obj_set_style_text_font(iconLabel, iconFont, 0); lv_obj_set_style_text_color(iconLabel, textColor, 0); } } } // Large decorative icon if (decorIcon_) { const lv_font_t* bigIconFont = Fonts::iconFont(5); // Largest icon if (bigIconFont) { lv_obj_set_style_text_font(decorIcon_, bigIconFont, 0); lv_obj_set_style_text_color(decorIcon_, textColor, 0); lv_obj_set_style_text_opa(decorIcon_, 60, 0); // Very transparent } } } else { // Bubble style // Apply shadow to bubble if enabled if (bubble_ && config_.shadow.enabled) { lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0); lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0); lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0); lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0); lv_obj_set_style_shadow_opa(bubble_, 255, 0); } // Font sizes const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize); const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize); const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0); // Style room icon if (roomIcon_ && iconFont) { lv_obj_set_style_text_font(roomIcon_, iconFont, 0); lv_obj_set_style_text_color(roomIcon_, textColor, 0); } // Style temperature if (tempLabel_ && textFont) { lv_obj_set_style_text_font(tempLabel_, textFont, 0); lv_obj_set_style_text_color(tempLabel_, textColor, 0); } // Style room label if (roomLabel_ && labelFont) { lv_obj_set_style_text_font(roomLabel_, labelFont, 0); lv_obj_set_style_text_color(roomLabel_, textColor, 0); lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed } } // Style sub-buttons - adjust icon size based on button size uint8_t subBtnFontIdx = 1; // Default small if (config_.subButtonSize > 55) { subBtnFontIdx = 3; } else if (config_.subButtonSize > 40) { subBtnFontIdx = 2; } const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx); for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { if (subButtonIcons_[i] && subBtnIconFont) { lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0); lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0); } } } void RoomCardWidget::updateSubButtonColor(uint8_t index) { if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return; const SubButtonConfig& cfg = config_.subButtons[index]; const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff; lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0); } void RoomCardWidget::updateTemperature(float value) { if (!tempLabel_ || tempFormat_[0] == '\0') return; char buf[32]; snprintf(buf, sizeof(buf), tempFormat_, value); lv_label_set_text(tempLabel_, buf); } void RoomCardWidget::updateHumidity(float value) { if (!humidityLabel_ || humidityFormat_[0] == '\0') return; char buf[32]; snprintf(buf, sizeof(buf), humidityFormat_, value); lv_label_set_text(humidityLabel_, buf); } void RoomCardWidget::onKnxValue(float value) { cachedPrimaryValue_ = value; hasCachedPrimary_ = true; updateTemperature(value); } void RoomCardWidget::onKnxSwitch(bool value) { // Not used directly - sub-button status is handled via onSubButtonStatus (void)value; } void RoomCardWidget::onSubButtonStatus(uint8_t index, bool value) { if (index >= MAX_SUBBUTTONS) return; subButtonStates_[index] = value; updateSubButtonColor(index); } void RoomCardWidget::sendSubButtonToggle(uint8_t index) { if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return; const SubButtonConfig& cfg = config_.subButtons[index]; if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) { bool newState = !subButtonStates_[index]; // Send KNX toggle via WidgetManager WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState); // Optimistically update local state subButtonStates_[index] = newState; updateSubButtonColor(index); } } void RoomCardWidget::bubbleClickCallback(lv_event_t* e) { RoomCardWidget* widget = static_cast(lv_event_get_user_data(e)); if (!widget) return; // Handle navigation based on action if (widget->config_.action == ButtonAction::JUMP) { WidgetManager::instance().navigateToScreen(widget->config_.targetScreen); } else if (widget->config_.action == ButtonAction::BACK) { WidgetManager::instance().navigateBack(); } } void RoomCardWidget::subButtonClickCallback(lv_event_t* e) { RoomCardWidget* widget = static_cast(lv_event_get_user_data(e)); lv_obj_t* target = static_cast(lv_event_get_target(e)); if (!widget || !target) return; uint8_t index = static_cast(reinterpret_cast(lv_obj_get_user_data(target))); if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) { const SubButtonConfig& cfg = widget->config_.subButtons[index]; if (cfg.action == SubButtonAction::TOGGLE_KNX) { widget->sendSubButtonToggle(index); } else if (cfg.action == SubButtonAction::NAVIGATE) { WidgetManager::instance().navigateToScreen(cfg.targetScreen); } } }