Highlight matched letters in quick bar

This commit is contained in:
Donnie 2021-03-25 08:43:24 -07:00
parent 6de8b4e35f
commit 56d4fbab86
6 changed files with 1519 additions and 590 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,399 @@
export enum EntityTouch {
None = 0,
AsOld = 1,
AsNew = 2,
}
interface Item<K, V> {
previous: Item<K, V> | undefined;
next: Item<K, V> | undefined;
key: K;
value: V;
}
export class LinkedMap<K, V> implements Map<K, V> {
readonly [Symbol.toStringTag] = "LinkedMap";
private _map: Map<K, Item<K, V>>;
private _head: Item<K, V> | undefined;
private _tail: Item<K, V> | undefined;
private _size: number;
private _state: number;
constructor() {
this._map = new Map<K, Item<K, V>>();
this._head = undefined;
this._tail = undefined;
this._size = 0;
this._state = 0;
}
clear(): void {
this._map.clear();
this._head = undefined;
this._tail = undefined;
this._size = 0;
this._state++;
}
isEmpty(): boolean {
return !this._head && !this._tail;
}
get size(): number {
return this._size;
}
get first(): V | undefined {
return this._head?.value;
}
get last(): V | undefined {
return this._tail?.value;
}
has(key: K): boolean {
return this._map.has(key);
}
get(key: K, touch: EntityTouch = EntityTouch.None): V | undefined {
const item = this._map.get(key);
if (!item) {
return undefined;
}
if (touch !== EntityTouch.None) {
this.touch(item, touch);
}
return item.value;
}
set(key: K, value: V, touch: EntityTouch = EntityTouch.None): this {
let item = this._map.get(key);
if (item) {
item.value = value;
if (touch !== EntityTouch.None) {
this.touch(item, touch);
}
} else {
item = { key, value, next: undefined, previous: undefined };
switch (touch) {
case EntityTouch.None:
this.addItemLast(item);
break;
case EntityTouch.AsOld:
this.addItemFirst(item);
break;
case EntityTouch.AsNew:
this.addItemLast(item);
break;
default:
this.addItemLast(item);
break;
}
this._map.set(key, item);
this._size++;
}
return this;
}
delete(key: K): boolean {
return !!this.remove(key);
}
remove(key: K): V | undefined {
const item = this._map.get(key);
if (!item) {
return undefined;
}
this._map.delete(key);
this.removeItem(item);
this._size--;
return item.value;
}
shift(): V | undefined {
if (!this._head && !this._tail) {
return undefined;
}
if (!this._head || !this._tail) {
throw new Error("Invalid list");
}
const item = this._head;
this._map.delete(item.key);
this.removeItem(item);
this._size--;
return item.value;
}
forEach(
callbackfn: (value: V, key: K, map: LinkedMap<K, V>) => void,
thisArg?: any
): void {
const state = this._state;
let current = this._head;
while (current) {
if (thisArg) {
callbackfn.bind(thisArg)(current.value, current.key, this);
} else {
callbackfn(current.value, current.key, this);
}
if (this._state !== state) {
throw new Error(`LinkedMap got modified during iteration.`);
}
current = current.next;
}
}
keys(): IterableIterator<K> {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const map = this;
const state = this._state;
let current = this._head;
const iterator: IterableIterator<K> = {
[Symbol.iterator]() {
return iterator;
},
next(): IteratorResult<K> {
if (map._state !== state) {
throw new Error(`LinkedMap got modified during iteration.`);
}
if (current) {
const result = { value: current.key, done: false };
current = current.next;
return result;
}
return { value: undefined, done: true };
},
};
return iterator;
}
values(): IterableIterator<V> {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const map = this;
const state = this._state;
let current = this._head;
const iterator: IterableIterator<V> = {
[Symbol.iterator]() {
return iterator;
},
next(): IteratorResult<V> {
if (map._state !== state) {
throw new Error(`LinkedMap got modified during iteration.`);
}
if (current) {
const result = {
value: current.value,
done: false,
};
current = current.next;
return result;
}
return { value: undefined, done: true };
},
};
return iterator;
}
entries(): IterableIterator<[K, V]> {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const map = this;
const state = this._state;
let current = this._head;
const iterator: IterableIterator<[K, V]> = {
[Symbol.iterator]() {
return iterator;
},
next(): IteratorResult<[K, V]> {
if (map._state !== state) {
throw new Error(`LinkedMap got modified during iteration.`);
}
if (current) {
const result: IteratorResult<[K, V]> = {
value: [current.key, current.value],
done: false,
};
current = current.next;
return result;
}
return {
value: undefined,
done: true,
};
},
};
return iterator;
}
[Symbol.iterator](): IterableIterator<[K, V]> {
return this.entries();
}
protected trimOld(newSize: number) {
if (newSize >= this.size) {
return;
}
if (newSize === 0) {
this.clear();
return;
}
let current = this._head;
let currentSize = this.size;
while (current && currentSize > newSize) {
this._map.delete(current.key);
current = current.next;
currentSize--;
}
this._head = current;
this._size = currentSize;
if (current) {
current.previous = undefined;
}
this._state++;
}
private addItemFirst(item: Item<K, V>): void {
// First time Insert
if (!this._head && !this._tail) {
this._tail = item;
} else if (!this._head) {
throw new Error("Invalid list");
} else {
item.next = this._head;
this._head.previous = item;
}
this._head = item;
this._state++;
}
private addItemLast(item: Item<K, V>): void {
// First time Insert
if (!this._head && !this._tail) {
this._head = item;
} else if (!this._tail) {
throw new Error("Invalid list");
} else {
item.previous = this._tail;
this._tail.next = item;
}
this._tail = item;
this._state++;
}
private removeItem(item: Item<K, V>): void {
if (item === this._head && item === this._tail) {
this._head = undefined;
this._tail = undefined;
} else if (item === this._head) {
// This can only happend if size === 1 which is handle
// by the case above.
if (!item.next) {
throw new Error("Invalid list");
}
item.next.previous = undefined;
this._head = item.next;
} else if (item === this._tail) {
// This can only happend if size === 1 which is handle
// by the case above.
if (!item.previous) {
throw new Error("Invalid list");
}
item.previous.next = undefined;
this._tail = item.previous;
} else {
const next = item.next;
const previous = item.previous;
if (!next || !previous) {
throw new Error("Invalid list");
}
next.previous = previous;
previous.next = next;
}
item.next = undefined;
item.previous = undefined;
this._state++;
}
private touch(item: Item<K, V>, touch: EntityTouch): void {
if (!this._head || !this._tail) {
throw new Error("Invalid list");
}
if (touch !== EntityTouch.AsOld && touch !== EntityTouch.AsNew) {
return;
}
if (touch === EntityTouch.AsOld) {
if (item === this._head) {
return;
}
const next = item.next;
const previous = item.previous;
// Unlink the item
if (item === this._tail) {
// previous must be defined since item was not head but is tail
// So there are more than on item in the map
previous!.next = undefined;
this._tail = previous;
} else {
// Both next and previous are not undefined since item was neither head nor tail.
next!.previous = previous;
previous!.next = next;
}
// Insert the node at head
item.previous = undefined;
item.next = this._head;
this._head.previous = item;
this._head = item;
this._state++;
} else if (touch === EntityTouch.AsNew) {
if (item === this._tail) {
return;
}
const next = item.next;
const previous = item.previous;
// Unlink the item.
if (item === this._head) {
// next must be defined since item was not tail but is head
// So there are more than on item in the map
next!.previous = undefined;
this._head = next;
} else {
// Both next and previous are not undefined since item was neither head nor tail.
next!.previous = previous;
previous!.next = next;
}
item.next = undefined;
item.previous = this._tail;
this._tail.next = item;
this._tail = item;
this._state++;
}
}
toJSON(): [K, V][] {
const data: [K, V][] = [];
this.forEach((value, key) => {
data.push([key, value]);
});
return data;
}
fromJSON(data: [K, V][]): void {
this.clear();
for (const [key, value] of data) {
this.set(key, value);
}
}
}

View File

@ -0,0 +1,51 @@
import { EntityTouch, LinkedMap } from "./linked-map";
export class LRUCache<K, V> extends LinkedMap<K, V> {
private _limit: number;
private _ratio: number;
constructor(limit: number, ratio = 1) {
super();
this._limit = limit;
this._ratio = Math.min(Math.max(0, ratio), 1);
}
get limit(): number {
return this._limit;
}
set limit(limit: number) {
this._limit = limit;
this.checkTrim();
}
get ratio(): number {
return this._ratio;
}
set ratio(ratio: number) {
this._ratio = Math.min(Math.max(0, ratio), 1);
this.checkTrim();
}
get(key: K, touch: EntityTouch = EntityTouch.AsNew): V | undefined {
return super.get(key, touch);
}
peek(key: K): V | undefined {
return super.get(key, EntityTouch.None);
}
set(key: K, value: V): this {
super.set(key, value, EntityTouch.AsNew);
this.checkTrim();
return this;
}
private checkTrim() {
if (this.size > this._limit) {
this.trimOld(Math.round(this._limit * this._ratio));
}
}
}

View File

@ -1,4 +1,4 @@
import { fuzzyScore } from "./filter"; import { createMatches, FuzzyScore, fuzzyScore } from "./filter";
/** /**
* Determine whether a sequence of letters exists in another string, * Determine whether a sequence of letters exists in another string,
@ -11,7 +11,8 @@ import { fuzzyScore } from "./filter";
*/ */
export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
let topScore = 0; let topScore = Number.NEGATIVE_INFINITY;
let topScores: FuzzyScore | undefined;
for (const word of words) { for (const word of words) {
const scores = fuzzyScore( const scores = fuzzyScore(
@ -31,19 +32,29 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
// The VS Code implementation of filter treats a score of "0" as just barely a match // The VS Code implementation of filter treats a score of "0" as just barely a match
// But we will typically use this matcher in a .filter(), which interprets 0 as a failure. // But we will typically use this matcher in a .filter(), which interprets 0 as a failure.
// By shifting all scores up by 1, we allow "0" matches, while retaining score precedence // By shifting all scores up by 1, we allow "0" matches, while retaining score precedence
const score = scores[0] + 1; const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) { if (score > topScore) {
topScore = score; topScore = score;
topScores = scores;
} }
} }
return topScore;
if (topScore === Number.NEGATIVE_INFINITY) {
return undefined;
}
return {
score: topScore,
decoratedText: getDecoratedText(filter, words[0]), // Need to change this to account for any N words
};
}; };
export interface ScorableTextItem { export interface ScorableTextItem {
score?: number; score?: number;
filterText: string; text: string;
altText?: string; altText?: string;
decoratedText?: string;
} }
type FuzzyFilterSort = <T extends ScorableTextItem>( type FuzzyFilterSort = <T extends ScorableTextItem>(
@ -54,13 +65,45 @@ type FuzzyFilterSort = <T extends ScorableTextItem>(
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => {
return items return items
.map((item) => { .map((item) => {
item.score = item.altText const match = item.altText
? fuzzySequentialMatch(filter, item.filterText, item.altText) ? fuzzySequentialMatch(filter, item.text, item.altText)
: fuzzySequentialMatch(filter, item.filterText); : fuzzySequentialMatch(filter, item.text);
item.score = match?.score;
item.decoratedText = match?.decoratedText;
return item; return item;
}) })
.filter((item) => item.score !== undefined && item.score > 0) .filter((item) => item.score !== undefined)
.sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) => .sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) =>
scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0 scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0
); );
}; };
export const getDecoratedText = (pattern: string, word: string) => {
const r = fuzzyScore(
pattern,
pattern.toLowerCase(),
0,
word,
word.toLowerCase(),
0,
true
);
if (r) {
const matches = createMatches(r);
let actualWord = "";
let pos = 0;
for (const match of matches) {
actualWord += word.substring(pos, match.start);
actualWord +=
"^" + word.substring(match.start, match.end).split("").join("^");
pos = match.end;
}
actualWord += word.substring(pos);
console.log(actualWord);
return actualWord;
}
return undefined;
};

View File

@ -3,7 +3,7 @@ import type { List } from "@material/mwc-list/mwc-list";
import { SingleSelectedEvent } from "@material/mwc-list/mwc-list-foundation"; import { SingleSelectedEvent } from "@material/mwc-list/mwc-list-foundation";
import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list-item";
import type { ListItem } from "@material/mwc-list/mwc-list-item"; import type { ListItem } from "@material/mwc-list/mwc-list-item";
import { mdiConsoleLine, mdiEarth, mdiReload, mdiServerNetwork } from "@mdi/js"; import { mdiConsoleLine } from "@mdi/js";
import { import {
css, css,
customElement, customElement,
@ -13,6 +13,7 @@ import {
property, property,
PropertyValues, PropertyValues,
query, query,
TemplateResult,
} from "lit-element"; } from "lit-element";
import { ifDefined } from "lit-html/directives/if-defined"; import { ifDefined } from "lit-html/directives/if-defined";
import { styleMap } from "lit-html/directives/style-map"; import { styleMap } from "lit-html/directives/style-map";
@ -36,7 +37,7 @@ import "../../components/ha-circular-progress";
import "../../components/ha-dialog"; import "../../components/ha-dialog";
import "../../components/ha-header-bar"; import "../../components/ha-header-bar";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
import { getPanelNameTranslationKey } from "../../data/panel"; import { getPanelIcon, getPanelNameTranslationKey } from "../../data/panel";
import { PageNavigation } from "../../layouts/hass-tabs-subpage"; import { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config"; import { configSections } from "../../panels/config/ha-panel-config";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
@ -46,44 +47,31 @@ import {
showConfirmationDialog, showConfirmationDialog,
} from "../generic/show-dialog-box"; } from "../generic/show-dialog-box";
import { QuickBarParams } from "./show-dialog-quick-bar"; import { QuickBarParams } from "./show-dialog-quick-bar";
import "../../components/ha-chip";
const DEFAULT_NAVIGATION_ICON = "hass:arrow-right-circle";
const DEFAULT_SERVER_ICON = "hass:server";
interface QuickBarItem extends ScorableTextItem { interface QuickBarItem extends ScorableTextItem {
primaryText: string; icon?: string;
iconPath?: string; iconPath?: string;
action(data?: any): void; action(data?: any): void;
} }
interface CommandItem extends QuickBarItem { interface QuickBarNavigationItem extends QuickBarItem {
categoryKey: "reload" | "navigation" | "server_control";
categoryText: string;
}
interface EntityItem extends QuickBarItem {
icon?: string;
}
const isCommandItem = (item: EntityItem | CommandItem): item is CommandItem => {
return (item as CommandItem).categoryKey !== undefined;
};
interface QuickBarNavigationItem extends CommandItem {
path: string; path: string;
} }
type NavigationInfo = PageNavigation & Pick<QuickBarItem, "primaryText">; interface NavigationInfo extends PageNavigation {
text: string;
}
type BaseNavigationCommand = Pick<
QuickBarNavigationItem,
"primaryText" | "path"
>;
@customElement("ha-quick-bar") @customElement("ha-quick-bar")
export class QuickBar extends LitElement { export class QuickBar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@internalProperty() private _commandItems?: CommandItem[]; @internalProperty() private _commandItems?: QuickBarItem[];
@internalProperty() private _entityItems?: EntityItem[]; @internalProperty() private _entityItems?: QuickBarItem[];
@internalProperty() private _items?: QuickBarItem[] = []; @internalProperty() private _items?: QuickBarItem[] = [];
@ -214,12 +202,6 @@ export class QuickBar extends LitElement {
} }
private _renderItem(item: QuickBarItem, index?: number) { private _renderItem(item: QuickBarItem, index?: number) {
return isCommandItem(item)
? this._renderCommandItem(item, index)
: this._renderEntityItem(item, index);
}
private _renderEntityItem(item: EntityItem, index?: number) {
return html` return html`
<mwc-list-item <mwc-list-item
.twoline=${Boolean(item.altText)} .twoline=${Boolean(item.altText)}
@ -232,19 +214,16 @@ export class QuickBar extends LitElement {
${item.iconPath ${item.iconPath
? html`<ha-svg-icon ? html`<ha-svg-icon
.path=${item.iconPath} .path=${item.iconPath}
class="entity"
slot="graphic" slot="graphic"
></ha-svg-icon>` ></ha-svg-icon>`
: html`<ha-icon : html`<ha-icon .icon=${item.icon} slot="graphic"></ha-icon>`}
.icon=${item.icon} ${item.decoratedText
class="entity" ? this._renderDecoratedText(item.decoratedText)
slot="graphic" : item.text}
></ha-icon>`}
<span>${item.primaryText}</span>
${item.altText ${item.altText
? html` ? html`
<span slot="secondary" class="item-text secondary" <span slot="secondary" class="item-text secondary"
>${item.altText}</span >${this._renderDecoratedText(item.altText)}</span
> >
` `
: null} : null}
@ -252,39 +231,18 @@ export class QuickBar extends LitElement {
`; `;
} }
private _renderCommandItem(item: CommandItem, index?: number) { private _renderDecoratedText(text: string) {
return html` const decoratedText: TemplateResult[] = [];
<mwc-list-item
.twoline=${Boolean(item.altText)}
.item=${item}
index=${ifDefined(index)}
>
<span>
<ha-chip
.label="${item.categoryText}"
hasIcon
class="command-category ${item.categoryKey}"
>
${item.iconPath
? html`<ha-svg-icon
.path=${item.iconPath}
slot="icon"
></ha-svg-icon>`
: ""}
${item.categoryText}</ha-chip
>
</span>
<span>${item.primaryText}</span> for (let i = 0; i < text.length; i++) {
${item.altText if (text[i] === "^") {
? html` decoratedText.push(html`<b>${text[i + 1]}</b>`);
<span slot="secondary" class="item-text secondary" i++;
>${item.altText}</span } else {
> decoratedText.push(html`${text[i]}`);
` }
: null} }
</mwc-list-item> return decoratedText;
`;
} }
private async processItemAndCloseDialog(item: QuickBarItem, index: number) { private async processItemAndCloseDialog(item: QuickBarItem, index: number) {
@ -374,112 +332,96 @@ export class QuickBar extends LitElement {
private _generateEntityItems(): QuickBarItem[] { private _generateEntityItems(): QuickBarItem[] {
return Object.keys(this.hass.states) return Object.keys(this.hass.states)
.map((entityId) => { .map((entityId) => ({
const primaryText = computeStateName(this.hass.states[entityId]); text: computeStateName(this.hass.states[entityId]),
return { altText: entityId,
primaryText, icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]),
filterText: primaryText, action: () => fireEvent(this, "hass-more-info", { entityId }),
altText: entityId, }))
icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), .sort((a, b) => compare(a.text.toLowerCase(), b.text.toLowerCase()));
action: () => fireEvent(this, "hass-more-info", { entityId }),
};
})
.sort((a, b) =>
compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase())
);
} }
private _generateCommandItems(): CommandItem[] { private _generateCommandItems(): QuickBarItem[] {
return [ return [
...this._generateReloadCommands(), ...this._generateReloadCommands(),
...this._generateServerControlCommands(), ...this._generateServerControlCommands(),
...this._generateNavigationCommands(), ...this._generateNavigationCommands(),
].sort((a, b) => ]
compare(a.filterText.toLowerCase(), b.filterText.toLowerCase()) .sort((a, b) => compare(a.text.toLowerCase(), b.text.toLowerCase()))
); .filter((item) => !item.text.includes("x"));
} }
private _generateReloadCommands(): CommandItem[] { private _generateReloadCommands(): QuickBarItem[] {
const reloadableDomains = componentsWithService(this.hass, "reload").sort(); const reloadableDomains = componentsWithService(this.hass, "reload").sort();
return reloadableDomains.map((domain) => { return reloadableDomains.map((domain) => ({
const categoryText = this.hass.localize( text:
`ui.dialogs.quick-bar.commands.types.reload`
);
const primaryText =
this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) ||
this.hass.localize( this.hass.localize(
"ui.dialogs.quick-bar.commands.reload.reload", "ui.dialogs.quick-bar.commands.reload.reload",
"domain", "domain",
domainToName(this.hass.localize, domain) domainToName(this.hass.localize, domain)
); ),
icon: domainIcon(domain),
return { action: () => this.hass.callService(domain, "reload"),
primaryText, }));
filterText: `${categoryText} ${primaryText}`,
action: () => this.hass.callService(domain, "reload"),
categoryKey: "reload",
iconPath: mdiReload,
categoryText,
};
});
} }
private _generateServerControlCommands(): CommandItem[] { private _generateServerControlCommands(): QuickBarItem[] {
const serverActions = ["restart", "stop"]; const serverActions = ["restart", "stop"];
return serverActions.map((action) => { return serverActions.map((action) =>
const categoryKey = "server_control"; this._generateConfirmationCommand(
const categoryText = this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
);
const primaryText = this.hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action",
"action",
this.hass.localize(
`ui.dialogs.quick-bar.commands.server_control.${action}`
)
);
return this._generateConfirmationCommand(
{ {
primaryText, text: this.hass.localize(
filterText: `${categoryText} ${primaryText}`, "ui.dialogs.quick-bar.commands.server_control.perform_action",
categoryKey, "action",
iconPath: mdiServerNetwork, this.hass.localize(
categoryText, `ui.dialogs.quick-bar.commands.server_control.${action}`
)
),
icon: DEFAULT_SERVER_ICON,
action: () => this.hass.callService("homeassistant", action), action: () => this.hass.callService("homeassistant", action),
}, },
this.hass.localize("ui.dialogs.generic.ok") this.hass.localize("ui.dialogs.generic.ok")
); )
}); );
} }
private _generateNavigationCommands(): CommandItem[] { private _generateNavigationCommands(): QuickBarItem[] {
const panelItems = this._generateNavigationPanelCommands(); const panelItems = this._generateNavigationPanelCommands();
const sectionItems = this._generateNavigationConfigSectionCommands(); const sectionItems = this._generateNavigationConfigSectionCommands();
return this._finalizeNavigationCommands([...panelItems, ...sectionItems]); return this._withNavigationActions([...panelItems, ...sectionItems]);
} }
private _generateNavigationPanelCommands(): BaseNavigationCommand[] { private _generateNavigationPanelCommands(): Omit<
QuickBarNavigationItem,
"action"
>[] {
return Object.keys(this.hass.panels) return Object.keys(this.hass.panels)
.filter((panelKey) => panelKey !== "_my_redirect") .filter((panelKey) => panelKey !== "_my_redirect")
.map((panelKey) => { .map((panelKey) => {
const panel = this.hass.panels[panelKey]; const panel = this.hass.panels[panelKey];
const translationKey = getPanelNameTranslationKey(panel); const translationKey = getPanelNameTranslationKey(panel);
const primaryText = const text = this.hass.localize(
this.hass.localize(translationKey) || panel.title || panel.url_path; "ui.dialogs.quick-bar.commands.navigation.navigate_to",
"panel",
this.hass.localize(translationKey) || panel.title || panel.url_path
);
return { return {
primaryText, text,
icon: getPanelIcon(panel) || DEFAULT_NAVIGATION_ICON,
path: `/${panel.url_path}`, path: `/${panel.url_path}`,
}; };
}); });
} }
private _generateNavigationConfigSectionCommands(): BaseNavigationCommand[] { private _generateNavigationConfigSectionCommands(): Partial<
QuickBarNavigationItem
>[] {
const items: NavigationInfo[] = []; const items: NavigationInfo[] = [];
for (const sectionKey of Object.keys(configSections)) { for (const sectionKey of Object.keys(configSections)) {
@ -503,12 +445,18 @@ export class QuickBar extends LitElement {
page: PageNavigation page: PageNavigation
): NavigationInfo | undefined { ): NavigationInfo | undefined {
if (page.component) { if (page.component) {
const caption = this.hass.localize( const shortCaption = this.hass.localize(
`ui.dialogs.quick-bar.commands.navigation.${page.component}` `ui.dialogs.quick-bar.commands.navigation.${page.component}`
); );
if (page.translationKey && caption) { if (page.translationKey && shortCaption) {
return { ...page, primaryText: caption }; const caption = this.hass.localize(
"ui.dialogs.quick-bar.commands.navigation.navigate_to",
"panel",
shortCaption
);
return { ...page, text: caption };
} }
} }
@ -516,9 +464,9 @@ export class QuickBar extends LitElement {
} }
private _generateConfirmationCommand( private _generateConfirmationCommand(
item: CommandItem, item: QuickBarItem,
confirmText: ConfirmationDialogParams["confirmText"] confirmText: ConfirmationDialogParams["confirmText"]
): CommandItem { ): QuickBarItem {
return { return {
...item, ...item,
action: () => action: () =>
@ -529,24 +477,13 @@ export class QuickBar extends LitElement {
}; };
} }
private _finalizeNavigationCommands( private _withNavigationActions(items) {
items: BaseNavigationCommand[] return items.map(({ text, icon, iconPath, path }) => ({
): CommandItem[] { text,
return items.map((item) => { icon,
const categoryKey = "navigation"; iconPath,
const categoryText = this.hass.localize( action: () => navigate(this, path),
`ui.dialogs.quick-bar.commands.types.${categoryKey}` }));
);
return {
...item,
categoryKey,
iconPath: mdiEarth,
categoryText,
filterText: `${categoryText} ${item.primaryText}`,
action: () => navigate(this, item.path),
};
});
} }
private _toggleIfAlreadyOpened() { private _toggleIfAlreadyOpened() {
@ -560,10 +497,10 @@ export class QuickBar extends LitElement {
: items; : items;
} }
private _filterItems = memoizeOne( private _filterItems = (
(items: QuickBarItem[], filter: string): QuickBarItem[] => items: QuickBarItem[],
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items) filter: string
); ): QuickBarItem[] => fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items);
static get styles() { static get styles() {
return [ return [
@ -588,8 +525,8 @@ export class QuickBar extends LitElement {
} }
} }
ha-icon.entity, ha-icon,
ha-svg-icon.entity { ha-svg-icon {
margin-left: 20px; margin-left: 20px;
} }
@ -598,29 +535,6 @@ export class QuickBar extends LitElement {
color: var(--primary-text-color); color: var(--primary-text-color);
} }
span.command-category {
font-weight: bold;
padding: 3px;
display: inline-flex;
border-radius: 6px;
color: black;
}
.command-category.reload {
--ha-chip-background-color: #cddc39;
--ha-chip-text-color: black;
}
.command-category.navigation {
--ha-chip-background-color: var(--light-primary-color);
--ha-chip-text-color: black;
}
.command-category.server_control {
--ha-chip-background-color: var(--warning-color);
--ha-chip-text-color: black;
}
.uni-virtualizer-host { .uni-virtualizer-host {
display: block; display: block;
position: relative; position: relative;
@ -636,10 +550,6 @@ export class QuickBar extends LitElement {
mwc-list-item { mwc-list-item {
width: 100%; width: 100%;
} }
mwc-list-item.command-item {
text-transform: capitalize;
}
`, `,
]; ];
} }

View File

@ -1,4 +1,9 @@
import { assert } from "chai"; import { assert } from "chai";
import {
createMatches,
fuzzyScore,
FuzzyScorer,
} from "../../../src/common/string/filter/filter";
import { import {
fuzzyFilterSort, fuzzyFilterSort,
@ -80,14 +85,10 @@ describe("fuzzySequentialMatch", () => {
describe("fuzzyFilterSort", () => { describe("fuzzyFilterSort", () => {
const filter = "ticker"; const filter = "ticker";
const item1 = { const item1 = { text: "automation.ticker", altText: "Stocks", score: 0 };
filterText: "automation.ticker", const item2 = { text: "sensor.ticker", altText: "Stocks up", score: 0 };
altText: "Stocks",
score: 0,
};
const item2 = { filterText: "sensor.ticker", altText: "Stocks up", score: 0 };
const item3 = { const item3 = {
filterText: "automation.check_router", text: "automation.check_router",
altText: "Timer Check Router", altText: "Timer Check Router",
score: 0, score: 0,
}; };
@ -105,3 +106,45 @@ describe("fuzzyFilterSort", () => {
assert.deepEqual(res, expectedItemsAfterFilter); assert.deepEqual(res, expectedItemsAfterFilter);
}); });
}); });
describe("createMatches", () => {
it(`sorts correctly`, () => {
assertMatches("tit", "win.tit", "win.^t^i^t", fuzzyScore);
});
});
function assertMatches(
pattern: string,
word: string,
decoratedWord: string | undefined,
filter: FuzzyScorer,
opts: {
patternPos?: number;
wordPos?: number;
firstMatchCanBeWeak?: boolean;
} = {}
) {
const r = filter(
pattern,
pattern.toLowerCase(),
opts.patternPos || 0,
word,
word.toLowerCase(),
opts.wordPos || 0,
opts.firstMatchCanBeWeak || false
);
assert.ok(!decoratedWord === !r);
if (r) {
const matches = createMatches(r);
let actualWord = "";
let pos = 0;
for (const match of matches) {
actualWord += word.substring(pos, match.start);
actualWord +=
"^" + word.substring(match.start, match.end).split("").join("^");
pos = match.end;
}
actualWord += word.substring(pos);
assert.equal(actualWord, decoratedWord);
}
}