diff --git a/src/common/string/filter/filter.ts b/src/common/string/filter/filter.ts index d3471433ab..8b804d3881 100644 --- a/src/common/string/filter/filter.ts +++ b/src/common/string/filter/filter.ts @@ -1,327 +1,341 @@ -/* eslint-disable no-console */ -// MIT License - -// Copyright (c) 2015 - present Microsoft Corporation - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ import { CharCode } from "./char-code"; +import { LRUCache } from "./lru-cache"; -const _debug = false; +export interface IFilter { + // Returns null if word doesn't match. + (word: string, wordToMatchAgainst: string): IMatch[] | null; +} -export interface Match { +export interface IMatch { start: number; end: number; } +// Combined filters + +/** + * @returns A filter which combines the provided set + * of filters with an or. The *first* filters that + * matches defined the return value of the returned + * filter. + */ +export function or(...filter: IFilter[]): IFilter { + return function (word: string, wordToMatchAgainst: string): IMatch[] | null { + for (let i = 0, len = filter.length; i < len; i++) { + const match = filter[i](word, wordToMatchAgainst); + if (match) { + return match; + } + } + return null; + }; +} + +// Prefix + +// Contiguous Substring + +export function matchesContiguousSubString( + word: string, + wordToMatchAgainst: string +): IMatch[] | null { + const index = wordToMatchAgainst.toLowerCase().indexOf(word.toLowerCase()); + if (index === -1) { + return null; + } + + return [{ start: index, end: index + word.length }]; +} + +// Substring + +export function matchesSubString( + word: string, + wordToMatchAgainst: string +): IMatch[] | null { + return _matchesSubString( + word.toLowerCase(), + wordToMatchAgainst.toLowerCase(), + 0, + 0 + ); +} + +function _matchesSubString( + word: string, + wordToMatchAgainst: string, + i: number, + j: number +): IMatch[] | null { + if (i === word.length) { + return []; + } + if (j === wordToMatchAgainst.length) { + return null; + } + + if (word[i] === wordToMatchAgainst[j]) { + const result: IMatch[] | null = _matchesSubString( + word, + wordToMatchAgainst, + i + 1, + j + 1 + ); + + if (result) { + return join({ start: j, end: j + 1 }, result); + } + + return null; + } + + return _matchesSubString(word, wordToMatchAgainst, i, j + 1); +} + +// CamelCase + +function isLower(code: number): boolean { + return CharCode.a <= code && code <= CharCode.z; +} + +export function isUpper(code: number): boolean { + return CharCode.A <= code && code <= CharCode.Z; +} + +function isNumber(code: number): boolean { + return CharCode.Digit0 <= code && code <= CharCode.Digit9; +} + +function isWhitespace(code: number): boolean { + return ( + code === CharCode.Space || + code === CharCode.Tab || + code === CharCode.LineFeed || + code === CharCode.CarriageReturn + ); +} + +const wordSeparators = new Set(); +"`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?" + .split("") + .forEach((s) => wordSeparators.add(s.charCodeAt(0))); + +function isWordSeparator(code: number): boolean { + return isWhitespace(code) || wordSeparators.has(code); +} + +function charactersMatch(codeA: number, codeB: number): boolean { + return codeA === codeB || (isWordSeparator(codeA) && isWordSeparator(codeB)); +} + +function isAlphanumeric(code: number): boolean { + return isLower(code) || isUpper(code) || isNumber(code); +} + +function join(head: IMatch, tail: IMatch[]): IMatch[] { + if (tail.length === 0) { + tail = [head]; + } else if (head.end === tail[0].start) { + tail[0].start = head.start; + } else { + tail.unshift(head); + } + return tail; +} + +function nextAnchor(camelCaseWord: string, start: number): number { + for (let i = start; i < camelCaseWord.length; i++) { + const c = camelCaseWord.charCodeAt(i); + if ( + isUpper(c) || + isNumber(c) || + (i > 0 && !isAlphanumeric(camelCaseWord.charCodeAt(i - 1))) + ) { + return i; + } + } + return camelCaseWord.length; +} + +interface ICamelCaseAnalysis { + upperPercent: number; + lowerPercent: number; + alphaPercent: number; + numericPercent: number; +} + +// Heuristic to avoid computing camel case matcher for words that don't +// look like camelCaseWords. +function analyzeCamelCaseWord(word: string): ICamelCaseAnalysis { + let upper = 0; + let lower = 0; + let alpha = 0; + let numeric = 0; + let code = 0; + + for (let i = 0; i < word.length; i++) { + code = word.charCodeAt(i); + + if (isUpper(code)) { + upper++; + } + if (isLower(code)) { + lower++; + } + if (isAlphanumeric(code)) { + alpha++; + } + if (isNumber(code)) { + numeric++; + } + } + + const upperPercent = upper / word.length; + const lowerPercent = lower / word.length; + const alphaPercent = alpha / word.length; + const numericPercent = numeric / word.length; + + return { upperPercent, lowerPercent, alphaPercent, numericPercent }; +} + +function isUpperCaseWord(analysis: ICamelCaseAnalysis): boolean { + const { upperPercent, lowerPercent } = analysis; + return lowerPercent === 0 && upperPercent > 0.6; +} + +function isCamelCaseWord(analysis: ICamelCaseAnalysis): boolean { + const { upperPercent, lowerPercent, alphaPercent, numericPercent } = analysis; + return ( + lowerPercent > 0.2 && + upperPercent < 0.8 && + alphaPercent > 0.6 && + numericPercent < 0.2 + ); +} + +// Heuristic to avoid computing camel case matcher for words that don't +// look like camel case patterns. +function isCamelCasePattern(word: string): boolean { + let upper = 0; + let lower = 0; + let code = 0; + let whitespace = 0; + + for (let i = 0; i < word.length; i++) { + code = word.charCodeAt(i); + + if (isUpper(code)) { + upper++; + } + if (isLower(code)) { + lower++; + } + if (isWhitespace(code)) { + whitespace++; + } + } + + if ((upper === 0 || lower === 0) && whitespace === 0) { + return word.length <= 30; + } + + return upper <= 5; +} + +/** + * Match pattern againt word in a fuzzy way. As in IntelliSense and faster and more + * powerfull than `matchesFuzzy` + */ +export function matchesFuzzy2(pattern: string, word: string): IMatch[] | null { + const score = fuzzyScore( + pattern, + pattern.toLowerCase(), + 0, + word, + word.toLowerCase(), + 0, + true + ); + return score ? createMatches(score) : null; +} + +export function anyScore( + pattern: string, + lowPattern: string, + patternPos: number, + word: string, + lowWord: string, + wordPos: number +): FuzzyScore { + const max = Math.min(13, pattern.length); + for (; patternPos < max; patternPos++) { + const result = fuzzyScore( + pattern, + lowPattern, + patternPos, + word, + lowWord, + wordPos, + false + ); + if (result) { + return result; + } + } + return [0, wordPos]; +} + +export function createMatches(score: undefined | FuzzyScore): IMatch[] { + if (typeof score === "undefined") { + return []; + } + const res: IMatch[] = []; + const wordPos = score[1]; + for (let i = score.length - 1; i > 1; i--) { + const pos = score[i] + wordPos; + const last = res[res.length - 1]; + if (last && last.end === pos) { + last.end = pos + 1; + } else { + res.push({ start: pos, end: pos + 1 }); + } + } + return res; +} + const _maxLen = 128; function initTable() { const table: number[][] = []; - const row: number[] = [0]; - for (let i = 1; i <= _maxLen; i++) { - row.push(-i); + const row: number[] = []; + for (let i = 0; i <= _maxLen; i++) { + row[i] = 0; } for (let i = 0; i <= _maxLen; i++) { - const thisRow = row.slice(0); - thisRow[0] = -i; - table.push(thisRow); + table.push(row.slice(0)); } return table; } -function isSeparatorAtPos(value: string, index: number): boolean { - if (index < 0 || index >= value.length) { - return false; - } - const code = value.charCodeAt(index); - switch (code) { - case CharCode.Underline: - case CharCode.Dash: - case CharCode.Period: - case CharCode.Space: - case CharCode.Slash: - case CharCode.Backslash: - case CharCode.SingleQuote: - case CharCode.DoubleQuote: - case CharCode.Colon: - case CharCode.DollarSign: - return true; - default: - return false; +function initArr(maxLen: number) { + const row: number[] = []; + for (let i = 0; i <= maxLen; i++) { + row[i] = 0; } + return row; } -function isWhitespaceAtPos(value: string, index: number): boolean { - if (index < 0 || index >= value.length) { - return false; - } - const code = value.charCodeAt(index); - switch (code) { - case CharCode.Space: - case CharCode.Tab: - return true; - default: - return false; - } -} - -function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean { - return word[pos] !== wordLow[pos]; -} - -function isPatternInWord( - patternLow: string, - patternPos: number, - patternLen: number, - wordLow: string, - wordPos: number, - wordLen: number -): boolean { - while (patternPos < patternLen && wordPos < wordLen) { - if (patternLow[patternPos] === wordLow[wordPos]) { - patternPos += 1; - } - wordPos += 1; - } - return patternPos === patternLen; // pattern must be exhausted -} - -enum Arrow { - Top = 0b1, - Diag = 0b10, - Left = 0b100, -} - -/** - * A tuple of three values. - * 0. the score - * 1. the matches encoded as bitmask (2^53) - * 2. the offset at which matching started - */ -export type FuzzyScore = [number, number, number]; - -interface FilterGlobals { - _matchesCount: number; - _topMatch2: number; - _topScore: number; - _wordStart: number; - _firstMatchCanBeWeak: boolean; - _table: number[][]; - _scores: number[][]; - _arrows: Arrow[][]; -} - -function initGlobals(): FilterGlobals { - return { - _matchesCount: 0, - _topMatch2: 0, - _topScore: 0, - _wordStart: 0, - _firstMatchCanBeWeak: false, - _table: initTable(), - _scores: initTable(), - _arrows: initTable(), - }; -} - -export function fuzzyScore( - pattern: string, - patternLow: string, - patternStart: number, - word: string, - wordLow: string, - wordStart: number, - firstMatchCanBeWeak: boolean -): FuzzyScore | undefined { - const globals = initGlobals(); - const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length; - const wordLen = word.length > _maxLen ? _maxLen : word.length; - - if ( - patternStart >= patternLen || - wordStart >= wordLen || - patternLen - patternStart > wordLen - wordStart - ) { - return undefined; - } - - // Run a simple check if the characters of pattern occur - // (in order) at all in word. If that isn't the case we - // stop because no match will be possible - if ( - !isPatternInWord( - patternLow, - patternStart, - patternLen, - wordLow, - wordStart, - wordLen - ) - ) { - return undefined; - } - - let row = 1; - let column = 1; - let patternPos = patternStart; - let wordPos = wordStart; - - let hasStrongFirstMatch = false; - - // There will be a match, fill in tables - for ( - row = 1, patternPos = patternStart; - patternPos < patternLen; - row++, patternPos++ - ) { - for ( - column = 1, wordPos = wordStart; - wordPos < wordLen; - column++, wordPos++ - ) { - const score = _doScore( - pattern, - patternLow, - patternPos, - patternStart, - word, - wordLow, - wordPos - ); - - if (patternPos === patternStart && score > 1) { - hasStrongFirstMatch = true; - } - - globals._scores[row][column] = score; - - const diag = - globals._table[row - 1][column - 1] + (score > 1 ? 1 : score); - const top = globals._table[row - 1][column] + -1; - const left = globals._table[row][column - 1] + -1; - - if (left >= top) { - // left or diag - if (left > diag) { - globals._table[row][column] = left; - globals._arrows[row][column] = Arrow.Left; - } else if (left === diag) { - globals._table[row][column] = left; - globals._arrows[row][column] = Arrow.Left || Arrow.Diag; - } else { - globals._table[row][column] = diag; - globals._arrows[row][column] = Arrow.Diag; - } - } else if (top > diag) { - globals._table[row][column] = top; - globals._arrows[row][column] = Arrow.Top; - } else if (top === diag) { - globals._table[row][column] = top; - globals._arrows[row][column] = Arrow.Top || Arrow.Diag; - } else { - globals._table[row][column] = diag; - globals._arrows[row][column] = Arrow.Diag; - } - } - } - - if (_debug) { - printTables(pattern, patternStart, word, wordStart, globals); - } - - if (!hasStrongFirstMatch && !firstMatchCanBeWeak) { - return undefined; - } - - globals._matchesCount = 0; - globals._topScore = -100; - globals._wordStart = wordStart; - globals._firstMatchCanBeWeak = firstMatchCanBeWeak; - - _findAllMatches2( - row - 1, - column - 1, - patternLen === wordLen ? 1 : 0, - 0, - false, - globals - ); - if (globals._matchesCount === 0) { - return undefined; - } - - return [globals._topScore, globals._topMatch2, wordStart]; -} - -function _doScore( - pattern: string, - patternLow: string, - patternPos: number, - patternStart: number, - word: string, - wordLow: string, - wordPos: number -) { - if (patternLow[patternPos] !== wordLow[wordPos]) { - return -1; - } - if (wordPos === patternPos - patternStart) { - // common prefix: `foobar <-> foobaz` - // ^^^^^ - if (pattern[patternPos] === word[wordPos]) { - return 7; - } - return 5; - } - - if ( - isUpperCaseAtPos(wordPos, word, wordLow) && - (wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow)) - ) { - // hitting upper-case: `foo <-> forOthers` - // ^^ ^ - if (pattern[patternPos] === word[wordPos]) { - return 7; - } - return 5; - } - - if ( - isSeparatorAtPos(wordLow, wordPos) && - (wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1)) - ) { - // hitting a separator: `. <-> foo.bar` - // ^ - return 5; - } - - if ( - isSeparatorAtPos(wordLow, wordPos - 1) || - isWhitespaceAtPos(wordLow, wordPos - 1) - ) { - // post separator: `foo <-> bar_foo` - // ^^^ - return 5; - } - return 1; -} +const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position +const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position +const _diag = initTable(); // the length of a contiguous diagonal match +const _table = initTable(); +const _arrows = initTable(); +const _debug = false; function printTable( table: number[][], @@ -360,104 +374,573 @@ function printTables( pattern: string, patternStart: number, word: string, - wordStart: number, - globals: FilterGlobals + wordStart: number ): void { pattern = pattern.substr(patternStart); word = word.substr(wordStart); - console.log( - printTable(globals._table, pattern, pattern.length, word, word.length) - ); - console.log( - printTable(globals._arrows, pattern, pattern.length, word, word.length) - ); - console.log( - printTable(globals._scores, pattern, pattern.length, word, word.length) - ); + console.log(printTable(_table, pattern, pattern.length, word, word.length)); + console.log(printTable(_arrows, pattern, pattern.length, word, word.length)); + console.log(printTable(_diag, pattern, pattern.length, word, word.length)); } -function _findAllMatches2( - row: number, - column: number, - total: number, - matches: number, - lastMatched: boolean, - globals: FilterGlobals -): void { - if (globals._matchesCount >= 10 || total < -25) { - // stop when having already 10 results, or - // when a potential alignment as already 5 gaps - return; +function isSeparatorAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.codePointAt(index); + switch (code) { + case CharCode.Underline: + case CharCode.Dash: + case CharCode.Period: + case CharCode.Space: + case CharCode.Slash: + case CharCode.Backslash: + case CharCode.SingleQuote: + case CharCode.DoubleQuote: + case CharCode.Colon: + case CharCode.DollarSign: + case CharCode.LessThan: + case CharCode.OpenParen: + case CharCode.OpenSquareBracket: + return true; + case undefined: + return false; + default: + if (isEmojiImprecise(code)) { + return true; + } + return false; + } +} + +function isWhitespaceAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.charCodeAt(index); + switch (code) { + case CharCode.Space: + case CharCode.Tab: + return true; + default: + return false; + } +} + +function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean { + return word[pos] !== wordLow[pos]; +} + +export function isPatternInWord( + patternLow: string, + patternPos: number, + patternLen: number, + wordLow: string, + wordPos: number, + wordLen: number, + fillMinWordPosArr = false +): boolean { + while (patternPos < patternLen && wordPos < wordLen) { + if (patternLow[patternPos] === wordLow[wordPos]) { + if (fillMinWordPosArr) { + // Remember the min word position for each pattern position + _minWordMatchPos[patternPos] = wordPos; + } + patternPos += 1; + } + wordPos += 1; + } + return patternPos === patternLen; // pattern must be exhausted +} + +enum Arrow { + Diag = 1, + Left = 2, + LeftLeft = 3, +} + +/** + * An array representating a fuzzy match. + * + * 0. the score + * 1. the offset at which matching started + * 2. `` + * 3. `` + * 4. `` etc + */ +export type FuzzyScore = [number, number, number[]]; // [number, number, number]; + +// export namespace FuzzyScore { +// /** +// * No matches and value `-100` +// */ +// export const Default: FuzzyScore = ([-100, 0]); + +// export function isDefault(score?: FuzzyScore): score is [-100, 0] { +// return !score || (score.length === 2 && score[0] === -100 && score[1] === 0); +// } +// } + +export interface FuzzyScorer { + ( + pattern: string, + lowPattern: string, + patternPos: number, + word: string, + lowWord: string, + wordPos: number, + firstMatchCanBeWeak: boolean + ): FuzzyScore | undefined; +} + +export function fuzzyScore( + pattern: string, + patternLow: string, + patternStart: number, + word: string, + wordLow: string, + wordStart: number, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length; + const wordLen = word.length > _maxLen ? _maxLen : word.length; + + if ( + patternStart >= patternLen || + wordStart >= wordLen || + patternLen - patternStart > wordLen - wordStart + ) { + return undefined; } - let simpleMatchCount = 0; + // Run a simple check if the characters of pattern occur + // (in order) at all in word. If that isn't the case we + // stop because no match will be possible + if ( + !isPatternInWord( + patternLow, + patternStart, + patternLen, + wordLow, + wordStart, + wordLen, + true + ) + ) { + return undefined; + } - while (row > 0 && column > 0) { - const score = globals._scores[row][column]; - const arrow = globals._arrows[row][column]; + // Find the max matching word position for each pattern position + // NOTE: the min matching word position was filled in above, in the `isPatternInWord` call + _fillInMaxWordMatchPos( + patternLen, + wordLen, + patternStart, + wordStart, + patternLow, + wordLow + ); - if (arrow === Arrow.Left) { - // left -> no match, skip a word character - column -= 1; - if (lastMatched) { - total -= 5; // new gap penalty - } else if (matches !== 0) { - total -= 1; // gap penalty after first match - } - lastMatched = false; - simpleMatchCount = 0; - } else if (arrow && Arrow.Diag) { - if (arrow && Arrow.Left) { - // left - _findAllMatches2( - row, - column - 1, - matches !== 0 ? total - 1 : total, // gap penalty after first match - matches, - lastMatched, - globals + let row = 1; + let column = 1; + let patternPos = patternStart; + let wordPos = wordStart; + + const hasStrongFirstMatch = [false]; + + // There will be a match, fill in tables + for ( + row = 1, patternPos = patternStart; + patternPos < patternLen; + row++, patternPos++ + ) { + // Reduce search space to possible matching word positions and to possible access from next row + const minWordMatchPos = _minWordMatchPos[patternPos]; + const maxWordMatchPos = _maxWordMatchPos[patternPos]; + const nextMaxWordMatchPos = + patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen; + + for ( + column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos; + wordPos < nextMaxWordMatchPos; + column++, wordPos++ + ) { + let score = Number.MIN_SAFE_INTEGER; + let canComeDiag = false; + + if (wordPos <= maxWordMatchPos) { + score = _doScore( + pattern, + patternLow, + patternPos, + patternStart, + word, + wordLow, + wordPos, + wordLen, + wordStart, + _diag[row - 1][column - 1] === 0, + hasStrongFirstMatch ); } - // diag - total += score; - row -= 1; - column -= 1; - lastMatched = true; - - // match -> set a 1 at the word pos - matches += 2 ** (column + globals._wordStart); - - // count simple matches and boost a row of - // simple matches when they yield in a - // strong match. - if (score === 1) { - simpleMatchCount += 1; - - if (row === 0 && !globals._firstMatchCanBeWeak) { - // when the first match is a weak - // match we discard it - return; - } - } else { - // boost - total += 1 + simpleMatchCount * (score - 1); - simpleMatchCount = 0; + let diagScore = 0; + if (score !== Number.MAX_SAFE_INTEGER) { + canComeDiag = true; + diagScore = score + _table[row - 1][column - 1]; + } + + const canComeLeft = wordPos > minWordMatchPos; + const leftScore = canComeLeft + ? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0) + : 0; // penalty for a gap start + + const canComeLeftLeft = + wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0; + const leftLeftScore = canComeLeftLeft + ? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0) + : 0; // penalty for a gap start + + if ( + canComeLeftLeft && + (!canComeLeft || leftLeftScore >= leftScore) && + (!canComeDiag || leftLeftScore >= diagScore) + ) { + // always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word + _table[row][column] = leftLeftScore; + _arrows[row][column] = Arrow.LeftLeft; + _diag[row][column] = 0; + } else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) { + // always prefer choosing left since that means a match is earlier in the word + _table[row][column] = leftScore; + _arrows[row][column] = Arrow.Left; + _diag[row][column] = 0; + } else if (canComeDiag) { + _table[row][column] = diagScore; + _arrows[row][column] = Arrow.Diag; + _diag[row][column] = _diag[row - 1][column - 1] + 1; + } else { + throw new Error(`not possible`); } - } else { - return; } } - total -= column >= 3 ? 9 : column * 3; // late start penalty + if (_debug) { + printTables(pattern, patternStart, word, wordStart); + } - // dynamically keep track of the current top score - // and insert the current best score at head, the rest at tail - globals._matchesCount += 1; - if (total > globals._topScore) { - globals._topScore = total; - globals._topMatch2 = matches; + if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) { + return undefined; + } + + row--; + column--; + + const result: FuzzyScore = [_table[row][column], wordStart]; + + let backwardsDiagLength = 0; + let maxMatchColumn = 0; + + while (row >= 1) { + // Find the column where we go diagonally up + let diagColumn = column; + do { + const arrow = _arrows[row][diagColumn]; + if (arrow === Arrow.LeftLeft) { + diagColumn -= 2; + } else if (arrow === Arrow.Left) { + diagColumn -= 1; + } else { + // found the diagonal + break; + } + } while (diagColumn >= 1); + + // Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match + if ( + backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters + patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally + !isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase + backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match + ) { + diagColumn = column; + } + + if (diagColumn === column) { + // this is a contiguous match + backwardsDiagLength++; + } else { + backwardsDiagLength = 1; + } + + if (!maxMatchColumn) { + // remember the last matched column + maxMatchColumn = diagColumn; + } + + row--; + column = diagColumn - 1; + result.push(column); + } + + if (wordLen === patternLen) { + // the word matches the pattern with all characters! + // giving the score a total match boost (to come up ahead other words) + result[0] += 2; + } + + // Add 1 penalty for each skipped character in the word + const skippedCharsCount = maxMatchColumn - patternLen; + result[0] -= skippedCharsCount; + + return result; +} + +function _fillInMaxWordMatchPos( + patternLen: number, + wordLen: number, + patternStart: number, + wordStart: number, + patternLow: string, + wordLow: string +) { + let patternPos = patternLen - 1; + let wordPos = wordLen - 1; + while (patternPos >= patternStart && wordPos >= wordStart) { + if (patternLow[patternPos] === wordLow[wordPos]) { + _maxWordMatchPos[patternPos] = wordPos; + patternPos--; + } + wordPos--; } } -// #endregion +function _doScore( + pattern: string, + patternLow: string, + patternPos: number, + patternStart: number, + word: string, + wordLow: string, + wordPos: number, + wordLen: number, + wordStart: number, + newMatchStart: boolean, + outFirstMatchStrong: boolean[] +): number { + if (patternLow[patternPos] !== wordLow[wordPos]) { + return Number.MIN_SAFE_INTEGER; + } + + let score = 1; + let isGapLocation = false; + if (wordPos === patternPos - patternStart) { + // common prefix: `foobar <-> foobaz` + // ^^^^^ + score = pattern[patternPos] === word[wordPos] ? 7 : 5; + } else if ( + isUpperCaseAtPos(wordPos, word, wordLow) && + (wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow)) + ) { + // hitting upper-case: `foo <-> forOthers` + // ^^ ^ + score = pattern[patternPos] === word[wordPos] ? 7 : 5; + isGapLocation = true; + } else if ( + isSeparatorAtPos(wordLow, wordPos) && + (wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1)) + ) { + // hitting a separator: `. <-> foo.bar` + // ^ + score = 5; + } else if ( + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1) + ) { + // post separator: `foo <-> bar_foo` + // ^^^ + score = 5; + isGapLocation = true; + } + + if (score > 1 && patternPos === patternStart) { + outFirstMatchStrong[0] = true; + } + + if (!isGapLocation) { + isGapLocation = + isUpperCaseAtPos(wordPos, word, wordLow) || + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1); + } + + // + if (patternPos === patternStart) { + // first character in pattern + if (wordPos > wordStart) { + // the first pattern character would match a word character that is not at the word start + // so introduce a penalty to account for the gap preceding this match + score -= isGapLocation ? 3 : 5; + } + } else if (newMatchStart) { + // this would be the beginning of a new match (i.e. there would be a gap before this location) + score += isGapLocation ? 2 : 0; + } else { + // this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location + score += isGapLocation ? 0 : 1; + } + + if (wordPos + 1 === wordLen) { + // we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word + // so pretend there is a gap after the last character in the word to normalize things + score -= isGapLocation ? 3 : 5; + } + + return score; +} + +export function fuzzyScoreGracefulAggressive( + pattern: string, + lowPattern: string, + patternPos: number, + word: string, + lowWord: string, + wordPos: number, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + return fuzzyScoreWithPermutations( + pattern, + lowPattern, + patternPos, + word, + lowWord, + wordPos, + true, + firstMatchCanBeWeak + ); +} + +export function fuzzyScoreGraceful( + pattern: string, + lowPattern: string, + patternPos: number, + word: string, + lowWord: string, + wordPos: number, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + return fuzzyScoreWithPermutations( + pattern, + lowPattern, + patternPos, + word, + lowWord, + wordPos, + false, + firstMatchCanBeWeak + ); +} + +function fuzzyScoreWithPermutations( + pattern: string, + lowPattern: string, + patternPos: number, + word: string, + lowWord: string, + wordPos: number, + aggressive: boolean, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + let top = fuzzyScore( + pattern, + lowPattern, + patternPos, + word, + lowWord, + wordPos, + firstMatchCanBeWeak + ); + + if (top && !aggressive) { + // when using the original pattern yield a result we` + // return it unless we are aggressive and try to find + // a better alignment, e.g. `cno` -> `^co^ns^ole` or `^c^o^nsole`. + return top; + } + + if (pattern.length >= 3) { + // When the pattern is long enough then try a few (max 7) + // permutations of the pattern to find a better match. The + // permutations only swap neighbouring characters, e.g + // `cnoso` becomes `conso`, `cnsoo`, `cnoos`. + const tries = Math.min(7, pattern.length - 1); + for ( + let movingPatternPos = patternPos + 1; + movingPatternPos < tries; + movingPatternPos++ + ) { + const newPattern = nextTypoPermutation(pattern, movingPatternPos); + if (newPattern) { + const candidate = fuzzyScore( + newPattern, + newPattern.toLowerCase(), + patternPos, + word, + lowWord, + wordPos, + firstMatchCanBeWeak + ); + if (candidate) { + candidate[0] -= 3; // permutation penalty + if (!top || candidate[0] > top[0]) { + top = candidate; + } + } + } + } + } + + return top; +} + +function nextTypoPermutation( + pattern: string, + patternPos: number +): string | undefined { + if (patternPos + 1 >= pattern.length) { + return undefined; + } + + const swap1 = pattern[patternPos]; + const swap2 = pattern[patternPos + 1]; + + if (swap1 === swap2) { + return undefined; + } + + return ( + pattern.slice(0, patternPos) + swap2 + swap1 + pattern.slice(patternPos + 2) + ); +} + +/** + * A fast function (therefore imprecise) to check if code points are emojis. + * Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js + */ +export function isEmojiImprecise(x: number): boolean { + return ( + (x >= 0x1f1e6 && x <= 0x1f1ff) || + x === 8986 || + x === 8987 || + x === 9200 || + x === 9203 || + (x >= 9728 && x <= 10175) || + x === 11088 || + x === 11093 || + (x >= 127744 && x <= 128591) || + (x >= 128640 && x <= 128764) || + (x >= 128992 && x <= 129003) || + (x >= 129280 && x <= 129535) || + (x >= 129648 && x <= 129750) + ); +} diff --git a/src/common/string/filter/linked-map.ts b/src/common/string/filter/linked-map.ts new file mode 100644 index 0000000000..fa5567eb7f --- /dev/null +++ b/src/common/string/filter/linked-map.ts @@ -0,0 +1,399 @@ +export enum EntityTouch { + None = 0, + AsOld = 1, + AsNew = 2, +} + +interface Item { + previous: Item | undefined; + next: Item | undefined; + key: K; + value: V; +} + +export class LinkedMap implements Map { + readonly [Symbol.toStringTag] = "LinkedMap"; + + private _map: Map>; + + private _head: Item | undefined; + + private _tail: Item | undefined; + + private _size: number; + + private _state: number; + + constructor() { + this._map = new Map>(); + 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) => 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 { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const map = this; + const state = this._state; + let current = this._head; + const iterator: IterableIterator = { + [Symbol.iterator]() { + return iterator; + }, + next(): IteratorResult { + 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 { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const map = this; + const state = this._state; + let current = this._head; + const iterator: IterableIterator = { + [Symbol.iterator]() { + return iterator; + }, + next(): IteratorResult { + 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): 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): 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): 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, 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); + } + } +} diff --git a/src/common/string/filter/lru-cache.ts b/src/common/string/filter/lru-cache.ts new file mode 100644 index 0000000000..188bf3557f --- /dev/null +++ b/src/common/string/filter/lru-cache.ts @@ -0,0 +1,51 @@ +import { EntityTouch, LinkedMap } from "./linked-map"; + +export class LRUCache extends LinkedMap { + 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)); + } + } +} diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index 6e4a5bf2e5..0ec8716bcc 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -1,4 +1,4 @@ -import { fuzzyScore } from "./filter"; +import { createMatches, FuzzyScore, fuzzyScore } from "./filter"; /** * 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[]) => { - let topScore = 0; + let topScore = Number.NEGATIVE_INFINITY; + let topScores: FuzzyScore | undefined; for (const word of words) { 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 // 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 - const score = scores[0] + 1; + const score = scores[0] === 0 ? 1 : scores[0]; if (score > topScore) { 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 { score?: number; - filterText: string; + text: string; altText?: string; + decoratedText?: string; } type FuzzyFilterSort = ( @@ -54,13 +65,45 @@ type FuzzyFilterSort = ( export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { return items .map((item) => { - item.score = item.altText - ? fuzzySequentialMatch(filter, item.filterText, item.altText) - : fuzzySequentialMatch(filter, item.filterText); + const match = item.altText + ? fuzzySequentialMatch(filter, item.text, item.altText) + : fuzzySequentialMatch(filter, item.text); + + item.score = match?.score; + item.decoratedText = match?.decoratedText; + return item; }) - .filter((item) => item.score !== undefined && item.score > 0) + .filter((item) => item.score !== undefined) .sort(({ score: scoreA = 0 }, { score: scoreB = 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; +}; diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index c8b04598f7..d79a2b3ff1 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -3,7 +3,7 @@ import type { List } from "@material/mwc-list/mwc-list"; import { SingleSelectedEvent } from "@material/mwc-list/mwc-list-foundation"; import "@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 { css, customElement, @@ -13,6 +13,7 @@ import { property, PropertyValues, query, + TemplateResult, } from "lit-element"; import { ifDefined } from "lit-html/directives/if-defined"; import { styleMap } from "lit-html/directives/style-map"; @@ -36,7 +37,7 @@ import "../../components/ha-circular-progress"; import "../../components/ha-dialog"; import "../../components/ha-header-bar"; import { domainToName } from "../../data/integration"; -import { getPanelNameTranslationKey } from "../../data/panel"; +import { getPanelIcon, getPanelNameTranslationKey } from "../../data/panel"; import { PageNavigation } from "../../layouts/hass-tabs-subpage"; import { configSections } from "../../panels/config/ha-panel-config"; import { haStyleDialog } from "../../resources/styles"; @@ -46,44 +47,31 @@ import { showConfirmationDialog, } from "../generic/show-dialog-box"; 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 { - primaryText: string; + icon?: string; iconPath?: string; action(data?: any): void; } -interface CommandItem 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 { +interface QuickBarNavigationItem extends QuickBarItem { path: string; } -type NavigationInfo = PageNavigation & Pick; +interface NavigationInfo extends PageNavigation { + text: string; +} -type BaseNavigationCommand = Pick< - QuickBarNavigationItem, - "primaryText" | "path" ->; @customElement("ha-quick-bar") export class QuickBar extends LitElement { @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[] = []; @@ -214,12 +202,6 @@ export class QuickBar extends LitElement { } 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` ` - : html``} - ${item.primaryText} + : html``} + ${item.decoratedText + ? this._renderDecoratedText(item.decoratedText) + : item.text} ${item.altText ? html` ${item.altText}${this._renderDecoratedText(item.altText)} ` : null} @@ -252,39 +231,18 @@ export class QuickBar extends LitElement { `; } - private _renderCommandItem(item: CommandItem, index?: number) { - return html` - - - - ${item.iconPath - ? html`` - : ""} - ${item.categoryText} - + private _renderDecoratedText(text: string) { + const decoratedText: TemplateResult[] = []; - ${item.primaryText} - ${item.altText - ? html` - ${item.altText} - ` - : null} - - `; + for (let i = 0; i < text.length; i++) { + if (text[i] === "^") { + decoratedText.push(html`${text[i + 1]}`); + i++; + } else { + decoratedText.push(html`${text[i]}`); + } + } + return decoratedText; } private async processItemAndCloseDialog(item: QuickBarItem, index: number) { @@ -374,112 +332,96 @@ export class QuickBar extends LitElement { private _generateEntityItems(): QuickBarItem[] { return Object.keys(this.hass.states) - .map((entityId) => { - const primaryText = computeStateName(this.hass.states[entityId]); - return { - primaryText, - filterText: primaryText, - altText: entityId, - icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), - action: () => fireEvent(this, "hass-more-info", { entityId }), - }; - }) - .sort((a, b) => - compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase()) - ); + .map((entityId) => ({ + text: computeStateName(this.hass.states[entityId]), + altText: entityId, + icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), + action: () => fireEvent(this, "hass-more-info", { entityId }), + })) + .sort((a, b) => compare(a.text.toLowerCase(), b.text.toLowerCase())); } - private _generateCommandItems(): CommandItem[] { + private _generateCommandItems(): QuickBarItem[] { return [ ...this._generateReloadCommands(), ...this._generateServerControlCommands(), ...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(); - return reloadableDomains.map((domain) => { - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.reload` - ); - const primaryText = + return reloadableDomains.map((domain) => ({ + text: this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || this.hass.localize( "ui.dialogs.quick-bar.commands.reload.reload", "domain", domainToName(this.hass.localize, domain) - ); - - return { - primaryText, - filterText: `${categoryText} ${primaryText}`, - action: () => this.hass.callService(domain, "reload"), - categoryKey: "reload", - iconPath: mdiReload, - categoryText, - }; - }); + ), + icon: domainIcon(domain), + action: () => this.hass.callService(domain, "reload"), + })); } - private _generateServerControlCommands(): CommandItem[] { + private _generateServerControlCommands(): QuickBarItem[] { const serverActions = ["restart", "stop"]; - return serverActions.map((action) => { - const categoryKey = "server_control"; - 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( + return serverActions.map((action) => + this._generateConfirmationCommand( { - primaryText, - filterText: `${categoryText} ${primaryText}`, - categoryKey, - iconPath: mdiServerNetwork, - categoryText, + text: this.hass.localize( + "ui.dialogs.quick-bar.commands.server_control.perform_action", + "action", + this.hass.localize( + `ui.dialogs.quick-bar.commands.server_control.${action}` + ) + ), + icon: DEFAULT_SERVER_ICON, action: () => this.hass.callService("homeassistant", action), }, this.hass.localize("ui.dialogs.generic.ok") - ); - }); + ) + ); } - private _generateNavigationCommands(): CommandItem[] { + private _generateNavigationCommands(): QuickBarItem[] { const panelItems = this._generateNavigationPanelCommands(); 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) .filter((panelKey) => panelKey !== "_my_redirect") .map((panelKey) => { const panel = this.hass.panels[panelKey]; const translationKey = getPanelNameTranslationKey(panel); - const primaryText = - this.hass.localize(translationKey) || panel.title || panel.url_path; + const text = this.hass.localize( + "ui.dialogs.quick-bar.commands.navigation.navigate_to", + "panel", + this.hass.localize(translationKey) || panel.title || panel.url_path + ); return { - primaryText, + text, + icon: getPanelIcon(panel) || DEFAULT_NAVIGATION_ICON, path: `/${panel.url_path}`, }; }); } - private _generateNavigationConfigSectionCommands(): BaseNavigationCommand[] { + private _generateNavigationConfigSectionCommands(): Partial< + QuickBarNavigationItem + >[] { const items: NavigationInfo[] = []; for (const sectionKey of Object.keys(configSections)) { @@ -503,12 +445,18 @@ export class QuickBar extends LitElement { page: PageNavigation ): NavigationInfo | undefined { if (page.component) { - const caption = this.hass.localize( + const shortCaption = this.hass.localize( `ui.dialogs.quick-bar.commands.navigation.${page.component}` ); - if (page.translationKey && caption) { - return { ...page, primaryText: caption }; + if (page.translationKey && shortCaption) { + 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( - item: CommandItem, + item: QuickBarItem, confirmText: ConfirmationDialogParams["confirmText"] - ): CommandItem { + ): QuickBarItem { return { ...item, action: () => @@ -529,24 +477,13 @@ export class QuickBar extends LitElement { }; } - private _finalizeNavigationCommands( - items: BaseNavigationCommand[] - ): CommandItem[] { - return items.map((item) => { - const categoryKey = "navigation"; - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.${categoryKey}` - ); - - return { - ...item, - categoryKey, - iconPath: mdiEarth, - categoryText, - filterText: `${categoryText} ${item.primaryText}`, - action: () => navigate(this, item.path), - }; - }); + private _withNavigationActions(items) { + return items.map(({ text, icon, iconPath, path }) => ({ + text, + icon, + iconPath, + action: () => navigate(this, path), + })); } private _toggleIfAlreadyOpened() { @@ -560,10 +497,10 @@ export class QuickBar extends LitElement { : items; } - private _filterItems = memoizeOne( - (items: QuickBarItem[], filter: string): QuickBarItem[] => - fuzzyFilterSort(filter.trimLeft(), items) - ); + private _filterItems = ( + items: QuickBarItem[], + filter: string + ): QuickBarItem[] => fuzzyFilterSort(filter.trimLeft(), items); static get styles() { return [ @@ -588,8 +525,8 @@ export class QuickBar extends LitElement { } } - ha-icon.entity, - ha-svg-icon.entity { + ha-icon, + ha-svg-icon { margin-left: 20px; } @@ -598,29 +535,6 @@ export class QuickBar extends LitElement { 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 { display: block; position: relative; @@ -636,10 +550,6 @@ export class QuickBar extends LitElement { mwc-list-item { width: 100%; } - - mwc-list-item.command-item { - text-transform: capitalize; - } `, ]; } diff --git a/test-mocha/common/string/sequence_matching.test.ts b/test-mocha/common/string/sequence_matching.test.ts index 48400606ea..db409f0165 100644 --- a/test-mocha/common/string/sequence_matching.test.ts +++ b/test-mocha/common/string/sequence_matching.test.ts @@ -1,4 +1,9 @@ import { assert } from "chai"; +import { + createMatches, + fuzzyScore, + FuzzyScorer, +} from "../../../src/common/string/filter/filter"; import { fuzzyFilterSort, @@ -80,14 +85,10 @@ describe("fuzzySequentialMatch", () => { describe("fuzzyFilterSort", () => { const filter = "ticker"; - const item1 = { - filterText: "automation.ticker", - altText: "Stocks", - score: 0, - }; - const item2 = { filterText: "sensor.ticker", altText: "Stocks up", score: 0 }; + const item1 = { text: "automation.ticker", altText: "Stocks", score: 0 }; + const item2 = { text: "sensor.ticker", altText: "Stocks up", score: 0 }; const item3 = { - filterText: "automation.check_router", + text: "automation.check_router", altText: "Timer Check Router", score: 0, }; @@ -105,3 +106,45 @@ describe("fuzzyFilterSort", () => { 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); + } +}