Files
hassos_config/www/community/Ultra-Vehicle-Card/ultra-vehicle-card-editor.js
2024-10-13 22:59:09 +02:00

3359 lines
108 KiB
JavaScript

import {
LitElement,
html,
css,
} from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
import { version } from "./version.js?v=30";
import './state-dropdown.js';
const stl = await import("./styles.js?v=" + version);
const loc = await import("./localize.js?v=" + version);
const styles = stl.styles;
const localize = loc.localize;
const DEFAULT_IMAGE_URL =
"https://github.com/user-attachments/assets/4ef72288-5ee9-4fa6-b2f3-c34c4160cf42";
const DEFAULT_IMAGE_TEXT = "Default Image";
const fireEvent = (node, type, detail, options) => {
options = options || {};
detail = detail === null || detail === undefined ? {} : detail;
const event = new Event(type, {
bubbles: options.bubbles === undefined ? true : options.bubbles,
cancelable: Boolean(options.cancelable),
composed: options.composed === undefined ? true : options.composed,
});
event.detail = detail;
node.dispatchEvent(event);
return event;
};
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
export class UltraVehicleCardEditor extends localize(LitElement) {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
_batteryLevelEntityFilter: { type: String },
_batteryRangeEntityFilter: { type: String },
_fuelLevelEntityFilter: { type: String },
_fuelRangeEntityFilter: { type: String },
_chargingStatusEntityFilter: { type: String },
_locationEntityFilter: { type: String },
_mileageEntityFilter: { type: String },
_iconGridFilter: { type: String },
_selectedIconGridEntities: { type: Array },
_customIcons: { type: Object },
_iconSearchFilter: { type: String },
_currentEditingEntity: { type: String },
_currentEditingIconType: { type: String },
_carStateEntityFilter: { type: String },
_chargeLimitEntityFilter: { type: String },
_showEntityInformation: { type: Boolean },
_iconSize: { type: Number },
_iconGap: { type: Number },
_iconSizes: { type: Object },
_showRowSeparatorDetails: { type: Boolean },
_mainImageHeight: { type: String },
_chargingImageHeight: { type: String },
_image_type: { type: String },
_image_entity: { type: String },
_layoutType: { type: String },
_showEngineAnimation: { type: Boolean },
_showChargingAnimation: { type: Boolean },
_expandedEntities: { type: Object },
};
}
static get styles() {
return [
styles,
css`
.entity-header {
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
cursor: pointer;
}
.entity-header .handle {
cursor: move;
padding-right: 8px;
}
.entity-header .remove-entity {
margin-left: auto;
}
.entity-name {
flex-grow: 1;
margin: 0 8px;
}
.bar-gradient-section {
margin-top: 16px;
}
.switch-wrapper {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.switch-wrapper span {
margin-left: 8px;
}
.bar-gradient-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.gradient-stop {
display: flex;
align-items: center;
gap: 8px;
}
mwc-button {
margin-top: 8px;
}
.gradient-stop {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(16px);
}
.slider.round {
border-radius: 24px;
}
.slider.round:before {
border-radius: 50%;
}
.description {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 4px;
margin-bottom: 8px;
}
.delete-icon {
cursor: pointer;
color: #ffffff;
margin-left: 8px;
}
.reset-all-colors {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 16px;
}
.reset-all-colors span {
margin-right: 8px;
}
.reset-all-colors ha-icon {
color: var(--primary-text-color);
}
mwc-tab-bar {
border-bottom: 1px solid var(--divider-color);
margin-bottom: 16px;
}
.reset-all-colors {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 8px;
}
.reset-all-colors span {
margin-right: 8px;
font-size: 14px;
}
.reset-icon.clickable {
cursor: pointer;
color: var(--primary-text-color);
}
.gradient-preview-container {
margin-bottom: 16px;
}
.gradient-preview {
height: 30px;
border-radius: 5px;
position: relative;
}
.percentage-marker {
position: absolute;
top: 9%;
transform: translateX(-50%);
}
.marker-line {
width: 2px;
height: 25px;
background-color: var(--uvc-card-background);
margin: 0 auto;
}
.marker-label {
font-size: 10px;
color: var(--primary-text-color);
text-align: center;
margin-top: 2px;
}
.editor-row.template-selected {
display: block;
}
.editor-row.template-selected .editor-item {
flex: 1 1 100%;
width: 100%;
}
state-dropdown {
display: block !important;
width: 100%;
margin-top: 8px;
}
ha-select {
width: 100%;
}
.template-input {
margin-top: 8px;
width: 100%;
}
.tab-bar {
display: flex;
margin-bottom: 16px;
justify-content: space-around;
flex-direction: row;
}
.tab {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab.active {
border-bottom-color: var(--primary-color);
}
.tab ha-icon {
margin-right: 8px;
}
`
];
}
constructor() {
super();
this._initializeProperties();
this._debouncedColorChanged = debounce(
this._applyColorChange.bind(this),
100
);
this._dialogCloseHandler = this._dialogCloseHandler.bind(this);
this._preventDialogClose = this._preventDialogClose.bind(this);
this._defaultColors = {
};
this._userChangedColors = {};
this._themeChangeListener = this._onThemeChange.bind(this);
this._activeTab = "settings";
this._expandedEntities = {};
}
firstUpdated() {
super.firstUpdated();
this._setupDialogCloseHandlers();
}
_setupDialogCloseHandlers() {
const dialog = this.closest('ha-dialog');
if (dialog) {
dialog.addEventListener('close', this._dialogCloseHandler, true);
dialog.addEventListener('iron-overlay-closed', this._dialogCloseHandler, true);
window.addEventListener('dialog-closed', this._preventDialogClose, true);
}
}
_getCustomLabel(entityId, state) {
return this.config.custom_labels?.[entityId]?.[state] || '';
}
_customLabelChanged(e, entityId, state) {
const value = e.target.value;
if (!this.config.custom_labels) {
this.config.custom_labels = {};
}
if (!this.config.custom_labels[entityId]) {
this.config.custom_labels[entityId] = {};
}
this.config.custom_labels[entityId][state] = value;
this._updateConfigAndRequestUpdate('custom_labels', this.config.custom_labels);
}
_removeDialogCloseHandlers() {
const dialog = this.closest('ha-dialog');
if (dialog) {
dialog.removeEventListener('close', this._dialogCloseHandler, true);
dialog.removeEventListener('iron-overlay-closed', this._dialogCloseHandler, true);
}
window.removeEventListener('dialog-closed', this._preventDialogClose, true);
}
_dialogCloseHandler(e) {
e.preventDefault();
e.stopPropagation();
return false;
}
_preventDialogClose(e) {
e.preventDefault();
e.stopPropagation();
if (e.detail && e.detail.dialog) {
e.detail.dialog.open = true;
}
return false;
}
_initializeProperties() {
this._batteryLevelEntityFilter = "";
this._batteryRangeEntityFilter = "";
this._fuelLevelEntityFilter = "";
this._fuelRangeEntityFilter = "";
this._chargingStatusEntityFilter = "";
this._locationEntityFilter = "";
this._mileageEntityFilter = "";
this._iconGridFilter = "";
this._selectedIconGridEntities = [];
this._customIcons = {};
this._iconInteractions = {};
this._iconStyles = {};
this._iconSearchFilter = "";
this._currentEditingEntity = null;
this._currentEditingIconType = null;
this._carStateEntityFilter = "";
this._chargeLimitEntityFilter = "";
this._showEntityInformation = true;
this._iconSize = 24;
this._iconGap = 12;
this._image_urlFilter = "";
this._charging_image_urlFilter = "";
this._iconSizes = {};
this._showRowSeparatorDetails = false;
this._mainImageHeight = "140px";
this._chargingImageHeight = "140px";
this._image_type = "image";
this._image_entity = "";
this._layoutType = "single";
this._showEngineAnimation = false;
this._showChargingAnimation = false;
}
setConfig(config) {
this.config = {
title: "My Vehicle",
image_url: "",
charging_image_url: "",
image_url_type: "image",
charging_image_url_type: "none",
vehicle_type: "EV",
unit_type: "mi",
level_entity: "",
range_entity: "",
charging_status_entity: "",
location_entity: "",
mileage_entity: "",
show_level: true,
show_range: true,
show_location: true,
show_mileage: true,
show_car_state: true,
show_charge_limit: true,
icon_grid_entities: [],
custom_icons: config.custom_icons || {},
icon_interactions: {},
icon_styles: {},
icon_labels: config.icon_labels || {},
hybrid_display_order: "fuel_first",
car_state_entity: "",
charge_limit_entity: "",
icon_size: 24,
icon_gap: 12,
showEntityInformation:
config.showEntityInformation !== undefined
? config.showEntityInformation
: true,
carStateTextColor: config.carStateTextColor || "",
rangeTextColor: config.rangeTextColor || "",
percentageTextColor: config.percentageTextColor || "",
icon_sizes: config.icon_sizes || {},
engine_on_entity: "",
row_separator_color:
config.row_separator_color || this._getDefaultColorAsHex(),
row_separator_height: config.row_separator_height || 1,
row_separators: config.row_separators || {},
iconActiveColor: config.iconActiveColor || "var(--primary-color)",
iconInactiveColor:
config.iconInactiveColor || "var(--primary-text-color)",
useFormattedEntities: config.useFormattedEntities !== undefined
? config.useFormattedEntities
: true, // Default to true
mainImageHeight: config.image_url_type !== "none" ? (config.mainImageHeight || '140px') : '0px',
chargingImageHeight: config.charging_image_url_type !== "none" ? (config.chargingImageHeight || '140px') : '0px',
showTitle: config.showTitle !== false,
layoutType: config.layoutType || "single",
useBarGradient: config.useBarGradient || false,
barGradientStops: config.barGradientStops || [
{ percentage: 0, color: '#ff0000' },
{ percentage: 100, color: '#00ff00' }
],
carStateTextColor: config.carStateTextColor || "",
rangeTextColor: config.rangeTextColor || "",
percentageTextColor: config.percentageTextColor || "",
cardTitleColor: config.cardTitleColor || "",
cardBackgroundColor: config.cardBackgroundColor || "",
barBackgroundColor: config.barBackgroundColor || "",
barBorderColor: config.barBorderColor || "",
barFillColor: config.barFillColor || "",
limitIndicatorColor: config.limitIndicatorColor || "",
infoTextColor: config.infoTextColor || "",
show_engine_animation: config.show_engine_animation !== false,
show_charging_animation: config.show_charging_animation !== false,
show_charging_status: config.show_charging_status !== false, // Default to true
show_engine_on: config.show_engine_on !== false, // Default to true
engine_on_image_url_type: config.engine_on_image_url_type || "none",
...config,
};
this._handleBackwardCompatibility();
this._initializeIconGridEntities();
this.loadResources(this.hass.language);
}
_handleBackwardCompatibility() {
if (this.config.level_entity && !this.config.battery_level_entity) {
this.config.battery_level_entity = this.config.level_entity;
}
if (this.config.range_entity && !this.config.battery_range_entity) {
this.config.battery_range_entity = this.config.range_entity;
}
}
_initializeIconGridEntities() {
this._selectedIconGridEntities = [...this.config.icon_grid_entities];
this._customIcons = { ...this.config.custom_icons };
this._iconInteractions = { ...this.config.icon_interactions };
this._iconStyles = { ...this.config.icon_styles };
this._iconSize = this.config.icon_size || 24;
this._showEntityInformation = this.config.showEntityInformation;
this._iconGap = this.config.icon_gap || 12;
this._image_urlFilter = "";
this._charging_image_urlFilter = "";
this._iconSizes = { ...this.config.icon_sizes };
this._layoutType = this.config.layoutType;
this._showEngineAnimation = this.config.show_engine_animation !== false;
this._showChargingAnimation = this.config.show_charging_animation !== false;
}
static getStubConfig() {
return {
title: "My Vehicle",
image_url: DEFAULT_IMAGE_URL,
charging_image_url: "",
image_url_type: "default",
charging_image_url_type: "none",
vehicle_type: "EV",
unit_type: "mi",
battery_level_entity: "",
battery_range_entity: "",
fuel_level_entity: "",
fuel_range_entity: "",
charging_status_entity: "",
location_entity: "",
mileage_entity: "",
show_battery: true,
show_battery_range: true,
show_fuel: true,
show_fuel_range: true,
show_location: true,
show_mileage: true,
icon_grid_entities: [],
custom_icons: {},
hybrid_display_order: "fuel_first",
car_state_entity: "",
charge_limit_entity: "",
show_car_state: true,
show_charge_limit: true,
cardBackgroundColor: "",
barBackgroundColor: "",
barFillColor: "",
limitIndicatorColor: "",
iconActiveColor: UltraVehicleCardEditor._getComputedColor("--primary-color"),
iconInactiveColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
carStateTextColor: "",
rangeTextColor: "",
cardTitleColor: "",
percentageTextColor: "",
icon_sizes: {},
icon_labels: {},
useFormattedEntities: false,
mainImageHeight: '140px',
chargingImageHeight: '140px',
showTitle: true,
layoutType: "single",
useBarGradient: false,
barGradientStops: [
{ percentage: 0, color: '#ff0000' },
{ percentage: 100, color: '#00ff00' }
],
show_engine_animation: false,
show_charging_animation: false,
};
}
render() {
if (!this.hass) {
return html``;
}
return html`
<div class="editor-container">
<div class="tab-bar">
<div class="tab ${this._activeTab === "settings" ? "active" : ""}" @click=${() => this._handleTabChange(0)}>
<ha-icon icon="mdi:cog"></ha-icon>
<span>${this.localize("editor.settings")}</span>
</div>
<div class="tab ${this._activeTab === "icon-grid" ? "active" : ""}" @click=${() => this._handleTabChange(1)}>
<ha-icon icon="mdi:apps"></ha-icon>
<span>${this.localize("editor.icon_grid")}</span>
</div>
<div class="tab ${this._activeTab === "customize" ? "active" : ""}" @click=${() => this._handleTabChange(2)}>
<ha-icon icon="mdi:palette"></ha-icon>
<span>${this.localize("editor.customize")}</span>
</div>
</div>
<div class="tab-content">
${this._activeTab === "settings" ? html`
${this._renderBasicConfig()}
${this._renderLayoutChooser()}
${this._renderFormattedEntitiesToggle()}
${this._renderEntityInformation()}
` : ""}
${this._activeTab === "icon-grid" ? html`
${this._renderIconGridConfig()}
` : ""}
${this._activeTab === "customize" ? html`
${this._renderColorPickers()}
${this._renderBarGradientToggle()}
` : ""}
</div>
</div>
`;
}
_handleTabChange(index) {
const tabIds = ["settings", "icon-grid", "customize"];
this._activeTab = tabIds[index];
this._refreshConfig();
this.requestUpdate();
}
_renderLayoutChooser() {
return html`
<div class="input-group">
<label for="layoutType">${this.localize("editor.layout_type")}</label>
<ha-select
id="layoutType"
.value=${this._layoutType}
@selected=${this._layoutChanged}
@closed=${(e) => e.stopPropagation()}
>
<mwc-list-item value="single">${this.localize("editor.single_column")}</mwc-list-item>
<mwc-list-item value="double">${this.localize("editor.double_column")}</mwc-list-item>
</ha-select>
</div>
`;
}
_layoutChanged(e) {
const newLayoutType = e.target.value;
this._layoutType = newLayoutType;
this._updateConfig("layoutType", this._layoutType);
// Set default image heights based on layout type
const defaultHeight = newLayoutType === 'double' ? '62px' : '140px';
// Update mainImageHeight
if (newLayoutType === 'double' && this.config.mainImageHeight === '140px') {
this._updateConfig("mainImageHeight", '62px');
} else if (newLayoutType === 'single' && this.config.mainImageHeight === '62px') {
this._updateConfig("mainImageHeight", '140px');
}
// Update chargingImageHeight
if (newLayoutType === 'double' && this.config.chargingImageHeight === '140px') {
this._updateConfig("chargingImageHeight", '62px');
} else if (newLayoutType === 'single' && this.config.chargingImageHeight === '62px') {
this._updateConfig("chargingImageHeight", '140px');
}
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
}
_renderBasicConfig() {
const defaultHeight = this._layoutType === 'double' ? 62 : 140;
return html`
<div class="input-group">
<label for="title">${this.localize("editor.card_title")}</label>
<div class="title-toggle-container">
<input
id="title"
type="text"
.value="${this.config.title || ''}"
@input="${this._titleChanged}"
.configValue="${"title"}"
/>
<label class="switch">
<input
type="checkbox"
.checked="${this.config.showTitle !== false}"
@change="${this._showTitleToggleChanged}"
.configValue="${"showTitle"}"
/>
<span class="slider round"></span>
</label>
</div>
</div>
<div class="input-group">
<label>${this.localize("editor.vehicle_type")}</label>
<div class="radio-group">
<label>
<input
type="radio"
name="vehicle_type"
value="EV"
?checked="${this.config.vehicle_type === "EV"}"
@change="${this._vehicleTypeChanged}"
/>
${this.localize("vehicle_types.ev")}
</label>
<label>
<input
type="radio"
name="vehicle_type"
value="Fuel"
?checked="${this.config.vehicle_type === "Fuel"}"
@change="${this._vehicleTypeChanged}"
/>
${this.localize("vehicle_types.fuel")}
</label>
<label>
<input
type="radio"
name="vehicle_type"
value="Hybrid"
?checked="${this.config.vehicle_type === "Hybrid"}"
@change="${this._vehicleTypeChanged}"
/>
${this.localize("vehicle_types.hybrid")}
</label>
</div>
</div>
${this.config.vehicle_type === "Hybrid"
? html`
<div class="input-group">
<label>${this.localize("editor.hybrid_display_order")}</label>
<div class="radio-group">
<label>
<input
type="radio"
name="hybrid_display_order"
value="fuel_first"
?checked="${this.config.hybrid_display_order ===
"fuel_first"}"
@change="${this._hybridOrderChanged}"
/>
${this.localize("editor.fuel_first")}
</label>
<label>
<input
type="radio"
name="hybrid_display_order"
value="battery_first"
?checked="${this.config.hybrid_display_order ===
"battery_first"}"
@change="${this._hybridOrderChanged}"
/>
${this.localize("editor.battery_first")}
</label>
</div>
</div>
`
: ""}
<div class="divider"></div>
<div class="image-section">
<div class="image-section-title">${this.localize("editor.main_image_section")}</div>
${this._renderImageUploadField(
this.localize("editor.main_image"),
"image_url",
this.localize("editor.enter_image_url")
)}
<div class="editor-item" id="main-image-height">
<label>${this.localize("editor.main_image_height")}</label>
<div class="input-with-unit">
<input
type="number"
min="50"
max="500"
.value="${parseInt(this.config.mainImageHeight) || defaultHeight}"
@input="${this._valueChanged}"
.configValue="${"mainImageHeight"}"
/>
<span class="unit">px</span>
</div>
</div>
</div>
${this.config.vehicle_type === "Fuel" || this.config.vehicle_type === "Hybrid" ? html`
<div class="image-section">
<div class="image-section-title">${this.localize("editor.engine_on_image_section")}</div>
${this._renderImageUploadField(
this.localize("editor.engine_on_image"),
"engine_on_image_url",
this.localize("editor.enter_image_url")
)}
<div class="editor-item">
<ha-entity-picker
.hass=${this.hass}
.configValue=${"engine_on_entity"}
.value=${this.config.engine_on_entity}
.label=${this.localize("editor.engine_on_entity")}
@value-changed=${this._valueChanged}
></ha-entity-picker>
</div>
<div class="editor-item" id="engine-on-image-height">
<label>${this.localize("editor.engine_on_image_height")}</label>
<div class="input-with-unit">
<input
type="number"
min="50"
max="500"
.value="${parseInt(this.config.engineOnImageHeight) || defaultHeight}"
@input="${this._valueChanged}"
.configValue="${"engineOnImageHeight"}"
/>
<span class="unit">px</span>
</div>
</div>
</div>
` : ''}
${this.config.vehicle_type === "EV" || this.config.vehicle_type === "Hybrid" ? html`
<div class="image-section">
<div class="image-section-title">${this.localize("editor.charging_image_section")}</div>
${this._renderImageUploadField(
this.localize("editor.charging_image"),
"charging_image_url",
this.localize("editor.enter_image_url")
)}
<div class="editor-item" id="charging-image-height">
<label>${this.localize("editor.charging_image_height")}</label>
<div class="input-with-unit">
<input
type="number"
min="50"
max="500"
.value="${parseInt(this.config.chargingImageHeight) || defaultHeight}"
@input="${this._valueChanged}"
.configValue="${"chargingImageHeight"}"
/>
<span class="unit">px</span>
</div>
</div>
</div>
` : ''}
`;
}
_handleImageUrlInput(e, configKey) {
const newValue = e.target.value;
this._updateConfig(configKey, newValue);
this._fireEvent('config-changed', { config: this.config });
}
_renderFormattedEntitiesToggle() {
return html`
<div class="input-group">
<label for="useFormattedEntities">${this.localize("editor.formatted_entities")}</label>
<div class="entity-description">
${this.localize("editor.formatted_entities_description")}
</div>
<label class="switch">
<input
type="checkbox"
id="useFormattedEntities"
.checked=${this.config.useFormattedEntities || true}
@change=${this._toggleFormattedEntities}
/>
<span class="slider round"></span>
</label>
</div>
`;
}
_renderEntityInformation() {
return html`
<div class="entity-information">
<div
class="entity-information-header"
@click=${this._toggleEntityInformation}
>
<h3>${this.localize("editor.entity_settings")}</h3>
<ha-icon
icon=${this._showEntityInformation
? "mdi:chevron-up"
: "mdi:chevron-down"}
></ha-icon>
</div>
${this._showEntityInformation
? html` ${this._renderEntityPickers()} `
: ""}
</div>
`;
}
_toggleEntityInformation() {
this._showEntityInformation = !this._showEntityInformation;
this.config = {
...this.config,
showEntityInformation: this._showEntityInformation,
};
this.configChanged(this.config);
this.requestUpdate();
}
_toggleFormattedEntities(e) {
const useFormattedEntities = e.target.checked;
this._updateConfig("useFormattedEntities", useFormattedEntities);
// Force a re-render of the card
this._fireEvent("config-changed", { config: this.config });
}
_renderEntityPickers() {
const { vehicle_type } = this.config;
return html`
${vehicle_type === "EV" || vehicle_type === "Hybrid"
? html`
${this._renderEntityPicker(
"battery_level_entity",
this.localize("editor.battery_level"),
this.localize("editor.battery_level_description")
)}
${this._renderEntityPicker(
"battery_range_entity",
this.localize("editor.battery_range"),
this.localize("editor.battery_range_description")
)}
${this._renderEntityPicker(
"charging_status_entity",
this.localize("editor.charging_status"),
this.localize("editor.charging_status_description")
)}
${this._renderEntityPicker(
"charge_limit_entity",
this.localize("editor.charge_limit"),
this.localize("editor.charge_limit_description")
)}
`
: ""}
${vehicle_type === "Fuel" || vehicle_type === "Hybrid"
? html`
${this._renderEntityPicker(
"fuel_level_entity",
this.localize("editor.fuel_level"),
this.localize("editor.fuel_level_description")
)}
${this._renderEntityPicker(
"fuel_range_entity",
this.localize("editor.fuel_range"),
this.localize("editor.fuel_range_description")
)}
${this._renderEntityPicker(
"engine_on_entity",
this.localize("editor.engine_on"),
this.localize("editor.engine_on_description")
)}
`
: ""}
${this._renderEntityPicker(
"location_entity",
this.localize("editor.location"),
this.localize("editor.location_description")
)}
${this._renderEntityPicker(
"mileage_entity",
this.localize("editor.mileage"),
this.localize("editor.mileage_description")
)}
${this._renderEntityPicker(
"car_state_entity",
this.localize("editor.car_state"),
this.localize("editor.car_state_description")
)}
`;
}
_renderEntityPicker(configValue, labelText, description) {
const toggleName = this._getToggleName(configValue);
return html`
<div class="input-group">
<label for="${configValue}">${labelText}</label>
<div class="entity-description">${description}</div>
<div class="entity-row">
<div class="entity-picker-wrapper">
<div class="entity-picker-container">
<input
type="text"
class="entity-picker-input"
.value="${this.config[configValue] || ""}"
@input="${(e) => this._entityFilterChanged(e, configValue)}"
placeholder="${this.localize("editor.search_entities")}"
/>
${this[`_${configValue}Filter`]
? html`
<div class="entity-picker-results">
${Object.keys(this.hass.states)
.filter((eid) =>
eid
.toLowerCase()
.includes(
this[`_${configValue}Filter`].toLowerCase()
)
)
.map(
(eid) => html`
<div
class="entity-picker-result"
@click="${() =>
this._selectEntity(configValue, eid)}"
>
${eid}
</div>
`
)}
</div>
`
: ""}
</div>
</div>
<label class="switch">
<input
type="checkbox"
?checked="${this.config[toggleName]}"
@change="${this._toggleChanged}"
.configValue="${toggleName}"
/>
<span class="slider round"></span>
</label>
</div>
</div>
`;
}
_renderIconGridConfig() {
return html`
<div class="icon-grid-container">
<h3>${this.localize("editor.icon_grid")}</h3>
<div class="input-group">
<div class="entity-description">
${this.localize("editor.icon_grid_description")}
</div>
<div class="entity-picker-wrapper">
<div class="entity-picker-container">
<input
type="text"
class="entity-picker-input"
.value="${this._iconGridFilter || ""}"
@input="${this._iconGridFilterChanged}"
placeholder="${this.localize("editor.search_entities")}"
/>
${this._iconGridFilter
? html`
<div class="entity-picker-results">
${Object.keys(this.hass.states)
.filter((eid) =>
eid
.toLowerCase()
.includes(this._iconGridFilter.toLowerCase())
)
.map(
(eid) => html`
<div
class="entity-picker-result"
@click="${() => this._addIconGridEntity(eid)}"
>
${eid}
</div>
`
)}
</div>
`
: ""}
</div>
</div>
<button class="add-row-button" @click="${this._addRowSeparator}">
${this.localize("editor.add_row_separator")}
</button>
<div class="reset-all-colors">
<span>${this.localize("editor.reset_all_icon_colors")}</span>
<ha-icon
class="reset-icon clickable"
icon="mdi:refresh"
title="${this.localize("editor.reset_all_icon_colors")}"
@click="${this._resetAllIconColors}"
></ha-icon>
</div>
</div>
<div
class="selected-entities"
@dragover="${this._onDragOver}"
@drop="${this._onDrop}"
>
${this._selectedIconGridEntities.map((entityId, index) =>
this._renderSelectedEntity(entityId, index)
)}
</div>
</div>
`;
}
_renderSelectedEntity(entityId, index) {
if (entityId === "row-separator") {
return this._renderRowSeparatorEditor(index);
}
const isExpanded = this._expandedEntities[entityId] || false;
const isActiveTemplate = this._isTemplateSelected(entityId, 'active');
const isInactiveTemplate = this._isTemplateSelected(entityId, 'inactive');
const sanitizedEntityId = entityId.replace(/\./g, "_");
const entity = this.hass.states[entityId];
const friendlyName = entity.attributes.friendly_name || entityId;
const customIcon = this._customIcons[entityId] || {};
const defaultIcon = entity.attributes.icon;
const activeIcon = customIcon.active || defaultIcon || "mdi:help-circle";
const inactiveIcon =
customIcon.inactive || defaultIcon || "mdi:help-circle";
const interaction = this._iconInteractions[entityId] || { type: "none" };
const buttonStyle = this._iconStyles[entityId] || "icon";
const activeColor =
customIcon.activeColor ||
UltraVehicleCardEditor._getComputedColor("--primary-color");
const inactiveColor =
customIcon.inactiveColor ||
UltraVehicleCardEditor._getComputedColor("--primary-text-color");
return html`
<div
class="selected-entity"
draggable="true"
@dragstart="${(e) => this._onDragStart(e, index)}"
data-entity-id="${entityId}"
>
<div class="entity-header" @click=${(e) => this._toggleEntityDetails(entityId, e)}>
<div
class="handle"
@mousedown="${(e) => this._onDragStart(e, index)}"
@touchstart="${(e) => this._onDragStart(e, index)}"
>
<ha-icon icon="mdi:drag"></ha-icon>
</div>
<ha-icon icon=${isExpanded ? "mdi:chevron-up" : "mdi:chevron-down"}></ha-icon>
<span class="entity-name">${friendlyName}</span>
<ha-icon
class="remove-entity"
icon="mdi:close"
@click="${() => this._removeIconGridEntity(index)}"
></ha-icon>
</div>
${this._expandedEntities[entityId] ? html`
<div class="entity-details">
<!-- Existing entity details content -->
${this._renderEntityDetails(entityId)}
</div>
` : ''}
</div>
`;
}
_renderEntityDetails(entityId) {
const sanitizedEntityId = entityId.replace(/\./g, "_");
const entity = this.hass.states[entityId];
const customIcon = this._customIcons[entityId] || {};
const defaultIcon = entity.attributes.icon;
const activeIcon = customIcon.active || defaultIcon || "mdi:help-circle";
const inactiveIcon = customIcon.inactive || defaultIcon || "mdi:help-circle";
const interaction = this._iconInteractions[entityId] || { type: "none" };
const buttonStyle = this._iconStyles[entityId] || "icon";
const activeColor = customIcon.activeColor || UltraVehicleCardEditor._getComputedColor("--primary-color");
const inactiveColor = customIcon.inactiveColor || UltraVehicleCardEditor._getComputedColor("--primary-text-color");
const isActiveTemplate = this._isTemplateSelected(entityId, 'active');
const isInactiveTemplate = this._isTemplateSelected(entityId, 'inactive');
const iconLabelPosition = (this.config.icon_labels && this.config.icon_labels[entityId]) || "none";
return html`
<div
class="entity-details"
id="entity-details-${sanitizedEntityId}"
style="display: block;"
>
<div class="editor-row ${isActiveTemplate || isInactiveTemplate ? 'template-selected' : ''}">
<div class="editor-item">
<label>${this.localize("editor.inactive_icon")}</label>
<ha-icon-picker
.hass=${this.hass}
.value=${inactiveIcon === "no-icon" ? "" : inactiveIcon}
@value-changed=${(e) => this._handleIconChange(e, "inactive", entityId)}
></ha-icon-picker>
<mwc-button
@click=${() => this._setNoIcon(entityId, "inactive")}
.selected=${inactiveIcon === "no-icon"}
>${inactiveIcon === "no-icon" ? "✓ " : ""}${this.localize("editor.no_icon")}</mwc-button>
<state-dropdown
.hass=${this.hass}
.config=${this.config.custom_icons?.[entityId] || {}}
.entityId=${entityId}
.stateType=${'inactive'}
.localize=${this.localize}
@state-dropdown-changed=${this._handleStateConfigChange}
@template-selected=${(e) => this._handleTemplateSelected(e, entityId, 'inactive')}
?disableDropdown=${isActiveTemplate}
></state-dropdown>
</div>
<div class="editor-item">
<label>${this.localize("editor.active_icon")}</label>
<ha-icon-picker
.hass=${this.hass}
.value=${activeIcon === "no-icon" ? "" : activeIcon}
@value-changed=${(e) => this._handleIconChange(e, "active", entityId)}
></ha-icon-picker>
<mwc-button
@click=${() => this._setNoIcon(entityId, "active")}
.selected=${activeIcon === "no-icon"}
>${activeIcon === "no-icon" ? "✓ " : ""}${this.localize("editor.no_icon")}</mwc-button>
<state-dropdown
.hass=${this.hass}
.config=${this.config.custom_icons?.[entityId] || {}}
.entityId=${entityId}
.stateType=${'active'}
.localize=${this.localize}
@state-dropdown-changed=${this._handleStateConfigChange}
@template-selected=${(e) => this._handleTemplateSelected(e, entityId, 'active')}
?disableDropdown=${isInactiveTemplate}
></state-dropdown>
</div>
</div>
<div class="editor-row">
<div class="editor-item">
${this._renderIconColorPicker(
this.localize("editor.inactive_icon_color"),
entityId,
"inactive",
inactiveColor
)}
${iconLabelPosition !== "none" ? html`
<label>${this.localize("editor.inactive_custom_label")}</label>
<input
type="text"
.value="${this._getCustomLabel(entityId, 'inactive')}"
@input="${(e) => this._customLabelChanged(e, entityId, 'inactive')}"
placeholder="${this.localize("editor.custom_label_placeholder")}"
/>
` : ''}
</div>
<div class="editor-item">
${this._renderIconColorPicker(
this.localize("editor.active_icon_color"),
entityId,
"active",
activeColor
)}
${iconLabelPosition !== "none" ? html`
<label>${this.localize("editor.active_custom_label")}</label>
<input
type="text"
.value="${this._getCustomLabel(entityId, 'active')}"
@input="${(e) => this._customLabelChanged(e, entityId, 'active')}"
placeholder="${this.localize("editor.custom_label_placeholder")}"
/>
` : ''}
</div>
</div>
<div class="divider"></div>
<div class="editor-row">
<div class="editor-item">
<label>${this.localize("editor.icon_style")}</label>
<select
@change="${(e) =>
this._handleButtonStyleChange(entityId, e.target.value)}"
.value="${buttonStyle}"
>
<option value="icon">Icon</option>
<option value="round">Round</option>
<option value="square">Square</option>
<option value="label">Label</option>
</select>
</div>
<div class="editor-item">
<label>${this.localize("editor.icon_size")}</label>
<div class="input-with-unit">
<input
id="icon_size_${entityId}"
type="number"
.value="${this._getIconSize(entityId)}"
@input="${(e) => this._iconSizeChanged(e, entityId)}"
min="12"
max="100"
/>
<span class="unit">px</span>
</div>
</div>
</div>
<div class="editor-row">
<div class="editor-item">
<label>${this.localize("editor.interaction")}</label>
${this._renderInteractionSelect(entityId, interaction)}
</div>
<div class="editor-item">
<label>${this.localize("editor.icon_label_position")}</label>
<select
.value=${iconLabelPosition}
@change=${(e) =>
this._updateIconLabel(entityId, e.target.value)}
>
<option value="none">${this.localize("editor.none")}</option>
<option value="left">${this.localize("editor.left")}</option>
<option value="top">${this.localize("editor.top")}</option>
<option value="right">${this.localize("editor.right")}</option>
<option value="bottom">
${this.localize("editor.bottom")}
</option>
</select>
</div>
</div>
</div>
</div>
`;
}
_handleTemplateSelected(e, entityId, stateType) {
const otherStateType = stateType === 'active' ? 'inactive' : 'active';
const otherDropdown = this.shadowRoot.querySelector(`state-dropdown[data-entity-id="${entityId}"][data-state-type="${otherStateType}"]`);
if (otherDropdown) {
otherDropdown.disableDropdown = e.detail.selected;
if (e.detail.selected) {
// Reset the other dropdown to 'default' if a template is selected
otherDropdown.value = 'default';
this._updateStateConfig(entityId, otherStateType, 'default');
}
}
// Force a re-render of the entire entity details
this.requestUpdate();
}
_isTemplateSelected(entityId, stateType) {
const config = this.config.custom_icons?.[entityId] || {};
return config[`${stateType}State`]?.startsWith('template:') || false;
}
_updateStateConfig(entityId, stateType, value) {
if (!this.config.custom_icons) {
this.config.custom_icons = {};
}
if (!this.config.custom_icons[entityId]) {
this.config.custom_icons[entityId] = {};
}
this.config.custom_icons[entityId][`${stateType}State`] = value;
this.configChanged(this.config);
}
_toggleRowSeparatorDetails(index) {
const detailsElement = this.shadowRoot.querySelector(
`#row-separator-details-${index}`
);
const toggleIcon = this.shadowRoot.querySelector(
`.selected-entity[data-entity-id="row-separator"]:nth-child(${
index + 1
}) .toggle-details`
);
if (detailsElement && toggleIcon) {
const isHidden =
detailsElement.style.display === "none" ||
!detailsElement.style.display;
detailsElement.style.display = isHidden ? "block" : "none";
toggleIcon.icon = isHidden ? "mdi:chevron-up" : "mdi:chevron-down";
}
}
_updateRowSeparatorConfig(index, property, value) {
if (!this.config.row_separators) {
this.config.row_separators = {};
}
if (!this.config.row_separators[index]) {
this.config.row_separators[index] = {};
}
if (value === '') {
delete this.config.row_separators[index][property];
} else {
this.config.row_separators[index][property] = value;
}
this.configChanged(this.config);
this.requestUpdate();
}
_onDrop(e) {
e.preventDefault();
let fromIndex;
if (e.dataTransfer) {
fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10);
} else {
fromIndex = this._draggedIndex;
}
const toIndex = [...e.currentTarget.children].indexOf(
e.target.closest(".selected-entity")
);
if (fromIndex !== undefined && fromIndex !== toIndex && toIndex !== -1) {
const newOrder = [...this._selectedIconGridEntities];
const [removed] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, removed);
this._selectedIconGridEntities = newOrder;
this._updateIconGridConfig();
}
// Reset the dragged index
this._draggedIndex = undefined;
}
_handleButtonStyleChange(entityId, style) {
this._iconStyles = {
...this._iconStyles,
[entityId]: style,
};
this._updateIconStylesConfig();
}
_updateIconStylesConfig() {
this.config = {
...this.config,
icon_styles: this._iconStyles,
};
this.configChanged(this.config);
}
_getDefaultColor(colorType) {
const style = getComputedStyle(this);
return colorType === "active"
? style.getPropertyValue("--primary-color").trim()
: style.getPropertyValue("--primary-text-color").trim();
}
_iconColorChanged(e, entityId, iconType) {
const color = e.target.value;
if (!this.config.custom_icons[entityId]) {
this.config.custom_icons[entityId] = {};
}
this.config.custom_icons[entityId][`${iconType}Color`] = color;
this._updateConfigAndRequestUpdate(
"custom_icons",
this.config.custom_icons
);
}
_resetIconColor(e, entityId, iconType) {
e.stopPropagation();
const defaultColor =
iconType === "active"
? UltraVehicleCardEditor._getComputedColor("--primary-color")
: UltraVehicleCardEditor._getComputedColor("--primary-text-color");
if (this.config.custom_icons[entityId]) {
this.config.custom_icons[entityId][`${iconType}Color`] = defaultColor;
}
this._updateConfigAndRequestUpdate(
"custom_icons",
this.config.custom_icons
);
}
_updateCustomIconsConfig() {
const cleanedCustomIcons = Object.entries(this._customIcons).reduce(
(acc, [key, value]) => {
const cleanedValue = {
active: value.active === "" ? undefined : value.active,
inactive: value.inactive === "" ? undefined : value.inactive,
activeColor: value.activeColor,
inactiveColor: value.inactiveColor,
};
if (cleanedValue.active || cleanedValue.inactive) {
acc[key] = cleanedValue;
}
return acc;
},
{}
);
this.config = {
...this.config,
custom_icons: cleanedCustomIcons,
};
this.configChanged(this.config);
}
_toggleEntityDetails(entityId, event) {
// Stop propagation to prevent conflicts with drag events
event.stopPropagation();
this._expandedEntities = {
...this._expandedEntities,
[entityId]: !this._expandedEntities[entityId]
};
this.requestUpdate();
}
_getNavigationPaths() {
return [
"overview",
"map",
"logbook",
"history",
"energy",
"config",
"developer-tools",
"lovelace",
"devices",
"integrations",
"automations",
"scenes",
"scripts",
"areas",
"tags",
"people",
];
}
_updateIndices() {
const elements = this.shadowRoot.querySelectorAll(".selected-entity");
elements.forEach((element, index) => {
element.dataset.index = index;
});
}
_renderInteractionSelect(entityId, interaction) {
const interactions = [
{ value: "more-info", label: this.localize("editor.more_info") },
{ value: "toggle", label: this.localize("editor.toggle") },
{ value: "navigate", label: this.localize("editor.navigate") },
{ value: "url", label: this.localize("editor.url") },
{ value: "trigger", label: this.localize("editor.trigger") },
{ value: "none", label: this.localize("editor.none") },
];
return html`
<select
class="interaction-select"
.value=${interaction.type}
@change=${(e) =>
this._handleInteractionTypeChange(entityId, e.target.value)}
>
${interactions.map(
(int) => html`
<option
value=${int.value}
?selected=${interaction.type === int.value}
>
${int.label}
</option>
`
)}
</select>
${this._renderInteractionOptions(entityId, interaction)}
`;
}
_renderInteractionOptions(entityId, interaction) {
switch (interaction.type) {
case "navigate":
return this._renderNavigationOption(entityId, interaction);
case "url":
return this._renderUrlOption(entityId, interaction);
default:
return html``;
}
}
_renderNavigationOption(entityId, interaction) {
const paths = this._getNavigationPaths();
return html`
<div class="interaction-option">
<label>${this.localize("editor.navigation_path")}:</label>
<select
@change=${(e) =>
this._updateInteractionOption(entityId, "path", e.target.value)}
>
${paths.map(
(path) => html`
<option value=${path} ?selected=${interaction.path === path}>
${path}
</option>
`
)}
</select>
</div>
`;
}
_renderUrlOption(entityId, interaction) {
return html`
<div class="interaction-option">
<label>${this.localize("editor.url")}:</label>
<input
type="text"
.value=${interaction.url || ""}
@input=${(e) =>
this._updateInteractionOption(entityId, "url", e.target.value)}
/>
</div>
`;
}
_handleInteractionTypeChange(entityId, newType) {
this._iconInteractions = {
...this._iconInteractions,
[entityId]: { type: newType },
};
this._updateIconInteractionsConfig();
this.requestUpdate();
}
_getDisplayImageUrl(url) {
return url && url.startsWith("data:image")
? this.localize("editor.uploaded_image")
: url;
}
_updateInteractionOption(entityId, option, value) {
this._iconInteractions = {
...this._iconInteractions,
[entityId]: {
...this._iconInteractions[entityId],
[option]: value,
},
};
this._updateIconInteractionsConfig();
}
_iconSizeChanged(e) {
this._iconSize = parseInt(e.target.value);
this.config = {
...this.config,
icon_size: this._iconSize,
};
this.configChanged(this.config);
fireEvent(this, "config-changed", { config: this.config });
}
_onDragStart(e, index) {
if (e.dataTransfer) {
e.dataTransfer.setData("text/plain", index.toString());
}
// Store the index in a class property as a fallback
this._draggedIndex = index;
}
_onDragOver(e) {
e.preventDefault();
}
_onDrop(e) {
e.preventDefault();
let fromIndex;
if (e.dataTransfer) {
fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10);
} else {
fromIndex = this._draggedIndex;
}
const toIndex = [...e.currentTarget.children].indexOf(
e.target.closest(".selected-entity")
);
if (fromIndex !== undefined && fromIndex !== toIndex && toIndex !== -1) {
const newOrder = [...this._selectedIconGridEntities];
const [removed] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, removed);
this._selectedIconGridEntities = newOrder;
this._updateIconGridConfig();
}
// Reset the dragged index
this._draggedIndex = undefined;
}
_handleIconChange(e, iconType, entityId) {
const newIcon = e.detail.value;
if (newIcon === "") {
this._clearIcon(entityId, iconType);
} else {
this._customIcons = {
...this._customIcons,
[entityId]: {
...this._customIcons[entityId],
[iconType]: newIcon,
},
};
this._updateCustomIconsConfig();
}
}
_updateIconInteractionsConfig() {
const newConfig = {
...this.config,
icon_interactions: this._iconInteractions,
};
this.configChanged(newConfig);
}
_renderColorPickers() {
const getDefaultColor = (property) => {
const style = getComputedStyle(this);
return (
style.getPropertyValue(property).trim() ||
style.getPropertyValue(`--${property}`).trim()
);
};
const defaultColors = {
cardTitleColor: getDefaultColor("--primary-text-color"),
cardBackgroundColor: UltraVehicleCardEditor._getComputedColor("--ha-card-background") || UltraVehicleCardEditor._getComputedColor("--card-background-color"),
barBackgroundColor: getDefaultColor("--secondary-text-color"),
barBorderColor: getDefaultColor("--secondary-text-color"),
barFillColor: getDefaultColor("--primary-color"),
limitIndicatorColor: getDefaultColor("--primary-text-color"),
infoTextColor: getDefaultColor("--secondary-text-color"),
carStateTextColor: getDefaultColor("--primary-text-color"),
rangeTextColor: getDefaultColor("--primary-text-color"),
percentageTextColor: getDefaultColor("--primary-text-color"),
};
return html`
<div class="color-pickers">
<h3>${this.localize("editor.colors")}</h3>
<div class="entity-description">
${this.localize("editor.custom_colors_description")}
</div>
<div class="reset-all-colors">
<span>${this.localize("editor.reset_all_colors")}</span>
<ha-icon
class="reset-icon clickable"
icon="mdi:refresh"
@click="${this._resetAllColors}"
title="${this.localize("editor.reset_all_colors")}"
></ha-icon>
</div>
<div class="color-pickers-grid">
${Object.entries(defaultColors).map(
([key, defaultValue]) => html`
<div class="color-picker-item">
${this._renderColorPicker(
this.localize(`editor.${key}`),
key,
defaultValue
)}
</div>
`
)}
</div>
</div>
`;
}
_refreshConfig() {
// Refresh the configuration values
this.config = { ...this.config };
this.requestUpdate();
}
_renderBarGradientToggle() {
return html`
<div class="bar-gradient-section">
<div class="input-group">
<label for="useBarGradient">${this.localize("editor.use_bar_gradient")}</label>
<label class="switch">
<input
type="checkbox"
id="useBarGradient"
.checked=${this.config.useBarGradient || false}
@change=${this._handleUseBarGradientChange}
/>
<span class="slider round"></span>
</label>
</div>
<div class="description">
${this.localize("editor.bar_gradient_description")}
</div>
${this.config.useBarGradient ? this._renderBarGradientOptions() : ''}
</div>
`;
}
_renderBarGradientOptions() {
const gradientStops = this.config.barGradientStops || this._getDefaultGradientStops();
return html`
<div class="bar-gradient-options">
${this._renderGradientPreview(gradientStops)}
${gradientStops.map((stop, index) => html`
<div class="gradient-stop">
<ha-textfield
type="number"
min="0"
max="100"
.value=${stop.percentage}
@input=${(e) => this._updateGradientStop(index, 'percentage', parseInt(e.target.value))}
label="${this.localize("editor.percentage")}"
></ha-textfield>
<div class="color-picker">
<label>${this.localize("editor.color")}</label>
<div class="icon-grid-color-picker-wrapper">
<input
type="text"
.value="${stop.color}"
@input="${(e) => this._updateGradientStop(index, 'color', e.target.value)}"
class="hex-input"
style="background-color: ${stop.color}; color: ${this._getContrastYIQ(stop.color)};"
/>
<div class="color-preview" style="background-color: ${stop.color};">
<ha-icon
icon="mdi:palette"
style="color: ${this._getContrastYIQ(stop.color)};"
></ha-icon>
<input
type="color"
.value="${stop.color}"
@input="${(e) => this._updateGradientStop(index, 'color', e.target.value)}"
class="color-input"
/>
</div>
<ha-icon
class="reset-icon"
icon="mdi:refresh"
@click="${(e) => this._resetGradientStopColor(e, index)}"
></ha-icon>
</div>
</div>
<ha-icon
class="delete-icon"
icon="mdi:close"
@click="${() => this._deleteGradientStop(index)}"
title="${this.localize("editor.delete_gradient_stop")}"
></ha-icon>
</div>
`)}
${gradientStops.length < 11 ? html`
<mwc-button @click=${this._addGradientStop}>
${this.localize("editor.add_gradient_stop")}
</mwc-button>
` : ''}
</div>
`;
}
_renderGradientPreview(stops) {
const sortedStops = stops.slice().sort((a, b) => a.percentage - b.percentage);
const gradientString = sortedStops.map(stop => `${stop.color} ${stop.percentage}%`).join(', ');
return html`
<div class="gradient-preview-container">
<div class="gradient-preview" style="background: linear-gradient(to right, ${gradientString});">
${[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map(percentage => html`
<div class="percentage-marker" style="left: ${percentage}%;">
<div class="marker-line"></div>
<span class="marker-label">${percentage}%</span>
</div>
`)}
</div>
</div>
`;
}
_handleUseBarGradientChange(e) {
const useBarGradient = e.target.checked;
if (useBarGradient && (!this.config.barGradientStops || this.config.barGradientStops.length === 0)) {
// Set default gradient stops when first enabled
this._updateConfig('barGradientStops', [
{ percentage: 0, color: '#FF0000' }, // Red at 0%
{ percentage: 100, color: '#00FF00' } // Green at 100%
]);
}
this._updateConfig('useBarGradient', useBarGradient);
}
_updateGradientStop(index, property, value) {
const gradientStops = [...(this.config.barGradientStops || [])];
gradientStops[index] = { ...gradientStops[index], [property]: value };
this._updateConfig('barGradientStops', gradientStops);
}
_resetGradientStopColor(e, index) {
e.stopPropagation();
const defaultColors = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff'];
const gradientStops = [...(this.config.barGradientStops || [])];
gradientStops[index] = { ...gradientStops[index], color: defaultColors[index % defaultColors.length] };
this._updateConfig('barGradientStops', gradientStops);
}
_deleteGradientStop(index) {
let gradientStops = [...(this.config.barGradientStops || this._getDefaultGradientStops())];
if (gradientStops.length > 2) {
gradientStops.splice(index, 1);
} else {
// If we're trying to delete when only 2 stops remain, reset to default
gradientStops = this._getDefaultGradientStops();
}
this._updateConfig('barGradientStops', gradientStops);
}
_getDefaultGradientStops() {
return [
{ percentage: 0, color: '#FF0000' },
{ percentage: 100, color: '#00FF00' }
];
}
_getFullGradientStops() {
return [
{ percentage: 0, color: '#FF0000' },
{ percentage: 10, color: '#FF1A00' },
{ percentage: 20, color: '#FF3300' },
{ percentage: 30, color: '#FF4D00' },
{ percentage: 40, color: '#FF6600' },
{ percentage: 50, color: '#FFFF00' },
{ percentage: 60, color: '#CCFF00' },
{ percentage: 70, color: '#99FF00' },
{ percentage: 80, color: '#66FF00' },
{ percentage: 90, color: '#33FF00' },
{ percentage: 100, color: '#00FF00' }
];
}
_addGradientStop() {
const gradientStops = [...(this.config.barGradientStops || this._getDefaultGradientStops())];
if (gradientStops.length < 11) {
const fullStops = this._getFullGradientStops();
const newStop = fullStops.find(stop => !gradientStops.some(existing => existing.percentage === stop.percentage));
if (newStop) {
gradientStops.push(newStop);
gradientStops.sort((a, b) => a.percentage - b.percentage);
this._updateConfig('barGradientStops', gradientStops);
}
} else {
console.warn("Maximum of 11 gradient stops reached");
}
}
_renderColorPicker(label, configKey, defaultValue) {
const currentValue = this.config[configKey] || defaultValue;
const textColor = this._getContrastYIQ(currentValue);
return html`
<div class="color-picker">
<label>${label}</label>
<div class="icon-grid-color-picker-wrapper">
<input
type="text"
.value="${currentValue}"
@input="${(e) => this._colorChanged(e, configKey)}"
class="hex-input"
style="background-color: ${currentValue}; color: ${textColor};"
/>
<div class="color-preview" style="background-color: ${currentValue};">
<ha-icon
icon="mdi:palette"
style="color: ${textColor};"
></ha-icon>
<input
type="color"
.value="${currentValue}"
@input="${(e) => this._colorChanged(e, configKey)}"
class="color-input"
/>
</div>
<ha-icon
class="reset-icon"
icon="mdi:refresh"
@click="${(e) => this._resetColor(configKey, defaultValue, e)}"
></ha-icon>
</div>
</div>
`;
}
_colorChanged(e, configKey) {
const color = e.target.value;
this._userChangedColors[configKey] = color !== this._defaultColors[configKey];
this._debouncedColorChanged(configKey, color);
}
_debouncedColorChanged(configKey, color) {
// Clean up and potentially expand the color before applying
const cleanedColor = this._cleanAndExpandColor(color);
this.config = { ...this.config, [configKey]: cleanedColor };
this.configChanged(this.config);
}
_cleanAndExpandColor(color) {
// Remove any non-hex characters
color = color.replace(/[^0-9A-Fa-f#]/g, '');
// Ensure only one '#' at the start
color = color.replace(/#+/g, '#');
if (color.includes('#') && !color.startsWith('#')) {
color = '#' + color.replace('#', '');
}
return color;
}
_expandHexColor(color) {
return '#' + color.slice(1).split('').map(char => char + char).join('');
}
_rgbaToHex(rgba) {
const [r, g, b] = rgba.match(/\d+/g).map(Number);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
static _expandHexColor(color) {
if (color && color.charAt(0) === '#' && color.length === 0) {
return color.replace(/([0-9A-F])/gi, '$1$1');
}
return color;
}
_resetColor(configKey, defaultValue, e) {
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation();
}
const expandedDefaultColor = UltraVehicleCardEditor._expandHexColor(defaultValue);
this._userChangedColors[configKey] = false;
this._applyColorChange(configKey, expandedDefaultColor);
this.requestUpdate();
}
_updateIconBackground() {
const cardBackgroundColor = this.config.cardBackgroundColor || getComputedStyle(this).getPropertyValue('--card-background-color').trim();
const isDarkBackground = this._isColorDark(cardBackgroundColor);
this._updateIconBackgroundColor(isDarkBackground);
}
_handleStateConfigChange(e) {
const { config, entityId, stateType, attributeValue } = e.detail;
let newConfig = { ...this.config, custom_icons: {...this.config?.custom_icons, [entityId]: {...this.config?.custom_icons?.[entityId]} }};
newConfig.custom_icons[entityId][`${stateType}State`] = config[`${stateType}State`];
if (config[`${stateType}State`].startsWith('attribute:') && attributeValue) {
newConfig.custom_icons[entityId][`${stateType}State`] += `:${attributeValue}`;
}
this.config = newConfig;
this.configChanged(this.config);
}
_fireEvent(type, detail) {
const event = new CustomEvent(type, {
detail,
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
_toggleChanged(ev) {
const target = ev.target;
if (target.configValue) {
this.config = {
...this.config,
[target.configValue]: target.checked,
};
this.configChanged(this.config);
}
}
_vehicleTypeChanged(ev) {
this.config = {
...this.config,
vehicle_type: ev.target.value,
};
this.configChanged(this.config);
this.requestUpdate();
}
_unitTypeChanged(ev) {
this.config = {
...this.config,
unit_type: ev.target.value,
};
this.configChanged(this.config);
this.requestUpdate();
}
_hybridOrderChanged(ev) {
this.config = {
...this.config,
hybrid_display_order: ev.target.value,
};
this.configChanged(this.config);
}
_renderImageUploadField(label, configKey, placeholder) {
const imageTypeKey = `${configKey}_type`;
const entityKey = configKey === 'image_url' ? 'image_entity' :
configKey === 'charging_image_url' ? 'charging_image_entity' :
'engine_on_image_entity';
const value = this.config[configKey] || "";
const currentType = this.config[imageTypeKey] || "default";
return html`
<div class="image-input-container">
<div style="display: content; justify-content: space-between; align-items: center;">
<label style="margin-right: 16px; font-size: 1.2em; font-weight: bold;">${label}</label>
<div class="radio-group">
<label>
<input type="radio" name="${imageTypeKey}" value="none"
?checked="${currentType === "none"}"
@change="${(e) => this._handleImageSourceChange(configKey, "none")}"
/>
${this.localize("editor.none")}
</label>
<label>
<input type="radio" name="${imageTypeKey}" value="image"
?checked="${currentType === "image"}"
@change="${(e) => this._handleImageSourceChange(configKey, "image")}"
/>
${this.localize("editor.local_url")}
</label>
<label>
<input type="radio" name="${imageTypeKey}" value="entity"
?checked="${currentType === "entity"}"
@change="${(e) => this._handleImageSourceChange(configKey, "entity")}"
/>
${this.localize("editor.entity")}
</label>
</div>
</div>
${currentType === "image"
? html`
<div class="image-upload-container">
<input
type="text"
.value="${value}"
placeholder="${placeholder}"
@input="${(e) => this._handleImageUrlInput(e, configKey)}"
/>
<label class="file-upload-label" for="${configKey}-upload"
>${this.localize("editor.upload_image")}</label
>
<input
type="file"
id="${configKey}-upload"
style="display:none"
@change="${(e) => this._handleImageUpload(e, configKey)}"
/>
</div>
`
: currentType === "entity"
? html`
<div class="entity-picker-wrapper">
<div class="entity-picker-container">
<input
type="text"
class="entity-picker-input"
.value="${this.config[entityKey] || ""}"
@input="${(e) => this._entityFilterChanged(e, entityKey)}"
placeholder="${this.localize("editor.search_entities")}"
/>
${this[`_${entityKey}Filter`]
? html`
<div class="entity-picker-results">
${Object.entries(this.hass.states)
.filter(([eid, state]) =>
eid.toLowerCase().includes(this[`_${entityKey}Filter`].toLowerCase()) ||
this._entityHasImage(state)
)
.map(
([eid, state]) => html`
<div
class="entity-picker-result"
@click="${() => this._selectEntity(entityKey, eid)}"
>
${eid}${this._entityHasImage(state) ? ' (has image)' : ''}
</div>
`
)}
</div>
`
: ""}
</div>
</div>
`
: ""}
</div>
`;
}
_entityHasImage(state) {
if (typeof state.state === 'string' && state.state.startsWith('http')) {
return true;
}
for (const [key, value] of Object.entries(state.attributes)) {
if (typeof value === 'string' && value.startsWith('http')) {
return true;
}
}
return false;
}
_renderEntityPickerWithoutToggle(configValue, labelText, description) {
return html`
<div class="input-group">
<label for="${configValue}">${labelText}</label>
<div class="entity-description">${description}</div>
<div class="entity-row">
<div class="entity-picker-wrapper">
<div class="entity-picker-container">
<input
type="text"
class="entity-picker-input"
.value="${this.config[configValue] || ""}"
@input="${(e) => this._entityFilterChanged(e, configValue)}"
placeholder="${this.localize("editor.search_entities")}"
/>
${this[`_${configValue}Filter`]
? html`
<div class="entity-picker-results">
${Object.keys(this.hass.states)
.filter((eid) =>
eid
.toLowerCase()
.includes(
this[`_${configValue}Filter`].toLowerCase()
)
)
.map(
(eid) => html`
<div
class="entity-picker-result"
@click="${() =>
this._selectEntity(configValue, eid)}"
>
${eid}
</div>
`
)}
</div>
`
: ""}
</div>
</div>
</div>
</div>
`;
}
_renderImageInput(configKey, type, value, placeholder) {
value = value || DEFAULT_IMAGE_URL;
switch (type) {
case "entity":
return html`
<ha-entity-picker
.hass=${this.hass}
.value=${value.startsWith("entity:") ? value.slice(7) : value}
.configValue="${configKey}"
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>
`;
case "template":
return html`
<ha-textarea
.value=${value}
.configValue="${configKey}"
@input="${this._templateChanged}"
placeholder="${this.localize("editor.enter_template_code")}"
rows="3"
></ha-textarea>
`;
default: // 'image'
return html`
<ha-textfield
type="text"
.value="${value}"
.configValue="${configKey}"
placeholder="${placeholder}"
@input="${this._valueChanged}"
/>
<label for="${configKey}_upload" class="file-upload-label">
${this.localize("editor.upload")}
<input
type="file"
id="${configKey}_upload"
@change="${(e) => this._handleImageUpload(e, configKey)}"
accept="image/*"
style="display: none;"
/>
</label>
`;
}
}
async _templateChanged(ev, configKey) {
const newValue = ev.target.value;
try {
const result = await this._evaluateTemplate(newValue);
if (result) {
this._updateConfig(configKey, newValue);
}
} catch (error) {
console.error("Error evaluating template:", error);
}
}
_renderTemplatePicker(configKey, value) {
const templates = this._getTemplateHelpers();
return html`
<ha-combo-box
.hass=${this.hass}
.value=${value}
.items=${templates}
.configValue="${configKey}"
@value-changed="${this._templatePicked}"
item-value-path="value"
item-label-path="name"
></ha-combo-box>
`;
}
_templatePicked(ev) {
const target = ev.target;
const configValue = target.configValue;
const newValue = ev.detail.value || "";
this._updateConfig(configValue, newValue);
}
_updateConfig(key, value) {
if (typeof key === 'object') {
this.config = { ...this.config, ...key };
} else {
this.config = { ...this.config, [key]: value };
}
this.configChanged(this.config);
this.requestUpdate();
}
_getTemplateHelpers() {
return Object.keys(this.hass.states)
.filter(
(entityId) =>
entityId.startsWith("template.") || entityId.startsWith("input_text.")
)
.map((entityId) => ({
value: `{{ states('${entityId}') }}`,
name: this.hass.states[entityId].attributes.friendly_name || entityId,
}));
}
_entityPicked(e, configKey) {
const newValue = e.detail.value;
if (newValue) {
this._updateConfig(configKey, newValue);
}
}
_handleImageUpload(e, configKey) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const imageData = e.target.result;
this._updateConfig(configKey, imageData);
this._updateConfig(`${configKey}_type`, 'image');
this.requestUpdate();
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
};
reader.readAsDataURL(file);
}
}
_selectEntity(configValue, entityId) {
const entity = this.hass.states[entityId];
let imageUrl = entity.state;
if (!imageUrl.startsWith('http')) {
imageUrl = Object.values(entity.attributes).find(attr => typeof attr === 'string' && attr.startsWith('http')) || '';
}
this.config = {
...this.config,
[configValue]: entityId,
[`${configValue.replace('_entity', '_url')}`]: imageUrl,
};
this[`_${configValue}Filter`] = "";
this.configChanged(this.config);
}
_iconGridFilterChanged(e) {
this._iconGridFilter = e.target.value;
this.requestUpdate();
}
_addIconGridEntity(entityId) {
if (this._selectedIconGridEntities.length === 0) {
this._addRowSeparator();
}
this._selectedIconGridEntities.push(entityId);
this._updateIconGridConfig();
this._iconGridFilter = "";
}
_removeIconGridEntity(index) {
const removedEntityId = this._selectedIconGridEntities[index];
this._selectedIconGridEntities = this._selectedIconGridEntities.filter(
(_, i) => i !== index
);
if (removedEntityId === "row-separator") {
// Remove the row separator configuration
const { [index]: _, ...restSeparators } = this.config.row_separators;
this.config.row_separators = restSeparators;
} else {
const { [removedEntityId]: _, ...restIcons } = this._customIcons;
this._customIcons = restIcons;
}
// If all entities are removed, remove the last row separator
if (
this._selectedIconGridEntities.length === 1 &&
this._selectedIconGridEntities[0] === "row-separator"
) {
this._selectedIconGridEntities = [];
this.config.row_separators = {};
}
this._updateIconGridConfig();
this._updateCustomIconsConfig();
}
_updateIconGridConfig() {
this._ensureRowSeparatorAtTop();
this.config = {
...this.config,
icon_grid_entities: this._selectedIconGridEntities,
row_separators: { ...this.config.row_separators },
};
this.configChanged(this.config);
}
_ensureRowSeparatorAtTop() {
if (
this._selectedIconGridEntities.length > 0 &&
this._selectedIconGridEntities[0] !== "row-separator"
) {
this._selectedIconGridEntities.unshift("row-separator");
if (!this.config.row_separators) {
this.config.row_separators = {};
}
this.config.row_separators[0] = {
color: "var(--uvc-info-text-color)",
height: 1,
icon_gap: 20,
horizontalAlignment: "center",
verticalAlignment: "middle",
};
this._updateIconGridConfig();
}
}
_getToggleName(configValue) {
switch (configValue) {
case "battery_level_entity":
return this.config.vehicle_type === "EV"
? "show_battery"
: "show_battery";
case "battery_range_entity":
return this.config.vehicle_type === "EV"
? "show_battery_range"
: "show_battery_range";
case "fuel_level_entity":
return "show_fuel";
case "fuel_range_entity":
return "show_fuel_range";
case "location_entity":
return "show_location";
case "mileage_entity":
return "show_mileage";
case "car_state_entity":
return "show_car_state";
case "charge_limit_entity":
return "show_charge_limit";
case "charging_status_entity":
return "show_charging_status";
case "engine_on_entity":
return "show_engine_on";
default:
return `show_${configValue.split("_")[0]}`;
}
}
_formatLabel(key) {
return key
.split(/(?=[A-Z])/)
.join(" ")
.replace(/^\w/, (c) => c.toUpperCase());
}
_getContrastYIQ(color) {
let r, g, b, a = 1;
if (color.startsWith('rgba')) {
[r, g, b, a] = color.match(/[\d.]+/g).map(Number);
} else if (color.startsWith('rgb')) {
[r, g, b] = color.match(/\d+/g).map(Number);
} else if (color.startsWith('#')) {
const hex = color.replace('#', '');
r = parseInt(hex.substr(0, 2), 16);
g = parseInt(hex.substr(2, 2), 16);
b = parseInt(hex.substr(4, 2), 16);
} else {
return '#808080'; // Default to black text if color format is unknown
}
// Adjust for transparency by blending with a white background
r = Math.round(r * a + 255 * (1 - a));
g = Math.round(g * a + 255 * (1 - a));
b = Math.round(b * a + 255 * (1 - a));
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
return (yiq >= 128) ? 'black' : 'white';
}
configChanged(newConfig) {
const event = new CustomEvent("config-changed", {
detail: { config: newConfig },
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
async _evaluateTemplate(template) {
try {
if (!this.hass) {
console.error("Home Assistant instance not available");
return null;
}
// Use Home Assistant's template rendering system
return this.hass.callWS({
type: "render_template",
template: template,
entity_ids: [],
});
} catch (error) {
console.error("Error evaluating template:", error);
return null;
}
}
_getIconSize(entityId) {
return this._iconSizes[entityId] || this.config.icon_size || 24;
}
_iconSizeChanged(e, entityId) {
const newSize = parseInt(e.target.value);
this._iconSizes = {
...this._iconSizes,
[entityId]: newSize,
};
this._updateIconSizesConfig();
}
_updateIconLabel(entityId, value) {
if (!this.config.icon_labels) {
this.config.icon_labels = {};
}
this.config.icon_labels[entityId] = value;
this.configChanged(this.config);
}
_addRowSeparator() {
const newIndex = this._selectedIconGridEntities.length;
this._selectedIconGridEntities.push("row-separator");
if (
!this.config.row_separators ||
Object.isFrozen(this.config.row_separators)
) {
this.config.row_separators = { ...this.config.row_separators };
}
this.config.row_separators[newIndex] = {
color: "transparent",
height: 1,
icon_gap: 20,
horizontalAlignment: "center",
verticalAlignment: "middle",
};
this._updateIconGridConfig();
}
_renderRowSeparatorEditor(index) {
return html`
<div
class="selected-entity row-separator"
draggable="true"
@dragstart="${(e) => this._onDragStart(e, index)}"
data-entity-id="row-separator"
>
<div class="entity-header">
<div
class="handle"
@mousedown="${(e) => this._onDragStart(e, index)}"
@touchstart="${(e) => this._onDragStart(e, index)}"
>
<ha-icon icon="mdi:drag"></ha-icon>
</div>
<ha-icon
class="toggle-details"
icon="mdi:chevron-down"
@click="${() => this._toggleRowSeparatorDetails(index)}"
></ha-icon>
<span class="entity-name"
>${this.localize("editor.row_separator")}</span
>
<ha-icon
class="remove-entity"
icon="mdi:close"
@click="${() => this._removeIconGridEntity(index)}"
></ha-icon>
</div>
<div
class="entity-details"
id="row-separator-details-${index}"
style="display: none;"
>
${this._renderRowSeparatorDetails(index)}
</div>
</div>
`;
}
_renderRowSeparatorDetails(index) {
const separatorConfig = this.config.row_separators?.[index] || {};
return html`
<div class="row-separator-details">
${this._renderRowSeparatorColorPicker(index)}
<div class="editor-row">
<div class="editor-item">
<label>${this.localize("editor.separator_height")}</label>
<div class="input-with-unit">
<input
type="number"
.value="${separatorConfig.height || ''}"
@input="${(e) =>
this._updateRowSeparatorConfig(
index,
"height",
e.target.value === '' ? '' : parseInt(e.target.value)
)}"
min="0"
max="100"
/>
<span class="unit">px</span>
</div>
</div>
<div class="editor-item">
<label>${this.localize("editor.icon_gap_size")}</label>
<div class="input-with-unit">
<input
type="number"
.value="${separatorConfig.icon_gap || ''}"
@input="${(e) =>
this._updateRowSeparatorConfig(
index,
"icon_gap",
e.target.value === '' ? '' : parseInt(e.target.value)
)}"
min="0"
max="100"
/>
<span class="unit">px</span>
</div>
</div>
</div>
<div class="editor-row">
<div class="editor-item">
<label>${this.localize("editor.horizontal_alignment")}</label>
<div class="alignment-buttons">
<button
class="icon-button"
@click="${() =>
this._updateRowSeparatorConfig(
index,
"horizontalAlignment",
"left"
)}"
?disabled="${separatorConfig.horizontalAlignment === "left"}"
title="${this.localize("editor.align_left")}"
>
</button>
<button
class="icon-button"
@click="${() =>
this._updateRowSeparatorConfig(
index,
"horizontalAlignment",
"center"
)}"
?disabled="${separatorConfig.horizontalAlignment === "center" ||
separatorConfig.horizontalAlignment === undefined}"
title="${this.localize("editor.align_center")}"
>
</button>
<button
class="icon-button"
@click="${() =>
this._updateRowSeparatorConfig(
index,
"horizontalAlignment",
"right"
)}"
?disabled="${separatorConfig.horizontalAlignment === "right"}"
title="${this.localize("editor.align_right")}"
>
</button>
</div>
</div>
<div class="editor-item">
<label>${this.localize("editor.vertical_alignment")}</label>
<div class="alignment-buttons">
<button
class="icon-button"
@click="${() =>
this._updateRowSeparatorConfig(
index,
"verticalAlignment",
"top"
)}"
?disabled="${separatorConfig.verticalAlignment === "top"}"
title="${this.localize("editor.align_top")}"
>
</button>
<button
class="icon-button"
@click="${() =>
this._updateRowSeparatorConfig(
index,
"verticalAlignment",
"middle"
)}"
?disabled="${separatorConfig.verticalAlignment === "middle"}"
title="${this.localize("editor.align_middle")}"
>
</button>
<button
class="icon-button"
@click="${() =>
this._updateRowSeparatorConfig(
index,
"verticalAlignment",
"bottom"
)}"
?disabled="${separatorConfig.verticalAlignment === "bottom"}"
title="${this.localize("editor.align_bottom")}"
>
</button>
</div>
</div>
</div>
</div>
`;
}
_renderRowSeparatorColorPicker(index) {
const currentColor =
this.config.row_separators?.[index]?.color ||
this._getDefaultColorAsHex();
const textColor = this._getContrastYIQ(currentColor);
const isTransparent = currentColor === "transparent";
return html`
<div class="row-separator-color-row">
<div class="color-picker row-separator-color-picker">
<label>${this.localize("editor.separator_color")}</label>
<div class="icon-grid-color-picker-wrapper">
<input
type="text"
.value="${isTransparent
? this.localize("editor.transparent")
: currentColor}"
@input="${(e) => this._rowSeparatorColorChanged(e, index)}"
class="hex-input"
style="background-color: ${isTransparent
? "transparent"
: currentColor}; color: ${textColor};"
/>
<div
class="color-preview"
style="background-color: ${isTransparent
? "transparent"
: currentColor};"
>
<ha-icon
icon="mdi:palette"
style="color: ${textColor};"
></ha-icon>
<input
type="color"
.value="${isTransparent ? "#ffffff" : currentColor}"
@input="${(e) => this._rowSeparatorColorChanged(e, index)}"
class="color-input"
/>
</div>
<ha-icon
class="reset-icon"
icon="mdi:refresh"
@click="${(e) => this._resetRowSeparatorColor(e, index)}"
></ha-icon>
</div>
</div>
<div class="transparent-button-wrapper">
<button
class="transparent-button"
@click="${() => this._toggleTransparentSeparator(index)}"
>
${isTransparent
? this.localize("editor.set_color")
: this.localize("editor.transparent")}
</button>
</div>
</div>
`;
}
_toggleTransparentSeparator(index) {
const currentColor = this.config.row_separators?.[index]?.color;
const defaultColor = this._getDefaultColorAsHex();
const newColor =
currentColor === "transparent" ? defaultColor : "transparent";
this._updateRowSeparatorConfig(index, "color", newColor);
this.requestUpdate();
}
_resetRowSeparatorColor(e, index) {
e.stopPropagation();
const defaultColor = this._getDefaultColorAsHex();
this._updateRowSeparatorConfig(index, "color", defaultColor);
}
_toggleRowSeparatorDetails(index) {
const detailsElement = this.shadowRoot.querySelector(
`#row-separator-details-${index}`
);
const toggleIcon = this.shadowRoot.querySelector(
`.selected-entity[data-entity-id="row-separator"]:nth-child(${
index + 1
}) .toggle-details`
);
if (detailsElement && toggleIcon) {
const isHidden =
detailsElement.style.display === "none" ||
!detailsElement.style.display;
detailsElement.style.display = isHidden ? "block" : "none";
toggleIcon.icon = isHidden ? "mdi:chevron-up" : "mdi:chevron-down";
}
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this.setDefaultValues();
this.loadResources(this.config.language || navigator.language).then(() => {
this.requestUpdate();
});
}
_camelToKebab(string) {
return string
.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2")
.toLowerCase();
}
_updateConfigAndRequestUpdate(key, value) {
this.config = {
...this.config,
[key]: value,
};
this.configChanged(this.config);
this.requestUpdate();
}
_rowSeparatorColorChanged(e, index) {
const color = e.target.value;
this._updateRowSeparatorConfig(index, "color", color);
}
_applyColorChange(configKey, color) {
const expandedColor = UltraVehicleCardEditor._expandHexColor(color);
if (configKey === 'cardTitleColor') {
this.config = {
...this.config,
[configKey]: color,
};
this._updateCardTitleColor(color);
} else {
if (configKey.includes("_")) {
// This is an icon-specific color
const [entityId, colorType] = configKey.split("_");
this._customIcons = {
...this._customIcons,
[entityId]: {
...this._customIcons[entityId],
[colorType]: color,
},
};
this._updateCustomIconsConfig();
} else {
// This is a global color
this.config = {
...this.config,
[configKey]: color,
};
this._updateSingleColor(configKey, color);
}
}
this.requestUpdate();
}
_getIconColor(entityId, colorType) {
const customIcon = this._customIcons[entityId];
if (customIcon && customIcon[`${colorType}Color`]) {
return customIcon[`${colorType}Color`];
}
if (colorType === "active") {
return UltraVehicleCardEditor._getComputedColor("--primary-color");
}
return UltraVehicleCardEditor._getComputedColor("--primary-text-color");
}
// Add this method to hide/show image height inputs
_updateImageHeightVisibility() {
const mainImageHeightInput = this.shadowRoot.querySelector('#main-image-height');
const chargingImageHeightInput = this.shadowRoot.querySelector('#charging-image-height');
const engineOnImageHeightInput = this.shadowRoot.querySelector('#engine-on-image-height');
if (mainImageHeightInput) {
mainImageHeightInput.style.display = this.config.image_url_type === 'none' ? 'none' : 'block';
}
if (chargingImageHeightInput) {
chargingImageHeightInput.style.display = this.config.charging_image_url_type === 'none' ? 'none' : 'block';
}
if (engineOnImageHeightInput) {
engineOnImageHeightInput.style.display = this.config.engine_on_image_url_type === 'none' ? 'none' : 'block';
}
}
// Call this method in the updated lifecycle method
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('config')) {
this._updateImageHeightVisibility();
}
}
// Update the image type change handlers
_onMainImageTypeChange(e) {
this._handleImageSourceChange('image_url', e.target.value);
}
_onChargingImageTypeChange(e) {
this._handleImageSourceChange('charging_image_url', e.target.value);
}
_onEngineOnImageTypeChange(e) {
this._handleImageSourceChange('engine_on_image_url', e.target.value);
}
_handleImageSourceChange(configKey, newType) {
this._updateConfig(`${configKey}_type`, newType);
if (newType === 'none') {
this._updateConfig(configKey, '');
this._updateConfig(`${configKey.replace('_url', '_entity')}`, '');
} else if (newType === 'entity') {
this._updateConfig(configKey, '');
} else if (newType === 'image') {
this._updateConfig(`${configKey.replace('_url', '_entity')}`, '');
if (this.config[configKey] === DEFAULT_IMAGE_URL) {
this._updateConfig(configKey, '');
}
}
this._updateImageHeightVisibility();
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
}
_valueChanged(ev) {
if (!this.config) {
return;
}
const target = ev.target;
const value = target.value;
const configValue = target.configValue;
if (configValue) {
if (configValue === 'show_engine_animation') {
this._showEngineAnimation = target.checked;
this._updateConfig(configValue, this._showEngineAnimation);
} else if (configValue === 'show_charging_animation') {
this._showChargingAnimation = target.checked;
this._updateConfig(configValue, this._showChargingAnimation);
} else if (configValue === 'mainImageHeight' || configValue === 'chargingImageHeight' || configValue === 'engineOnImageHeight') {
// For image height inputs, append 'px' to the value if it's not already there
const newValue = value.endsWith('px') ? value : `${value}px`;
this._updateConfig(configValue, newValue);
// Force a full update of the card
this._fireEvent('config-changed', { config: this.config });
} else if (configValue === 'image_url' || configValue === 'charging_image_url' || configValue === 'engine_on_image_url') {
this._updateConfig(configValue, value);
} else {
this._updateConfig(configValue, target.checked !== undefined ? target.checked : value);
}
}
}
_entityFilterChanged(e, configKey) {
this[`_${configKey}Filter`] = e.target.value;
this.requestUpdate();
}
_isColorDark(color) {
const rgb = this._hexToRgb(color);
if (!rgb) return false;
const [r, g, b] = rgb.split(',').map(Number);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128;
}
_hexToRgb(hex) {
if (!hex) return null;
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` : null;
}
_updateIconBackgroundColor(isDarkBackground) {
const iconBackgroundColor = isDarkBackground ? '#ffffff' : '#000000';
this.style.setProperty('--uvc-icon-background', iconBackgroundColor);
}
firstUpdated() {
super.firstUpdated();
this.addEventListener('click', this._handleEditorClick);
this._addDialogClosePrevention();
}
_addDialogClosePrevention() {
window.addEventListener('dialog-closed', this._preventDialogClose, true);
}
_removeDialogClosePrevention() {
window.removeEventListener('dialog-closed', this._preventDialogClose, true);
}
_preventDialogClose(e) {
if (e.target.tagName === 'HA-DIALOG') {
e.preventDefault();
e.stopPropagation();
}
}
_handleEditorClick(e) {
e.stopPropagation();
}
_handleStateConfigChange(e) {
const { config, entityId, stateType, attributeValue } = e.detail;
let newConfig = { ...this.config, custom_icons: {...this.config?.custom_icons, [entityId]: {...this.config?.custom_icons?.[entityId]} }};
newConfig.custom_icons[entityId][`${stateType}State`] = config[`${stateType}State`];
if (config[`${stateType}State`].startsWith('attribute:') && attributeValue) {
newConfig.custom_icons[entityId][`${stateType}State`] += `:${attributeValue}`;
}
this.config = newConfig;
this.configChanged(this.config);
}
_titleChanged(ev) {
const newTitle = ev.target.value;
this._updateConfig("title", newTitle);
}
_showTitleToggleChanged(ev) {
const showTitle = ev.target.checked;
this._updateConfig("showTitle", showTitle);
}
_updateConfig(key, value) {
if (typeof key === 'object') {
this.config = { ...this.config, ...key };
} else {
this.config = { ...this.config, [key]: value };
}
this.configChanged(this.config);
this.requestUpdate();
}
_toggleFormattedEntities(e) {
const useFormattedEntities = e.target.checked;
this._updateConfig("useFormattedEntities", useFormattedEntities);
this._fireEvent("config-changed", { config: this.config });
}
_updateIconSizesConfig() {
this.config = {
...this.config,
icon_sizes: this._iconSizes,
};
this.configChanged(this.config);
}
_setNoIcon(entityId, iconType) {
this._customIcons = {
...this._customIcons,
[entityId]: {
...this._customIcons[entityId],
[iconType]: "no-icon",
},
};
this._updateCustomIconsConfig();
this.requestUpdate();
}
_clearIcon(entityId, iconType) {
if (this._customIcons[entityId]) {
const { [iconType]: _, ...rest } = this._customIcons[entityId];
if (Object.keys(rest).length === 0) {
const { [entityId]: __, ...restIcons } = this._customIcons;
this._customIcons = restIcons;
} else {
this._customIcons = {
...this._customIcons,
[entityId]: rest,
};
}
this._updateCustomIconsConfig();
}
}
_getDefaultColorAsHex() {
const defaultColor = getComputedStyle(document.documentElement)
.getPropertyValue("--uvc-info-text-color")
.trim();
if (defaultColor.startsWith("#")) {
return defaultColor;
} else if (defaultColor.startsWith("rgb")) {
const rgb = defaultColor.match(/\d+/g);
return `#${parseInt(rgb[0]).toString(16).padStart(2, "0")}${parseInt(
rgb[1]
)
.toString(16)
.padStart(2, "0")}${parseInt(rgb[2]).toString(16).padStart(2, "0")}`;
}
return "Default"; // Fallback color if unable to determine
}
setDefaultValues() {
if (!this.config.image) {
this._updateConfig("image", DEFAULT_IMAGE_URL);
}
if (!this.config.charging_image) {
this._updateConfig("charging_image", DEFAULT_IMAGE_URL);
}
}
_updateSingleColor(configKey, color) {
const event = new CustomEvent("config-changed", {
detail: { config: { ...this.config, [configKey]: color } },
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
}
static _getComputedColor(variable) {
const style = getComputedStyle(document.documentElement);
let value = style.getPropertyValue(variable).trim();
if (value.startsWith("#")) {
return this._expandHexColor(value);
} else if (value.startsWith("rgb")) {
// Handle both rgb and rgba
const parts = value.match(/[\d.]+/g);
if (parts.length >= 3) {
const r = parseInt(parts[0]);
const g = parseInt(parts[1]);
const b = parseInt(parts[2]);
const a = parts.length === 4 ? parseFloat(parts[3]) : 1;
if (a < 1) {
// Return rgba for transparent colors
return `rgba(${r}, ${g}, ${b}, ${a})`;
} else {
// Convert to hex for opaque colors
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
}
}
}
// Return the original value if it's not a recognized format
return value;
}
_renderIconColorPicker(label, entityId, iconType) {
const isActive = iconType === "active";
const defaultColor = isActive
? UltraVehicleCardEditor._getComputedColor("--primary-color")
: UltraVehicleCardEditor._getComputedColor("--primary-text-color");
const currentColor = UltraVehicleCardEditor._expandHexColor(
this.config.custom_icons[entityId]?.[`${iconType}Color`] || defaultColor
);
return html`
<div class="color-picker">
<label>${label}</label>
<div class="icon-grid-color-picker-wrapper">
<input
type="text"
.value="${currentColor}"
@input="${(e) => this._iconColorChanged(e, entityId, iconType)}"
class="hex-input"
style="background-color: ${currentColor}; color: ${this._getContrastYIQ(
currentColor
)};"
/>
<div class="color-preview" style="background-color: ${currentColor};">
<ha-icon
icon="mdi:palette"
style="color: ${this._getContrastYIQ(currentColor)};"
></ha-icon>
<input
type="color"
.value="${currentColor}"
@input="${(e) => this._iconColorChanged(e, entityId, iconType)}"
class="color-input"
/>
</div>
<ha-icon
class="reset-icon"
icon="mdi:refresh"
@click="${(e) => this._resetIconColor(e, entityId, iconType)}"
></ha-icon>
</div>
</div>
`;
}
// Update the image type change handlers
_onMainImageTypeChange(e) {
this._handleImageSourceChange('image_url', e.target.value);
}
_onChargingImageTypeChange(e) {
this._handleImageSourceChange('charging_image_url', e.target.value);
}
_onEngineOnImageTypeChange(e) {
this._handleImageSourceChange('engine_on_image_url', e.target.value);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this._handleEditorClick);
this._removeDialogClosePrevention();
window.removeEventListener('set-theme', this._themeChangeListener);
}
_onThemeChange() {
Object.keys(this._defaultColors).forEach((key) => {
if (!this._userChangedColors[key]) {
this._updateConfig(key, this._defaultColors[key]);
}
});
this.requestUpdate();
}
_resetAllColors() {
const defaultColors = {
cardTitleColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
cardBackgroundColor: UltraVehicleCardEditor._getComputedColor("--ha-card-background") || UltraVehicleCardEditor._getComputedColor("--card-background-color"),
barBackgroundColor: UltraVehicleCardEditor._getComputedColor("--secondary-text-color"),
barBorderColor: UltraVehicleCardEditor._getComputedColor("--secondary-text-color"),
barFillColor: UltraVehicleCardEditor._getComputedColor("--primary-color"),
limitIndicatorColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
infoTextColor: UltraVehicleCardEditor._getComputedColor("--secondary-text-color"),
carStateTextColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
rangeTextColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
percentageTextColor: UltraVehicleCardEditor._getComputedColor("--primary-text-color"),
};
Object.entries(defaultColors).forEach(([key, defaultValue]) => {
this._resetColor(key, defaultValue);
});
this.requestUpdate();
}
_resetAllIconColors() {
// Reset all icon colors to default
this._customIcons = Object.keys(this._customIcons).reduce((acc, entityId) => {
acc[entityId] = {
...this._customIcons[entityId],
activeColor: undefined,
inactiveColor: undefined
};
return acc;
}, {});
// Update the config
this._updateCustomIconsConfig();
// Remove the custom CSS properties
this.style.removeProperty('--uvc-icon-active');
this.style.removeProperty('--uvc-icon-inactive');
// Update the config to remove global icon colors
this.config = {
...this.config,
iconActiveColor: undefined,
iconInactiveColor: undefined
};
// Force a re-render of the card
this._fireEvent('config-changed', { config: this.config });
this.requestUpdate();
}
}
customElements.define("ultra-vehicle-card-editor", UltraVehicleCardEditor);