Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: improve state card performance #1

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Bubble Card is a minimalist and customizable card collection for Home Assistant

## Table of contents

**[`Installation`](#installation)** | **[`Configuration`](#configuration)** | **[`Pop-up`](#pop-up)** | **[`Horizontal buttons stack`](#horizontal-buttons-stack)** | **[`Button`](#button)** | **[`Custom button`](#custom-button)** | **[`Cover`](#cover)** | **[`Separator`](#separator)** | **[`Empty column`](#empty-column)** | **[`Actions`](#tap-double-tap-and-hold-actions)** | **[`Full example`](#full-example)** | **[`Styling`](#styling)** | **[`Conflicts`](#custom-components-conflicts)** | **[`Help`](#help)** | **[`Contribution`](#contribution)**
**[`Installation`](#installation)** | **[`Configuration`](#configuration)** | **[`Pop-up`](#pop-up)** | **[`Horizontal buttons stack`](#horizontal-buttons-stack)** | **[`Button`](#button)** | **[`Custom button`](#custom-button)** | **[`Cover`](#cover)** | **[`Separator`](#separator)** | **[`Empty column`](#empty-column)** | **[`State`](#state)** | **[`Actions`](#tap-double-tap-and-hold-actions)** | **[`Full example`](#full-example)** | **[`Styling`](#styling)** | **[`Conflicts`](#custom-components-conflicts)** | **[`Help`](#help)** | **[`Contribution`](#contribution)**


## Screenshots and features
Expand Down Expand Up @@ -101,7 +101,7 @@ Most options can be configured in the GUI editor, except for custom styles, cust
| Name | Type | Requirement | Supported options | Description |
| --- | --- | --- | --- | --- |
| `type` | string | **Required** | `custom:bubble-card` | Type of the card |
| `card_type` | string | **Required** | `button`, `cover`, `empty-column`, `horizontal-buttons-stack`, `pop-up` or `separator` | Type of the Bubble Card, see below |
| `card_type` | string | **Required** | `button`, `cover`, `empty-column`, `horizontal-buttons-stack`, `pop-up`, `separator` or `state` | Type of the Bubble Card, see below |
| `styles` | object list | Optional | Any CSS stylesheets | Allows you to customize your cards, see [styling](#styling) |
| `column_fix` | boolean or string | Optional | `true`, `false` (default) or a negative value like `-10` | Fix some issues with the dashboard layout, such as empty columns or misaligned cards. Add it in YAML to the **first** Bubble Card on your dashboard. You can also try to add a negative value to find the one that fit your dashboard. Then refresh the page. |

Expand Down Expand Up @@ -371,6 +371,36 @@ type: custom:bubble-card
card_type: empty-column
```

## State

This is a simple state display that could help you display any kind of data that don't have a specific action linked.

This is only available in YAML for now.

### Options

| Name | Type | Requirement | Supported options | Description |
| --- | --- | --- | --- | --- |
| `entity` | string | **Required** (and soon optional) | Any entity | An entity for the state of the button |
| `name` | string | Optional | Any string | A name for your button, if not defined it will display the entity name |
| `icon` | string | Optional | Any `mdi:` icon or a link to a square image | An icon for your button, if not defined it will display the entity icon or the `entity-picture` |
| `show_state` | boolean | Optional | `true` (default) or `false` | Show the state of your `entity` below its `name` |
| `tap_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the type of action on button click, if undefined, `more-info` will be used. |
| `double_tap_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the type of action on the button double click, if undefined, `more-info` will be used. |
| `hold_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the type of action on button hold, if undefined, `more-info` will be used. |

### Example

Here is an example of a button that toggles all the lights of a room and if you double tap or hold it, it will open a pop-up with all your other lights:

```yaml
type: custom:bubble-card
card_type: state
entity: sensor.kitchen_temperature
name: Kitchen
icon: mdi:thermometer
```

## Tap, double tap and hold actions

You can also use HA default tap actions, double tap actions and hold actions on the icons of the buttons, the pop-ups and the covers. This allows you to display the “more info” window by holding the icon and to turn on/off the lamp of a slider by a single tap for example.
Expand Down
6 changes: 6 additions & 0 deletions src/bubble-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { handleButton } from './cards/button.ts';
import { handleSeparator } from './cards/separator.ts';
import { handleCover } from './cards/cover.ts';
import { handleEmptyColumn } from './cards/empty-column.ts';
import { handleState } from './cards/state.ts';
import BubbleCardEditor from './editor/bubble-card-editor.ts';

let editor;
Expand Down Expand Up @@ -54,6 +55,11 @@ class BubbleCard extends HTMLElement {
case 'horizontal-buttons-stack' :
handleHorizontalButtonsStack(this);
break;

// Intitalize state card
case 'state':
handleState(this);
break;
}

checkResources(hass);
Expand Down
177 changes: 177 additions & 0 deletions src/cards/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { createElement, forwardHaptic, getIcon, getImage, getName, getState, tapFeedback } from '../tools/utils.ts';
import { addActions } from '../tools/tap-actions.ts';

function createStructure(context) {
context.elements = {};
context.elements.stateCardContainer = createElement('div', 'state-card-container');
context.elements.stateCard = createElement('div', 'state-card');
context.elements.nameContainer = createElement('div', 'name-container');
context.elements.iconContainer = createElement('div', 'icon-container');
context.elements.name = createElement('p', 'name');
context.elements.state = createElement('p', 'state');
context.elements.feedback = createElement('div', 'feedback-element');
context.elements.icon = createElement('ha-icon', 'icon');
context.elements.image = createElement('div', 'entity-picture');
context.elements.style = createElement('style');

context.elements.feedback.style.display = 'none';
context.elements.style.innerText = stateCardStyles;
context.elements.stateCard.addEventListener('click', () => tapFeedback(context), { passive: true });

addActions(context.elements.stateCard, context.config, context._hass, forwardHaptic);

context.elements.iconContainer.appendChild(context.elements.icon);
context.elements.iconContainer.appendChild(context.elements.image);
Comment on lines +23 to +24
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I place both image and icon in the DOM,but since one of them would be hidden, it's not a big deal on performance. It will avoid the need to handle the creation/removal of the dom nodes based on the state.
It's also the case of the feedback element and the state


context.elements.nameContainer.appendChild(context.elements.name);
context.elements.nameContainer.appendChild(context.elements.state);
context.elements.stateCard.appendChild(context.elements.iconContainer);
context.elements.stateCard.appendChild(context.elements.nameContainer);
context.elements.stateCard.appendChild(context.elements.feedback);

if (!context.editor) {
context.elements.stateCardContainer.appendChild(context.elements.stateCard);
}

context.content.innerHTML = '';
context.content.appendChild(context.elements.stateCardContainer);
context.content.appendChild(context.elements.style);

context.cardType = 'state';
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will let the code know that the card has already been initialized.

}

function updateValues(context) {
const name = getName(context);
const state = getState(context);
const showState = context.config.show_state !== false;
const formattedState = context._hass.formatEntityState(context._hass.states[context.config.entity]);

if (state === 'unavailable') {
context.card.style.opacity = '0.5';
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than having it in the style append, it's manually updated inline here so the execution time would be better.

} else {
context.card.style.opacity = '1';
}

if (name !== context.elements.name.innerText) {
context.elements.name.innerText = name;
}
Comment on lines +55 to +57
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only place where I check for the difference: changing a style will not trigger any change in the DOM if the value is the same but innerText or innerHTML will


if (showState && formattedState !== context.elements.state.innerText) {
context.elements.state.style.display = '';
context.elements.state.innerText = formattedState;
context.elements.nameContainer.style.display = 'block';
context.elements.nameContainer.style.lineHeight = '4px';
}
if (showState === false) {
context.elements.state.style.display = 'none';
context.elements.nameContainer.style.display = 'inline-flex';
context.elements.nameContainer.style.lineHeight = '16px';
}

const icon = getIcon(context);
const image = getImage(context);
if (icon !== '') {
context.elements.icon.icon = icon;
context.elements.icon.style.display = '';
context.elements.image.style.display = 'none';
} else if (image !== '') {
context.elements.image.style.backgroundImage = 'url(' + image + ')';
context.elements.icon.style.display = 'none';
context.elements.image.style.display = '';
} else {
context.elements.icon.style.display = 'none';
context.elements.image.style.display = 'none';
}
}

export function handleState(context) {
if (context.cardType !== 'state') {
createStructure(context)
}

updateValues(context);
}

const stateCardStyles = `
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style is now only static string. This way, it's only included once when the component is mounted

ha-card {
margin-top: 0 !important;
background: none !important;
}

.state-card-container {
width: 100%;
height: 50px;
z-index: 0;
background-color: var(--background-color-2, var(--secondary-background-color));
border-radius: 25px;
mask-image: radial-gradient(white, black);
-webkit-transform: translateZ(0);
overflow: hidden;
}

.state-card {
display: inline-flex;
position: absolute;
height: 100%;
width: 100%;
transition: background-color 1.5s;
background-color: rgba(0, 0, 0, 0);
cursor: pointer !important;
}

.state-card .icon-container {
display: flex;
flex-wrap: wrap;
align-content: center;
justify-content: center;
z-index: 1;
min-width: 38px;
min-height: 38px;
margin: 6px;
border-radius: 50%;
background-color: var(--card-background-color, var(--ha-card-background));
overflow: hidden;
}

.state-card ha-icon {
display: flex;
width: 22px;
}

.state-card .entity-picture {
background-size: cover;
background-position: center;
height: 100%;
width: 100%;
}

.state-card .name-container {
position: relative;
margin-left: 4px;
z-index: 1;
font-weight: 600;
align-items: center;
padding-right: 16px;
}

.state-card .state {
font-size: 12px;
opacity: 0.7;
}

.state-card .feedback-element {
position: absolute;
top: 0;
left: 0;
opacity: 0;
width: 100%;
height: 100%;
background-color: rgb(0, 0, 0);
}

@keyframes tap-feedback {
0% { transform: translateX(-100%); opacity: 0; }
64% { transform: translateX(0); opacity: 0.1; }
100% { transform: translateX(100%); opacity: 0; }
}
`;
36 changes: 35 additions & 1 deletion src/editor/bubble-card-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ export default class BubbleCardEditor extends LitElement {
}

get _show_state() {
return this._config.show_state || false;
const defaultState = this._config.card_type === 'state' ? true : false;
return this._config.show_state || defaultState;
}

get _hide_backdrop() {
Expand Down Expand Up @@ -208,6 +209,10 @@ export default class BubbleCardEditor extends LitElement {
{
'label': 'Separator',
'value': 'separator'
},
{
'label': 'State',
'value': 'state'
}
];

Expand Down Expand Up @@ -630,6 +635,35 @@ export default class BubbleCardEditor extends LitElement {
${this.makeVersion()}
</div>
`;
} else if (this._config.card_type === 'state') {
return html`
<div class="card-config">
${this.makeDropdown("Card type", "card_type", cardTypeList)}
<h3>State</h3>
<ha-alert alert-type="info">A card to display an entity state</ha-alert>
${this.makeDropdown(this._button_type !== 'slider' ? "Entity (toggle)" : "Entity (light or media_player)", "entity", allEntitiesList)}
<ha-formfield .label="Optional - Show entity state">
<ha-switch
aria-label="Optional - Show entity state"
.checked=${this._show_state}
.configValue="${"show_state"}"
@change=${this._valueChanged}
></ha-switch>
<div class="mdc-form-field">
<label class="mdc-label">Optional - Show entity state</label>
</div>
</ha-formfield>
<ha-textfield
label="Optional - Name"
.value="${this._name}"
.configValue="${"name"}"
@input="${this._valueChanged}"
style="width: 100%;"
></ha-textfield>
${this.makeDropdown("Optional - Icon", "icon")}
${this.makeVersion()}
</div>
`;
} else if (!this._config.card_type) {
return html`
<div class="card-config">
Expand Down
4 changes: 2 additions & 2 deletions src/tools/tap-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ export function sendActionEvent(element, config, action) {
action: "more-info"
},
double_tap_action: config.double_tap_action || {
action: "toggle"
action: config.card_type === "state" ? "more-info" : "toggle"
},
hold_action: config.hold_action || {
action: "toggle"
action: config.card_type === "state" ? "more-info" : "toggle"
}
};

Expand Down
49 changes: 49 additions & 0 deletions src/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,58 @@ export function toggleEntity(hass, entityId) {
});
}

export function tapFeedback(context) {
if (context.elements.feedback === undefined) return;

forwardHaptic("success");

context.elements.feedback.style.display = '';
context.elements.feedback.style.animation = 'tap-feedback .5s';

setTimeout(() => {
context.elements.feedback.style.animation = 'none';
context.elements.feedback.style.display = 'none';
}, 500);
}

export function getIcon(context) {
const entityIcon = context._hass.states[context.config.entity]?.attributes.icon;
const configIcon = context.config.icon;

if (configIcon) return configIcon;
if (entityIcon) return entityIcon;

return '';
}

export function getImage(context) {
const entityImage = context._hass.states[context.config.entity]?.attributes.entity_picture;

if (entityImage) return entityImage;

return '';
}

export function getName(context) {
const configName = context.config.name;
const entityName = context._hass.states[context.config.entity]?.attributes.friendly_name

if (configName) return configName;
if (entityName) return entityName;

return '';
}

export function getState(context) {
return context._hass.states[context.config.entity]?.state ?? '';
}

export function createElement(tag, className = '') {
const element = document.createElement(tag);

if (className !== '') {
element.classList.add(className);
}

return element;
}