diff --git a/src/common/string/sequence_matching.ts b/src/common/string/sequence_matching.ts new file mode 100644 index 0000000000..3898ec667f --- /dev/null +++ b/src/common/string/sequence_matching.ts @@ -0,0 +1,29 @@ +/** + * Determine whether a sequence of letters exists in another string, + * in that order, allowing for skipping. Ex: "chdr" exists in "chandelier") + * + * filter => sequence of letters + * word => Word to check for sequence + * + * return true if word contains sequence. Otherwise false. + */ +export const fuzzySequentialMatch = (filter: string, word: string) => { + if (filter === "") { + return true; + } + + for (let i = 0; i <= filter.length; i++) { + const pos = word.indexOf(filter[0]); + + if (pos < 0) { + return false; + } + + const newWord = word.substring(pos + 1); + const newFilter = filter.substring(1); + + return fuzzySequentialMatch(newFilter, newWord); + } + + return true; +}; diff --git a/src/dialogs/quick-open/ha-quick-open-dialog.ts b/src/dialogs/quick-open/ha-quick-open-dialog.ts new file mode 100644 index 0000000000..8eebf70457 --- /dev/null +++ b/src/dialogs/quick-open/ha-quick-open-dialog.ts @@ -0,0 +1,225 @@ +import "../../components/ha-header-bar"; +import "@polymer/paper-input/paper-input"; +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-list/mwc-list"; +import { + css, + customElement, + html, + internalProperty, + LitElement, + property, +} from "lit-element"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-dialog"; +import { haStyleDialog } from "../../resources/styles"; +import { HomeAssistant, ServiceCallRequest } from "../../types"; +import { PolymerChangedEvent } from "../../polymer-types"; +import { fuzzySequentialMatch } from "../../common/string/sequence_matching"; +import { componentsWithService } from "../../common/config/components_with_service"; +import { domainIcon } from "../../common/entity/domain_icon"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { domainToName } from "../../data/integration"; +import { QuickOpenDialogParams } from "./show-dialog-quick-open"; +import { HassEntity } from "home-assistant-js-websocket"; +import { compare } from "../../common/string/compare"; +import memoizeOne from "memoize-one"; + +interface CommandItem extends ServiceCallRequest { + text: string; +} + +@customElement("ha-quick-open-dialog") +export class QuickOpenDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @internalProperty() private _commandItems: CommandItem[] = []; + + @internalProperty() private _itemFilter = ""; + + @internalProperty() private _opened = false; + + @internalProperty() private _commandMode = false; + + public async showDialog(params: QuickOpenDialogParams) { + this._commandMode = params.commandMode || false; + this._opened = true; + this._commandItems = this._generateCommandItems(); + } + + public closeDialog() { + this._opened = false; + this._itemFilter = ""; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._opened) { + return html``; + } + + return html` + + ${this._itemFilter}` : this._itemFilter} + > + + ${this._commandMode + ? this.renderCommandsList(this._itemFilter) + : this.renderEntityList(this._itemFilter)} + + + `; + } + + protected renderCommandsList = memoizeOne((filter) => { + const items = this._filterCommandItems(this._commandItems, filter); + + return html` + ${items.map( + ({ text, domain, service, serviceData }) => html` + + + ${text} + + ` + )} + `; + }); + + protected renderEntityList = memoizeOne((filter) => { + const entities = this._filterEntityItems( + Object.keys(this.hass.states), + filter + ); + + return html` + ${entities.map((entity_id) => { + const domain = computeDomain(entity_id); + return html` + + + ${entity_id} + + `; + })} + `; + }); + + private _entityFilterChanged(ev: PolymerChangedEvent) { + const newFilter = ev.detail.value; + + if (newFilter.startsWith(">")) { + this._commandMode = true; + this._itemFilter = newFilter.substring(1); + } else { + this._commandMode = false; + this._itemFilter = newFilter; + } + } + + private _generateCommandItems(): CommandItem[] { + const reloadableDomains = componentsWithService(this.hass, "reload").sort(); + + return reloadableDomains.map((domain) => ({ + text: + this.hass.localize(`ui.dialogs.quick-open.commands.reload.${domain}`) || + this.hass.localize( + "ui.dialogs.quick-open.commands.reload.reload", + "domain", + domainToName(this.hass.localize, domain) + ), + domain, + service: "reload", + })); + } + + private _filterCommandItems( + items: CommandItem[], + filter: string + ): CommandItem[] { + return items + .filter(({ text }) => + fuzzySequentialMatch(filter.toLowerCase(), text.toLowerCase()) + ) + .sort((itemA, itemB) => compare(itemA.text, itemB.text)); + } + + private _filterEntityItems( + entity_ids: HassEntity["entity_id"][], + filter: string + ): HassEntity["entity_id"][] { + return entity_ids + .filter((entity_id) => + fuzzySequentialMatch(filter.toLowerCase(), entity_id) + ) + .sort(); + } + + private async _executeCommand(ev: Event) { + const target = ev.currentTarget as any; + + await this.hass.callService( + target.domain, + target.service, + target.serviceData + ); + + this.closeDialog(); + } + + private _entityMoreInfo(ev: Event) { + ev.preventDefault(); + fireEvent(this, "hass-more-info", { + entityId: (ev.target as any).text, + }); + this.closeDialog(); + } + + static get styles() { + return [ + haStyleDialog, + css` + .heading { + padding: 20px 20px 0px; + } + + ha-dialog { + --dialog-z-index: 8; + --dialog-content-padding: 0px 24px 20px; + } + + @media (min-width: 800px) { + ha-dialog { + --mdc-dialog-max-width: 800px; + --mdc-dialog-min-width: 500px; + --dialog-surface-position: fixed; + --dialog-surface-top: 40px; + --mdc-dialog-max-height: calc(100% - 72px); + } + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-quick-open-dialog": QuickOpenDialog; + } +} diff --git a/src/dialogs/quick-open/show-dialog-quick-open.ts b/src/dialogs/quick-open/show-dialog-quick-open.ts new file mode 100644 index 0000000000..10b3b1932f --- /dev/null +++ b/src/dialogs/quick-open/show-dialog-quick-open.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../common/dom/fire_event"; + +export interface QuickOpenDialogParams { + entityFilter?: string; + commandMode?: boolean; +} + +export const loadQuickOpenDialog = () => + import(/* webpackChunkName: "quick-open-dialog" */ "./ha-quick-open-dialog"); + +export const showQuickOpenDialog = ( + element: HTMLElement, + dialogParams: QuickOpenDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "ha-quick-open-dialog", + dialogImport: loadQuickOpenDialog, + dialogParams, + }); +}; diff --git a/src/layouts/home-assistant.ts b/src/layouts/home-assistant.ts index b1f95abd52..1fa77f2670 100644 --- a/src/layouts/home-assistant.ts +++ b/src/layouts/home-assistant.ts @@ -17,9 +17,10 @@ import { import "./ha-init-page"; import "./home-assistant-main"; import { storeState } from "../util/ha-pref-storage"; +import QuickOpenMixin from "../state/quick-open-mixin"; @customElement("home-assistant") -export class HomeAssistantAppEl extends HassElement { +export class HomeAssistantAppEl extends QuickOpenMixin(HassElement) { @internalProperty() private _route?: Route; @internalProperty() private _error = false; diff --git a/src/state/quick-open-mixin.ts b/src/state/quick-open-mixin.ts new file mode 100644 index 0000000000..73856a03cc --- /dev/null +++ b/src/state/quick-open-mixin.ts @@ -0,0 +1,31 @@ +import type { Constructor, PropertyValues } from "lit-element"; +import { HassElement } from "./hass-element"; +import { + QuickOpenDialogParams, + showQuickOpenDialog, +} from "../dialogs/quick-open/show-dialog-quick-open"; + +declare global { + interface HASSDomEvents { + "hass-quick-open": QuickOpenDialogParams; + } +} + +export default >(superClass: T) => + class extends superClass { + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + + document.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.code === "KeyP" && e.metaKey) { + e.preventDefault(); + const eventParams: QuickOpenDialogParams = {}; + if (e.shiftKey) { + eventParams.commandMode = true; + } + + showQuickOpenDialog(this, eventParams); + } + }); + } + }; diff --git a/src/translations/en.json b/src/translations/en.json index 7a62f3ab1b..ad917f2bae 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -434,6 +434,44 @@ } }, "dialogs": { + "quick-open": { + "commands": { + "reload": { + "reload": "[%key:ui::panel::config::server_control::section::reloading::reload%]", + "core": "[%key:ui::panel::config::server_control::section::reloading::core%]", + "group": "[%key:ui::panel::config::server_control::section::reloading::group%]", + "automation": "[%key:ui::panel::config::server_control::section::reloading::automation%]", + "script": "[%key:ui::panel::config::server_control::section::reloading::script%]", + "scene": "[%key:ui::panel::config::server_control::section::reloading::scene%]", + "person": "[%key:ui::panel::config::server_control::section::reloading::person%]", + "zone": "[%key:ui::panel::config::server_control::section::reloading::zone%]", + "input_boolean": "[%key:ui::panel::config::server_control::section::reloading::input_boolean%]", + "input_text": "[%key:ui::panel::config::server_control::section::reloading::input_text%]", + "input_number": "[%key:ui::panel::config::server_control::section::reloading::input_number%]", + "input_datetime": "[%key:ui::panel::config::server_control::section::reloading::input_datetime%]", + "input_select": "[%key:ui::panel::config::server_control::section::reloading::input_select%]", + "template": "[%key:ui::panel::config::server_control::section::reloading::template%]", + "universal": "[%key:ui::panel::config::server_control::section::reloading::universal%]", + "rest": "[%key:ui::panel::config::server_control::section::reloading::rest%]", + "command_line": "[%key:ui::panel::config::server_control::section::reloading::command_line%]", + "filter": "[%key:ui::panel::config::server_control::section::reloading::filter%]", + "statistics": "[%key:ui::panel::config::server_control::section::reloading::statistics%]", + "generic": "[%key:ui::panel::config::server_control::section::reloading::generic%]", + "generic_thermostat": "[%key:ui::panel::config::server_control::section::reloading::generic_thermostat%]", + "homekit": "[%key:ui::panel::config::server_control::section::reloading::homekit%]", + "min_max": "[%key:ui::panel::config::server_control::section::reloading::min_max%]", + "history_stats": "[%key:ui::panel::config::server_control::section::reloading::history_stats%]", + "trend": "[%key:ui::panel::config::server_control::section::reloading::trend%]", + "ping": "[%key:ui::panel::config::server_control::section::reloading::ping%]", + "filesize": "[%key:ui::panel::config::server_control::section::reloading::filesize%]", + "telegram": "[%key:ui::panel::config::server_control::section::reloading::telegram%]", + "smtp": "[%key:ui::panel::config::server_control::section::reloading::smtp%]", + "mqtt": "[%key:ui::panel::config::server_control::section::reloading::mqtt%]", + "rpi_gpio": "[%key:ui::panel::config::server_control::section::reloading::rpi_gpio%]" + } + }, + "filter_placeholder": "Entity Filter" + }, "voice_command": { "did_not_hear": "Home Assistant did not hear anything", "found": "I found the following for you:", diff --git a/src/types.ts b/src/types.ts index c6b90056ea..c277a60093 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,6 +206,12 @@ export interface ServiceCallResponse { context: Context; } +export interface ServiceCallRequest { + domain: string; + service: string; + serviceData?: { [key: string]: any }; +} + export interface HomeAssistant { auth: Auth & { external?: ExternalMessaging }; connection: Connection; @@ -239,9 +245,9 @@ export interface HomeAssistant { userData?: CoreFrontendUserData | null; hassUrl(path?): string; callService( - domain: string, - service: string, - serviceData?: { [key: string]: any } + domain: ServiceCallRequest["domain"], + service: ServiceCallRequest["service"], + serviceData?: ServiceCallRequest["serviceData"] ): Promise; callApi( method: "GET" | "POST" | "PUT" | "DELETE",