Title: [286611] trunk
Revision
286611
Author
[email protected]
Date
2021-12-07 12:51:52 -0800 (Tue, 07 Dec 2021)

Log Message

Web Inspector: Support fuzzy matching in CSS completions
https://bugs.webkit.org/show_bug.cgi?id=230351
<rdar://82976292>

Reviewed by Devin Rousso.

Source/WebInspectorUI:

Use fuzzy matching for identifying CSS completions in the Styles details sidebar.

There are three main parts to this patch:
1. Introduce `WI.CSSQueryController` with logic to do fuzzy matching on provided values.
2. Change `WI.CompletionSuggestionsView` to support both plain strings and `WI.QueryResult`s.
3. Change `WI.SpreadsheetTextField` so its `value` doesn't always return its `element.textContent`.

With fuzzy matching, completions are not guaranteed anymore to be prefixed with the query.
Therefore, it's no longer viable to rely on `WI.SpreadsheetTextField.value` being a concatenation of `textContent` of nodes within.

To adress this, there's now `WI.SpreadsheetTextField._pendingValue` which includes the prospective
completion regardless of whether the query is a completion prefix or at match at any other position.

* Localizations/en.lproj/localizedStrings.js:
* UserInterface/Base/Setting.js:
Add a flag to enable the fuzzy matching feature.

* UserInterface/Controllers/CSSQueryController.js: Added.
(WI.CSSQueryController):
(WI.CSSQueryController.prototype.addValues):
(WI.CSSQueryController.prototype.reset):
(WI.CSSQueryController.prototype.executeQuery):
(WI.CSSQueryController.prototype._findSpecialCharacterIndices):
Add a speclialized class to hold the logic for fuzzy matching of CSS properties and values.
It clones logic from `WI.ResouceQueryController` with adjustments specific to CSS; more to follow as the feature gets refined.

* UserInterface/Main.html:
* UserInterface/Models/CSSCompletions.js:
(WI.CSSCompletions):
(WI.CSSCompletions.prototype.addValues):
(WI.CSSCompletions.prototype.executeQuery):
Support both the current prefix matching approach as well as fuzzy matching.

* UserInterface/Models/CSSKeywordCompletions.js:
(WI.CSSKeywordCompletions.forPartialPropertyName):
Opt into fuzzy matching when getting completions for property names and values.

* UserInterface/Models/QueryResult.js:
(WI.QueryResult.prototype.get matches):
* UserInterface/Test.html:

* UserInterface/Views/CompletionSuggestionsView.css:
(.completion-suggestions-container > .item > .highlighted):
Highlight specific characters that matched in result identified by fuzzy matching.

* UserInterface/Views/CompletionSuggestionsView.js:
(WI.CompletionSuggestionsView.prototype.selectNext):
(WI.CompletionSuggestionsView.prototype.selectPrevious):
(WI.CompletionSuggestionsView.prototype.update):
(WI.CompletionSuggestionsView.prototype.getCompletionText):
(WI.CompletionSuggestionsView.prototype._createHighlightedCompletionFragment):
Change `WI.CompletionSuggestionsView` to hold a list of completions,
either strings or `WI.QueryResult`, and return the appropriate completion text
instead of returning the `textContent` of the selected element.

`WI.CompletionSuggestionsView` is used elsewhere in the Console and Sources panel
so avoid impacting those consumers until they opt in to fuzzy matching as well.

* UserInterface/Views/SettingsTabContentView.js:

* UserInterface/Views/SpreadsheetStyleProperty.js:
(WI.SpreadsheetStyleProperty.prototype._handleNameChange):
(WI.SpreadsheetStyleProperty.prototype._handleValueChange):
(WI.SpreadsheetStyleProperty.prototype._nameCompletionDataProvider):
(WI.SpreadsheetStyleProperty.prototype._valueCompletionDataProvider):
On change, get the value of the `WI.SpreadsheetTextField` instead of
the corresponding element's `textContent`.

* UserInterface/Views/SpreadsheetTextField.js:
Introduced `SpreadsheetTextField._completionPrefix` to hold the query a user is typing.

Introduced `SpreadsheetTextField._completionText` to hold the completion text of the selected but
not yet applied completion. This replaces the previous behavior of concatenating the query and `suggestionHint`
because with fuzzy matching the completion isn't guaranteed to be prefixed with the query.

Introduced `SpreadsheetTextField._pendingValue` to hold the result of applying the selected
completion. This replaces the previous behavior of `WI.SpreadsheetTextField._combineEditorElementChildren`.

(WI.SpreadsheetTextField):
(WI.SpreadsheetTextField.prototype.get value):
Change `WI.SpreadsheetTextField` so its value is divorced from its element's `textContent`.
While editing, `WI.SpreadsheetTextField.value` returns the pending value so that
a valid CSS string gets written to the stylesheet.

(WI.SpreadsheetTextField.prototype.set suggestionHint):
If the query is a prefix for the selected completion, keep the existing behavior of appending
the suggestion hint substring. Otherwise, hide the suggestion hint element because the
concatenation doesn't make sense.

(WI.SpreadsheetTextField.prototype.stopEditing):
(WI.SpreadsheetTextField.prototype.discardCompletion):
(WI.SpreadsheetTextField.prototype.completionSuggestionsSelectedCompletion):
(WI.SpreadsheetTextField.prototype.completionSuggestionsClickedCompletion):
(WI.SpreadsheetTextField.prototype._discardChange):
(WI.SpreadsheetTextField.prototype._handleBlur):
(WI.SpreadsheetTextField.prototype._handleKeyDown):
(WI.SpreadsheetTextField.prototype._handleKeyDownForSuggestionView):
(WI.SpreadsheetTextField.prototype._handleInput):
(WI.SpreadsheetTextField.prototype._updateCompletions):
(WI.SpreadsheetTextField.prototype._applyPendingValue):
After applying a completion, the value is replaced with `WI.SpreadsheetTextField._pendingValue`.
At this point, `WI.SpreadsheetTextField.value` and the `textContent` of the element converge.

(WI.SpreadsheetTextField.prototype._updatePendingValueWithCompletionText):
When presented with a completion, the query gets substituted with the full completion text in
`WI.SpreadsheetTextField._pendingValue` regardless of whether the query is a prefix or not.

(WI.SpreadsheetTextField.prototype._applyCompletionHint): Deleted.
(WI.SpreadsheetTextField.prototype._combineEditorElementChildren): Deleted.

LayoutTests:

Add test for `WI.CSSQueryController` to check fuzzy matching logic for CSS completions.
Follows prior example from `LayoutTests/inspector/unit-tests/resource-query-controller.html`
since `WI.ResouceQueryController` served as the model class that was adapted for CSS.

* inspector/unit-tests/css-query-controller-expected.txt: Added.
* inspector/unit-tests/css-query-controller.html: Added.

Modified Paths

Added Paths

Diff

Modified: trunk/LayoutTests/ChangeLog (286610 => 286611)


--- trunk/LayoutTests/ChangeLog	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/LayoutTests/ChangeLog	2021-12-07 20:51:52 UTC (rev 286611)
@@ -1,3 +1,18 @@
+2021-12-07  Razvan Caliman  <[email protected]>
+
+        Web Inspector: Support fuzzy matching in CSS completions
+        https://bugs.webkit.org/show_bug.cgi?id=230351
+        <rdar://82976292>
+
+        Reviewed by Devin Rousso.
+
+        Add test for `WI.CSSQueryController` to check fuzzy matching logic for CSS completions.
+        Follows prior example from `LayoutTests/inspector/unit-tests/resource-query-controller.html`
+        since `WI.ResouceQueryController` served as the model class that was adapted for CSS.
+
+        * inspector/unit-tests/css-query-controller-expected.txt: Added.
+        * inspector/unit-tests/css-query-controller.html: Added.
+
 2021-12-07  Chris Dumez  <[email protected]>
 
         ASSERTION FAILED: m_messagesBeingDispatched.isEmpty() on http/tests/resourceLoadStatistics/website-data-removal-for-site-with-user-interaction.html

Modified: trunk/LayoutTests/inspector/unit-tests/css-keyword-completions-expected.txt (286610 => 286611)


--- trunk/LayoutTests/inspector/unit-tests/css-keyword-completions-expected.txt	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/LayoutTests/inspector/unit-tests/css-keyword-completions-expected.txt	2021-12-07 20:51:52 UTC (rev 286611)
@@ -71,3 +71,12 @@
 PASS: Expected result prefix to be ""
 PASS: All expected completions were present.
 
+-- Running test case: WI.CSSKeywordCompletions.forPartialPropertyValue.NoWhitespaceAfterFunction
+PASS: Expected result prefix to be ""
+PASS: Expected exactly 0 completion results.
+PASS: All expected completions were present.
+
+-- Running test case: WI.CSSKeywordCompletions.forPartialPropertyValue.WhitespaceAfterFunction
+PASS: Expected result prefix to be "a"
+PASS: All expected completions were present.
+

Modified: trunk/LayoutTests/inspector/unit-tests/css-keyword-completions.html (286610 => 286611)


--- trunk/LayoutTests/inspector/unit-tests/css-keyword-completions.html	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/LayoutTests/inspector/unit-tests/css-keyword-completions.html	2021-12-07 20:51:52 UTC (rev 286611)
@@ -66,7 +66,7 @@
         expectedCompletions: ["range"],
     });
 
-    function addTestForPartialPropertyValue({name, description, propertyName, text, caretPosition, expectedPrefix, expectedCompletions, additionalFunctionValueCompletionsProvider}) {
+    function addTestForPartialPropertyValue({name, description, propertyName, text, caretPosition, expectedPrefix, expectedCompletions, expectedCompletionCount, additionalFunctionValueCompletionsProvider}) {
         suite.addTestCase({
             name,
             description,
@@ -74,11 +74,15 @@
                 caretPosition ??= text.length;
                 expectedPrefix ??= text;
                 expectedCompletions ??= [];
+                expectedCompletionCount ??= -1;
                 additionalFunctionValueCompletionsProvider ??= () => {};
 
                 let completionResults = WI.CSSKeywordCompletions.forPartialPropertyValue(text, propertyName, {caretPosition, additionalFunctionValueCompletionsProvider});
                 InspectorTest.expectEqual(completionResults.prefix, expectedPrefix, `Expected result prefix to be "${expectedPrefix}"`);
 
+                if (expectedCompletionCount >= 0)
+                    InspectorTest.expectEqual(completionResults.completions.length, expectedCompletionCount, `Expected exactly ${expectedCompletionCount} completion results.`);
+
                 // Because expected completions could be added at any time, just make sure the list contains our expected completions, instead of enforcing an exact match between expectations and reality.
                 let expectedCompletionsPresent = expectedCompletions.every((expectedCompletion) => {
                     if (!completionResults.completions.includes(expectedCompletion)) {
@@ -229,6 +233,28 @@
         additionalFunctionValueCompletionsProvider: () => ["--one", "--two"],
     });
 
+    // `hsl()a|`
+    addTestForPartialPropertyValue({
+        name: "WI.CSSKeywordCompletions.forPartialPropertyValue.NoWhitespaceAfterFunction",
+        description: "Test that color completions are not offered immediately after a closing parenthesis",
+        propertyName: "background",
+        text: "hsl()a",
+        caretPosition: 6,
+        expectedPrefix: "",
+        expectedCompletionCount: 0
+    });
+
+    // `hsl() a|`
+    addTestForPartialPropertyValue({
+        name: "WI.CSSKeywordCompletions.forPartialPropertyValue.WhitespaceAfterFunction",
+        description: "Test that color completions are offered when a closing parenthesis is followed by whitespace",
+        propertyName: "background",
+        text: "hsl() a",
+        caretPosition: 7,
+        expectedPrefix: "a",
+        expectedCompletions: ["aliceblue", "antiquewhite"],
+    });
+
     suite.runTestCasesAndFinish();
 }
 </script>

Added: trunk/LayoutTests/inspector/unit-tests/css-query-controller-expected.txt (0 => 286611)


--- trunk/LayoutTests/inspector/unit-tests/css-query-controller-expected.txt	                        (rev 0)
+++ trunk/LayoutTests/inspector/unit-tests/css-query-controller-expected.txt	2021-12-07 20:51:52 UTC (rev 286611)
@@ -0,0 +1,54 @@
+Testing WI.CSSQueryController
+
+
+== Running test suite: CSSQueryController
+-- Running test case: FindSpecialCharacterIndices
+PASS: Result for margin should match expected special indices.
+PASS: Result for MARGIN should match expected special indices.
+PASS: Result for margin-right should match expected special indices.
+PASS: Result for -webkit-mask should match expected special indices.
+PASS: Result for --var-kebab-case should match expected special indices.
+PASS: Result for --varCamelCase should match expected special indices.
+PASS: Result for --varlowercase should match expected special indices.
+PASS: Result for --VARUPPERCASE should match expected special indices.
+PASS: Result for --var_snake_case should match expected special indices.
+PASS: Result for var(--name) should match expected special indices.
+PASS: Result for rgb(0, 0, 0) should match expected special indices.
+PASS: Result for rgb(0 0 0 / 100%) should match expected special indices.
+PASS: Result for rgb(0 0 0 / 0.1) should match expected special indices.
+
+-- Running test case: ExecuteQueryAgainstNoValues
+PASS: Should return no results.
+
+-- Running test case: ExecuteWhitespaceQueryOrEmptyQuery
+PASS: Whitespace query should return no results.
+PASS: Whitespace query should return no results.
+PASS: Whitespace query should return no results.
+PASS: Whitespace query should return no results.
+PASS: Empty query should return no results.
+
+-- Running test case: ExecuteQueryMatchNone
+PASS: Query "abcde" shouldn't match "abcd".
+PASS: Query "abcd-" shouldn't match "abcde".
+PASS: Query "abcde" shouldn't match "abced".
+
+-- Running test case: ExecuteQueryMatchesExpectedCharacters
+PASS: Query "abcd" should match "abcd" in "abcde".
+PASS: Query "a-bcde" should match "a    - bcde" in "abcde-abcde".
+PASS: Query "abcde" should match "A B C D E" in "AaBbCcDdEe".
+PASS: Query "abcde" should match "A   B  C De" in "AbcdBcdCdDe".
+PASS: Query "abcdex" should match "A B C d ex" in "AxBxCxdxexDxyxEF".
+PASS: Query "bc" should match " bC" in "abCd".
+PASS: Query "bb" should match " bB" in "abBc".
+
+-- Running test case: ExecuteQueryGeneralRankings
+PASS: Results should be ranked by descending relevancy.
+
+-- Running test case: ExecuteQueryPositionRankings
+PASS: Results should be ranked by descending relevancy.
+
+-- Running test case: GetMatchingTextRanges
+PASS: Result TextRanges should match the expected ranges.
+PASS: Result TextRanges should match the expected ranges.
+PASS: Result TextRanges should match the expected ranges.
+

Added: trunk/LayoutTests/inspector/unit-tests/css-query-controller.html (0 => 286611)


--- trunk/LayoutTests/inspector/unit-tests/css-query-controller.html	                        (rev 0)
+++ trunk/LayoutTests/inspector/unit-tests/css-query-controller.html	2021-12-07 20:51:52 UTC (rev 286611)
@@ -0,0 +1,263 @@
+<!doctype html>
+<html>
+<head>
+<script src=""
+<script>
+function test()
+{
+    let suite = InspectorTest.createSyncSuite("CSSQueryController");
+
+    suite.addTestCase({
+        name: "FindSpecialCharacterIndices",
+        description: "Should correctly find special characters.",
+        test() {
+            let matcher = new WI.CSSQueryController;
+            let tests = [
+                {
+                    padvalue: "margin",
+                    expected: "^",
+                },
+                {
+                    padvalue: "MARGIN",
+                    expected: "^",
+                },
+                {
+                    padvalue: "margin-right",
+                    expected: "^     ^^",
+                },
+                {
+                    padvalue: "-webkit-mask",
+                    expected: "^^     ^^",
+                },
+                {
+                    padvalue: "--var-kebab-case",
+                    expected: "^^^  ^^    ^^",
+                },
+                {
+                    padvalue: "--varCamelCase",
+                    expected: "^^^  ^    ^",
+                },
+                {
+                    padvalue: "--varlowercase",
+                    expected: "^^^",
+                },
+                {
+                    padvalue: "--VARUPPERCASE",
+                    expected: "^^^",
+                },
+                {
+                    padvalue: "--var_snake_case",
+                    expected: "^^^  ^^    ^^",
+                },
+                {
+                    padvalue: "var(--name)",
+                    expected: "^   ^^^",
+                },
+                {
+                    padvalue: "rgb(0, 0, 0)",
+                    expected: "^",
+                },
+                {
+                    padvalue: "rgb(0 0 0 / 100%)",
+                    expected: "^",
+                },
+                {
+                    padvalue: "rgb(0 0 0 / 0.1)",
+                    expected: "^",
+                }
+            ];
+
+            function createSpecialMask(padvalue, specialIndices) {
+                let mask = " ".repeat(padvalue.length);
+                specialIndices.forEach((index) => {
+                    mask = mask.substr(0, index) + "^" + mask.substr(index + 1);
+                });
+                return mask.trim();
+            }
+
+            for (let {padvalue, expected} of tests) {
+                let actual = createSpecialMask(padvalue, matcher._findSpecialCharacterIndices(padvalue));
+                InspectorTest.expectEqual(actual, expected, `Result for ${padvalue} should match expected special indices.`);
+            }
+        }
+    });
+
+    suite.addTestCase({
+        name: "ExecuteQueryAgainstNoValues",
+        description: "Should return no results if no values were added to the controller.",
+        test() {
+            let matcher = new WI.CSSQueryController;
+            let results = matcher.executeQuery("abcde");
+            InspectorTest.expectThat(!results.length, "Should return no results.")
+        }
+    });
+
+    suite.addTestCase({
+        name: "ExecuteWhitespaceQueryOrEmptyQuery",
+        description: "Empty queries and queries containing only whitespace should return no results.",
+        test() {
+            const whitespaceCharacters = " \t\r\n";
+            let matcher = new WI.CSSQueryController(["abcde"]);
+
+            for (let query of whitespaceCharacters) {
+                let results = matcher.executeQuery(query);
+                InspectorTest.expectThat(!results.length, "Whitespace query should return no results.");
+            }
+
+            let results = matcher.executeQuery("");
+            InspectorTest.expectThat(!results.length, "Empty query should return no results.");
+        }
+    });
+
+    suite.addTestCase({
+        name: "ExecuteQueryMatchNone",
+        description: "Should not find a match.",
+        test() {
+            let matcher = new WI.CSSQueryController;
+            let tests = [
+                {query: "abcde", value: "abcd"},
+                {query: "abcd-", value: "abcde"},
+                {query: "abcde", value: "abced"},
+            ];
+
+            for (let {query, value} of tests) {
+                matcher.reset();
+                matcher.addValues([value]);
+                let results = matcher.executeQuery(query);
+                InspectorTest.expectThat(!results.length, `Query "${query}" shouldn't match "${value}".`);
+            }
+        }
+    });
+
+    suite.addTestCase({
+        name: "ExecuteQueryMatchesExpectedCharacters",
+        description: "Should match the expected query characters.",
+        test() {
+            let matcher = new WI.CSSQueryController;
+            let tests = [
+                {
+                    query: "abcd",
+                    value: "abcde",
+                    expected: "abcd"
+                },
+                {
+                    query: "a-bcde",
+                    value: "abcde-abcde",
+                    expected: "a    - bcde"
+                },
+                {
+                    query: "abcde",
+                    value: "AaBbCcDdEe",
+                    expected: "A B C D E"
+                },
+                {
+                    query: "abcde",
+                    value: "AbcdBcdCdDe",
+                    expected: "A   B  C De"
+                },
+                {
+                    query: "abcdex",
+                    value: "AxBxCxdxexDxyxEF",
+                    expected: "A B C d ex"
+                },
+                {
+                    query: "bc",
+                    value: "abCd",
+                    expected: " bC"
+                },
+                {
+                    query: "bb",
+                    value: "abBc",
+                    expected: " bB"
+                }
+            ];
+
+            function createMatchesMask(queryResult) {
+                let value = queryResult.value;
+                let lastIndex = -1;
+                let result = "";
+
+                for (let match of queryResult.matches) {
+                    let gap = " ".repeat(match.index - lastIndex - 1);
+                    result += gap;
+                    result += value[match.index];
+                    lastIndex = match.index;
+                }
+
+                return result;
+            }
+
+            for (let {query, value, expected} of tests) {
+                matcher.reset();
+                matcher.addValues([value]);
+
+                let results = matcher.executeQuery(query);
+                InspectorTest.assert(results.length === 1, "Should return exactly one match.");
+                let actual = results.length ? createMatchesMask(results[0]) : null;
+                InspectorTest.expectEqual(actual, expected, `Query "${query}" should match "${expected}" in "${value}".`);
+            }
+        }
+    });
+
+    suite.addTestCase({
+        name: "ExecuteQueryGeneralRankings",
+        description: "Check that query results are ranked by descending relevance.",
+        test() {
+            // Values in order of descending rank.
+            let values = ["AbCdE", "AbcDe", "abcde", "AxbcDe", "AxBxCxDxEx", "AxbxcDe", "xabcde"];
+            let matcher = new WI.CSSQueryController(values);
+            let query = "abcde";
+            let results = matcher.executeQuery(query);
+            let resultValues = results.map((result) => result.value);
+            InspectorTest.expectShallowEqual(resultValues, values, "Results should be ranked by descending relevancy.");
+        }
+    });
+
+    suite.addTestCase({
+        name: "ExecuteQueryPositionRankings",
+        description: "Check that matches close to the beginning of the filename rank higher.",
+        test() {
+            // Values in order of descending rank.
+            let values = ["bcd", "BxCxDx", "AxBxCxDx", "abcd", "xxxAxxxBxxxCxxxD"];
+            let matcher = new WI.CSSQueryController(values);
+            let query = "bcd";
+            let results = matcher.executeQuery(query);
+            let resultValues = results.map((result) => result.value);
+            InspectorTest.expectShallowEqual(resultValues, values, "Results should be ranked by descending relevancy.");
+        }
+    });
+
+    suite.addTestCase({
+        name: "GetMatchingTextRanges",
+        description: "Check that query result TextRanges are correct.",
+        test() {
+            function textRange(start, end) {
+                return new WI.TextRange(0, start, 0, end);
+            }
+
+            let matcher = new WI.CSSQueryController;
+            let tests = [
+                {value: "a", ranges: []},
+                {value: "abcde", ranges: [textRange(0, 5)]},
+                {value: "AxBxCxDe", ranges: [textRange(0, 1), textRange(2, 3), textRange(4, 5), textRange(6, 8)]},
+            ];
+
+            for (let {value, ranges} of tests) {
+                matcher.reset();
+                matcher.addValues([value]);
+
+                let results = matcher.executeQuery("abcde");
+                let resultTextRanges = results.length ? results[0].matchingTextRanges : [];
+                InspectorTest.expectEqual(JSON.stringify(resultTextRanges), JSON.stringify(ranges), "Result TextRanges should match the expected ranges.");
+            }
+        }
+    });
+
+    suite.runTestCasesAndFinish();
+}
+</script>
+</head>
+<body _onload_="runTest()">
+    <p>Testing WI.CSSQueryController</p>
+</body>
+</html>

Modified: trunk/LayoutTests/inspector/unit-tests/string-utilities-expected.txt (286610 => 286611)


--- trunk/LayoutTests/inspector/unit-tests/string-utilities-expected.txt	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/LayoutTests/inspector/unit-tests/string-utilities-expected.txt	2021-12-07 20:51:52 UTC (rev 286611)
@@ -70,3 +70,21 @@
 PASS: The letter 'c' and 'e' are escaped.
 PASS: The letter 'c', 'd', and 'e' are escaped.
 
+-- Running test case: String.prototype.isLowerCase
+PASS: String with single lowercase character should be lowercase.
+PASS: String with multiple lowercase characters should be lowercase.
+PASS: String with single uppercase character should not be lowercase.
+PASS: String with mixed case characters should not be lowercase.
+PASS: Empty string should not be lowercase.
+PASS: String with non-alpha character should not be lowercase.
+PASS: String with numeric character should not be lowercase.
+
+-- Running test case: String.prototype.isUpperCase
+PASS: String with single uppercase character should be uppercase.
+PASS: String with multiple uppercase characters should be uppercase.
+PASS: String with single lowercase character should not be uppercase.
+PASS: String with mixed case characters should not be uppercase.
+PASS: Empty string should not be uppercase.
+PASS: String with non-alpha character should not be uppercase.
+PASS: String with numeric character should not be uppercase.
+

Modified: trunk/LayoutTests/inspector/unit-tests/string-utilities.html (286610 => 286611)


--- trunk/LayoutTests/inspector/unit-tests/string-utilities.html	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/LayoutTests/inspector/unit-tests/string-utilities.html	2021-12-07 20:51:52 UTC (rev 286611)
@@ -125,6 +125,36 @@
         }
     });
 
+    suite.addTestCase({
+        name: "String.prototype.isLowerCase",
+        test() {
+            InspectorTest.expectTrue("a".isLowerCase(), "String with single lowercase character should be lowercase.");
+            InspectorTest.expectTrue("abc".isLowerCase(), "String with multiple lowercase characters should be lowercase.");
+            InspectorTest.expectFalse("A".isLowerCase(), "String with single uppercase character should not be lowercase.");
+            InspectorTest.expectFalse("aBc".isLowerCase(), "String with mixed case characters should not be lowercase.");
+            InspectorTest.expectFalse("".isLowerCase(), "Empty string should not be lowercase.");
+            InspectorTest.expectFalse(".".isLowerCase(), "String with non-alpha character should not be lowercase.");
+            InspectorTest.expectFalse("1".isLowerCase(), "String with numeric character should not be lowercase.");
+
+            return true;
+        }
+    });
+
+    suite.addTestCase({
+        name: "String.prototype.isUpperCase",
+        test() {
+            InspectorTest.expectTrue("A".isUpperCase(), "String with single uppercase character should be uppercase.");
+            InspectorTest.expectTrue("ABC".isUpperCase(), "String with multiple uppercase characters should be uppercase.");
+            InspectorTest.expectFalse("a".isUpperCase(), "String with single lowercase character should not be uppercase.");
+            InspectorTest.expectFalse("AbC".isUpperCase(), "String with mixed case characters should not be uppercase.");
+            InspectorTest.expectFalse("".isUpperCase(), "Empty string should not be uppercase.");
+            InspectorTest.expectFalse(".".isUpperCase(), "String with non-alpha character should not be uppercase.");
+            InspectorTest.expectFalse("1".isUpperCase(), "String with numeric character should not be uppercase.");
+
+            return true;
+        }
+    });
+
     suite.runTestCasesAndFinish();
 }
 </script>

Modified: trunk/Source/WebInspectorUI/ChangeLog (286610 => 286611)


--- trunk/Source/WebInspectorUI/ChangeLog	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/ChangeLog	2021-12-07 20:51:52 UTC (rev 286611)
@@ -1,3 +1,121 @@
+2021-12-07  Razvan Caliman  <[email protected]>
+
+        Web Inspector: Support fuzzy matching in CSS completions
+        https://bugs.webkit.org/show_bug.cgi?id=230351
+        <rdar://82976292>
+
+        Reviewed by Devin Rousso.
+
+        Use fuzzy matching for identifying CSS completions in the Styles details sidebar.
+
+        There are three main parts to this patch:
+        1. Introduce `WI.CSSQueryController` with logic to do fuzzy matching on provided values.
+        2. Change `WI.CompletionSuggestionsView` to support both plain strings and `WI.QueryResult`s.
+        3. Change `WI.SpreadsheetTextField` so its `value` doesn't always return its `element.textContent`.
+
+        With fuzzy matching, completions are not guaranteed anymore to be prefixed with the query.
+        Therefore, it's no longer viable to rely on `WI.SpreadsheetTextField.value` being a concatenation of `textContent` of nodes within. 
+
+        To adress this, there's now `WI.SpreadsheetTextField._pendingValue` which includes the prospective
+        completion regardless of whether the query is a completion prefix or at match at any other position.
+
+        * Localizations/en.lproj/localizedStrings.js:
+        * UserInterface/Base/Setting.js:
+        Add a flag to enable the fuzzy matching feature.
+
+        * UserInterface/Controllers/CSSQueryController.js: Added.
+        (WI.CSSQueryController):
+        (WI.CSSQueryController.prototype.addValues):
+        (WI.CSSQueryController.prototype.reset):
+        (WI.CSSQueryController.prototype.executeQuery):
+        (WI.CSSQueryController.prototype._findSpecialCharacterIndices):
+        Add a speclialized class to hold the logic for fuzzy matching of CSS properties and values.
+        It clones logic from `WI.ResouceQueryController` with adjustments specific to CSS; more to follow as the feature gets refined.
+
+        * UserInterface/Main.html:
+        * UserInterface/Models/CSSCompletions.js:
+        (WI.CSSCompletions):
+        (WI.CSSCompletions.prototype.addValues):
+        (WI.CSSCompletions.prototype.executeQuery):
+        Support both the current prefix matching approach as well as fuzzy matching.
+
+        * UserInterface/Models/CSSKeywordCompletions.js:
+        (WI.CSSKeywordCompletions.forPartialPropertyName):
+        Opt into fuzzy matching when getting completions for property names and values. 
+
+        * UserInterface/Models/QueryResult.js:
+        (WI.QueryResult.prototype.get matches):
+        * UserInterface/Test.html:
+
+        * UserInterface/Views/CompletionSuggestionsView.css:
+        (.completion-suggestions-container > .item > .highlighted):
+        Highlight specific characters that matched in result identified by fuzzy matching.
+
+        * UserInterface/Views/CompletionSuggestionsView.js:
+        (WI.CompletionSuggestionsView.prototype.selectNext):
+        (WI.CompletionSuggestionsView.prototype.selectPrevious):
+        (WI.CompletionSuggestionsView.prototype.update):
+        (WI.CompletionSuggestionsView.prototype.getCompletionText):
+        (WI.CompletionSuggestionsView.prototype._createHighlightedCompletionFragment):
+        Change `WI.CompletionSuggestionsView` to hold a list of completions, 
+        either strings or `WI.QueryResult`, and return the appropriate completion text
+        instead of returning the `textContent` of the selected element.
+
+        `WI.CompletionSuggestionsView` is used elsewhere in the Console and Sources panel
+        so avoid impacting those consumers until they opt in to fuzzy matching as well.
+
+        * UserInterface/Views/SettingsTabContentView.js:
+
+        * UserInterface/Views/SpreadsheetStyleProperty.js:
+        (WI.SpreadsheetStyleProperty.prototype._handleNameChange):
+        (WI.SpreadsheetStyleProperty.prototype._handleValueChange):
+        (WI.SpreadsheetStyleProperty.prototype._nameCompletionDataProvider):
+        (WI.SpreadsheetStyleProperty.prototype._valueCompletionDataProvider):
+        On change, get the value of the `WI.SpreadsheetTextField` instead of 
+        the corresponding element's `textContent`.
+
+        * UserInterface/Views/SpreadsheetTextField.js:
+        Introduced `SpreadsheetTextField._completionPrefix` to hold the query a user is typing.
+
+        Introduced `SpreadsheetTextField._completionText` to hold the completion text of the selected but
+        not yet applied completion. This replaces the previous behavior of concatenating the query and `suggestionHint`
+        because with fuzzy matching the completion isn't guaranteed to be prefixed with the query.
+
+        Introduced `SpreadsheetTextField._pendingValue` to hold the result of applying the selected
+        completion. This replaces the previous behavior of `WI.SpreadsheetTextField._combineEditorElementChildren`.
+
+        (WI.SpreadsheetTextField):
+        (WI.SpreadsheetTextField.prototype.get value):
+        Change `WI.SpreadsheetTextField` so its value is divorced from its element's `textContent`.
+        While editing, `WI.SpreadsheetTextField.value` returns the pending value so that
+        a valid CSS string gets written to the stylesheet.
+
+        (WI.SpreadsheetTextField.prototype.set suggestionHint):
+        If the query is a prefix for the selected completion, keep the existing behavior of appending
+        the suggestion hint substring. Otherwise, hide the suggestion hint element because the
+        concatenation doesn't make sense.
+
+        (WI.SpreadsheetTextField.prototype.stopEditing):
+        (WI.SpreadsheetTextField.prototype.discardCompletion):
+        (WI.SpreadsheetTextField.prototype.completionSuggestionsSelectedCompletion):
+        (WI.SpreadsheetTextField.prototype.completionSuggestionsClickedCompletion):
+        (WI.SpreadsheetTextField.prototype._discardChange):
+        (WI.SpreadsheetTextField.prototype._handleBlur):
+        (WI.SpreadsheetTextField.prototype._handleKeyDown):
+        (WI.SpreadsheetTextField.prototype._handleKeyDownForSuggestionView):
+        (WI.SpreadsheetTextField.prototype._handleInput):
+        (WI.SpreadsheetTextField.prototype._updateCompletions):
+        (WI.SpreadsheetTextField.prototype._applyPendingValue):
+        After applying a completion, the value is replaced with `WI.SpreadsheetTextField._pendingValue`.
+        At this point, `WI.SpreadsheetTextField.value` and the `textContent` of the element converge.
+
+        (WI.SpreadsheetTextField.prototype._updatePendingValueWithCompletionText):
+        When presented with a completion, the query gets substituted with the full completion text in 
+        `WI.SpreadsheetTextField._pendingValue` regardless of whether the query is a prefix or not.
+
+        (WI.SpreadsheetTextField.prototype._applyCompletionHint): Deleted.
+        (WI.SpreadsheetTextField.prototype._combineEditorElementChildren): Deleted.
+
 2021-12-06  Patrick Angle  <[email protected]>
 
         Web Inspector: Support Cascade Layers in the Styles sidebar

Modified: trunk/Source/WebInspectorUI/Localizations/en.lproj/localizedStrings.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/Localizations/en.lproj/localizedStrings.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/Localizations/en.lproj/localizedStrings.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -1607,6 +1607,7 @@
 localizedStrings["Use Default Appearance"] = "Use Default Appearance";
 localizedStrings["Use Mock Capture Devices"] = "Use Mock Capture Devices";
 localizedStrings["Use default media styles"] = "Use default media styles";
+localizedStrings["Use fuzzy matching for completion suggestions"] = "Use fuzzy matching for completion suggestions";
 localizedStrings["Use the resource cache when loading resources"] = "Use the resource cache when loading resources";
 localizedStrings["User Agent"] = "User Agent";
 localizedStrings["User Agent Style Sheet"] = "User Agent Style Sheet";

Modified: trunk/Source/WebInspectorUI/UserInterface/Base/Setting.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Base/Setting.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Base/Setting.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -231,6 +231,7 @@
     experimentalEnableStylesJumpToVariableDeclaration: new WI.Setting("experimental-styles-jump-to-variable-declaration", false),
     experimentalCollapseBlackboxedCallFrames: new WI.Setting("experimental-collapse-blackboxed-call-frames", false),
     experimentalAllowInspectingInspector: new WI.Setting("experimental-allow-inspecting-inspector", false),
+    experimentalCSSCompletionFuzzyMatching: new WI.Setting("experimental-css-completion-fuzzy-matching", false),
 
     // Protocol
     protocolLogAsText: new WI.Setting("protocol-log-as-text", false),

Modified: trunk/Source/WebInspectorUI/UserInterface/Base/Utilities.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Base/Utilities.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Base/Utilities.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -773,7 +773,7 @@
 {
     value()
     {
-        return String(this) === this.toLowerCase();
+        return /^[a-z]+$/.test(this);
     }
 });
 
@@ -781,7 +781,7 @@
 {
     value()
     {
-        return String(this) === this.toUpperCase();
+        return /^[A-Z]+$/.test(this);
     }
 });
 

Added: trunk/Source/WebInspectorUI/UserInterface/Controllers/CSSQueryController.js (0 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Controllers/CSSQueryController.js	                        (rev 0)
+++ trunk/Source/WebInspectorUI/UserInterface/Controllers/CSSQueryController.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2021 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+WI.CSSQueryController = class CSSQueryController extends WI.QueryController
+{
+    constructor(values)
+    {
+        console.assert(Array.isArray(values), values);
+
+        super();
+
+        this._values = values || [];
+        this._cachedSpecialCharacterIndicesForValueMap = new Map;
+    }
+
+    // Public
+
+    addValues(values)
+    {
+        console.assert(Array.isArray(values), values);
+        if (!values.length)
+            return;
+
+        this._values.pushAll(values);
+    }
+
+    reset()
+    {
+        this._values = [];
+        this._cachedSpecialCharacterIndicesForValueMap.clear();
+    }
+
+    executeQuery(query)
+    {
+        if (!query || !this._values.length)
+            return [];
+
+        query = query.toLowerCase();
+
+        let results = [];
+
+        for (let value of this._values) {
+            if (!this._cachedSpecialCharacterIndicesForValueMap.has(value))
+                this._cachedSpecialCharacterIndicesForValueMap.set(value, this._findSpecialCharacterIndices(value));
+
+            let matches = this.findQueryMatches(query, value.toLowerCase(), this._cachedSpecialCharacterIndicesForValueMap.get(value));
+            if (matches.length)
+                results.push(new WI.QueryResult(value, matches));
+        }
+
+        return results.sort((a, b) => {
+            if (a.rank === b.rank)
+                return a.value.extendedLocaleCompare(b.value);
+            return b.rank - a.rank;
+        });
+    }
+
+    // Private
+
+    _findSpecialCharacterIndices(string)
+    {
+        if (!string.length)
+            return [];
+
+        const separators = "-_";
+
+        // Special characters include the following:
+        // 1. The first character.
+        // 2. Uppercase characters that follow a lowercase letter.
+        // 3. Separators and the first character following the separator.
+        let indices = [0];
+
+        for (let i = 1; i < string.length; ++i) {
+            let character = string[i];
+            let isSpecial = false;
+
+            if (separators.includes(character))
+                isSpecial = true;
+            else {
+                let previousCharacter = string[i - 1];
+                if (separators.includes(previousCharacter))
+                    isSpecial = true;
+                else if (character.isUpperCase() && previousCharacter.isLowerCase())
+                    isSpecial = true;
+            }
+
+            if (isSpecial)
+                indices.push(i);
+        }
+
+        return indices;
+    }
+};

Modified: trunk/Source/WebInspectorUI/UserInterface/Main.html (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Main.html	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Main.html	2021-12-07 20:51:52 UTC (rev 286611)
@@ -927,6 +927,7 @@
     <script src=""
     <script src=""
     <script src=""
+    <script src=""
     <script src=""
     <script src=""
     <script src=""

Modified: trunk/Source/WebInspectorUI/UserInterface/Models/CSSCompletions.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Models/CSSCompletions.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Models/CSSCompletions.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -58,6 +58,7 @@
         this._values.sort();
 
         this._acceptEmptyPrefix = acceptEmptyPrefix;
+        this._queryController = null;
     }
 
     // Static
@@ -253,8 +254,17 @@
 
         this._values.pushAll(values);
         this._values.sort();
+
+        this._queryController?.addValues(values);
     }
 
+    executeQuery(query)
+    {
+        this._queryController ||= new WI.CSSQueryController(this._values);
+
+        return this._queryController.executeQuery(query);
+    }
+
     startsWith(prefix)
     {
         if (!prefix)

Modified: trunk/Source/WebInspectorUI/UserInterface/Models/CSSKeywordCompletions.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Models/CSSKeywordCompletions.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Models/CSSKeywordCompletions.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -31,7 +31,7 @@
 
 WI.CSSKeywordCompletions = {};
 
-WI.CSSKeywordCompletions.forPartialPropertyName = function(text, {caretPosition, allowEmptyPrefix} = {})
+WI.CSSKeywordCompletions.forPartialPropertyName = function(text, {caretPosition, allowEmptyPrefix, useFuzzyMatching} = {})
 {
     caretPosition ??= text.length;
     allowEmptyPrefix ??= false;
@@ -42,10 +42,17 @@
 
     if (!text.length && allowEmptyPrefix)
         return {prefix: text, completions: WI.CSSCompletions.cssNameCompletions.values};
-    return {prefix: text, completions:WI.CSSCompletions.cssNameCompletions.startsWith(text)};
+
+    let completions;
+    if (useFuzzyMatching)
+        completions = WI.CSSCompletions.cssNameCompletions.executeQuery(text);
+    else
+        completions = WI.CSSCompletions.cssNameCompletions.startsWith(text);
+
+    return {prefix: text, completions};
 };
 
-WI.CSSKeywordCompletions.forPartialPropertyValue = function(text, propertyName, {caretPosition, additionalFunctionValueCompletionsProvider} = {})
+WI.CSSKeywordCompletions.forPartialPropertyValue = function(text, propertyName, {caretPosition, additionalFunctionValueCompletionsProvider, useFuzzyMatching} = {})
 {
     caretPosition ??= text.length;
 
@@ -86,10 +93,15 @@
     if ((caretIsInMiddleOfToken && currentTokenValue.length) || (!caretIsInMiddleOfToken && tokenAfterCaret && /[a-zA-Z0-9-]/.test(tokenAfterCaret.value[0])))
         return {prefix: "", completions: []};
 
-    // If the current token value is a comma or opening parenthesis, treat it as if we are at the start of a new token.
+    // If the current token value is a comma or open parenthesis, treat it as if we are at the start of a new token.
     if (currentTokenValue === "(" || currentTokenValue === ",")
         currentTokenValue = "";
 
+    // It's not valid CSS to append completions immediately after a closing parenthesis.
+    let tokenBeforeCaret = tokens[indexOfTokenAtCaret - 1];
+    if (currentTokenValue === ")" || tokenBeforeCaret?.value === ")")
+        return {prefix: "", completions: []};
+
     let functionName = null;
     let preceedingFunctionDepth = 0;
     for (let i = indexOfTokenAtCaret; i >= 0; --i) {
@@ -108,14 +120,20 @@
         }
     }
 
+    let valueCompletions;
     if (functionName) {
-        let completions = WI.CSSKeywordCompletions.forFunction(functionName);
-        let contextualValueCompletions = additionalFunctionValueCompletionsProvider?.(functionName) || [];
-        completions.addValues(contextualValueCompletions);
-        return {prefix: currentTokenValue, completions: completions.startsWith(currentTokenValue)};
-    }
+        valueCompletions = WI.CSSKeywordCompletions.forFunction(functionName);
+        valueCompletions.addValues(additionalFunctionValueCompletionsProvider?.(functionName) ?? []);
+    } else
+        valueCompletions = WI.CSSKeywordCompletions.forProperty(propertyName);
 
-    return {prefix: currentTokenValue, completions: WI.CSSKeywordCompletions.forProperty(propertyName).startsWith(currentTokenValue)};
+    let completions;
+    if (useFuzzyMatching)
+        completions = valueCompletions.executeQuery(currentTokenValue);
+    else
+        completions = valueCompletions.startsWith(currentTokenValue);
+
+    return {prefix: currentTokenValue, completions};
 };
 
 WI.CSSKeywordCompletions.forProperty = function(propertyName)

Modified: trunk/Source/WebInspectorUI/UserInterface/Models/QueryResult.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Models/QueryResult.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Models/QueryResult.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -36,6 +36,7 @@
     // Public
 
     get value() { return this._value; }
+    get matches() { return this._matches; }
 
     get rank()
     {

Modified: trunk/Source/WebInspectorUI/UserInterface/Test.html (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Test.html	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Test.html	2021-12-07 20:51:52 UTC (rev 286611)
@@ -253,6 +253,7 @@
     <script src=""
     <script src=""
     <script src=""
+    <script src=""
     <script src=""
     <script src=""
     <script src=""

Modified: trunk/Source/WebInspectorUI/UserInterface/Views/CompletionSuggestionsView.css (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Views/CompletionSuggestionsView.css	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Views/CompletionSuggestionsView.css	2021-12-07 20:51:52 UTC (rev 286611)
@@ -70,6 +70,10 @@
     color: var(--text-color);
 }
 
+.completion-suggestions-container > .item > .matched {
+    font-weight: bold;
+}
+
 .completion-suggestions-container:not(:active) > .item.selected,
 .completion-suggestions-container > .item:active {
     background-color: var(--selected-background-color);

Modified: trunk/Source/WebInspectorUI/UserInterface/Views/CompletionSuggestionsView.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Views/CompletionSuggestionsView.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Views/CompletionSuggestionsView.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -32,6 +32,7 @@
         this._delegate = delegate || null;
         this._preventBlur = preventBlur || false;
 
+        this._completions = [];
         this._selectedIndex = NaN;
         this._moveIntervalIdentifier = null;
 
@@ -89,9 +90,8 @@
         else
             ++this.selectedIndex;
 
-        var selectedItemElement = this._selectedItemElement;
-        if (selectedItemElement && this._delegate && typeof this._delegate.completionSuggestionsSelectedCompletion === "function")
-            this._delegate.completionSuggestionsSelectedCompletion(this, selectedItemElement.textContent);
+        if (this._completions[this.selectedIndex])
+            this._delegate?.completionSuggestionsSelectedCompletion?.(this, this.getCompletionText(this._completions[this.selectedIndex]));
     }
 
     selectPrevious()
@@ -101,9 +101,8 @@
         else
             --this.selectedIndex;
 
-        var selectedItemElement = this._selectedItemElement;
-        if (selectedItemElement && this._delegate && typeof this._delegate.completionSuggestionsSelectedCompletion === "function")
-            this._delegate.completionSuggestionsSelectedCompletion(this, selectedItemElement.textContent);
+        if (this._completions[this.selectedIndex])
+            this._delegate?.completionSuggestionsSelectedCompletion?.(this, this.getCompletionText(this._completions[this.selectedIndex]));
     }
 
     isHandlingClickEvent()
@@ -175,22 +174,39 @@
     update(completions, selectedIndex)
     {
         this._containerElement.removeChildren();
+        this._completions = completions;
 
         if (typeof selectedIndex === "number")
             this._selectedIndex = selectedIndex;
 
-        for (var i = 0; i < completions.length; ++i) {
+        for (let [index, completion] of completions.entries()) {
             var itemElement = document.createElement("div");
             itemElement.classList.add("item");
-            itemElement.classList.toggle("selected", i === this._selectedIndex);
-            itemElement.textContent = completions[i];
+            itemElement.classList.toggle("selected", index === this._selectedIndex);
+
+            if (typeof completion === "string")
+                itemElement.textContent = completion;
+            else if (completion instanceof WI.QueryResult)
+                itemElement.appendChild(this._createMatchedCompletionFragment(completion.value, completion.matchingTextRanges));
+
             this._containerElement.appendChild(itemElement);
-
-            if (this._delegate && typeof this._delegate.completionSuggestionsViewCustomizeCompletionElement === "function")
-                this._delegate.completionSuggestionsViewCustomizeCompletionElement(this, itemElement, completions[i]);
+            this._delegate?.completionSuggestionsViewCustomizeCompletionElement?.(this, itemElement, completion);
         }
     }
 
+    getCompletionText(completion)
+    {
+        console.assert(typeof completion === "string" || completion instanceof WI.QueryResult, completion);
+
+        if (typeof completion === "string")
+            return completion;
+
+        if (completion instanceof WI.QueryResult)
+            return completion.value;
+
+        return "";
+    }
+
     // Private
 
     get _selectedItemElement()
@@ -203,6 +219,31 @@
         return element;
     }
 
+    _createMatchedCompletionFragment(completionText, matchingTextRanges)
+    {
+        let completionFragment = document.createDocumentFragment();
+        let lastIndex = 0;
+        for (let textRange of matchingTextRanges) {
+            console.assert(textRange.startColumn >= 0 && textRange.startColumn < completionText.length, textRange);
+            console.assert(textRange.endColumn > 0 && textRange.endColumn <= completionText.length, textRange);
+            console.assert(textRange.startColumn < textRange.endColumn);
+
+            if (textRange.startColumn > lastIndex)
+                completionFragment.append(completionText.substring(lastIndex, textRange.startColumn));
+
+            let matchedSpan = document.createElement("span");
+            matchedSpan.classList.add("matched");
+            matchedSpan.append(completionText.substring(textRange.startColumn, textRange.endColumn));
+            completionFragment.append(matchedSpan);
+            lastIndex = textRange.endColumn;
+        }
+
+        if (lastIndex < completionText.length)
+            completionFragment.append(completionText.substring(lastIndex, completionText.length));
+
+        return completionFragment;
+    }
+
     _mouseDown(event)
     {
         if (event.button !== 0)

Modified: trunk/Source/WebInspectorUI/UserInterface/Views/SettingsTabContentView.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Views/SettingsTabContentView.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Views/SettingsTabContentView.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -397,6 +397,7 @@
             let stylesGroup = experimentalSettingsView.addGroup(WI.UIString("Styles:"));
             stylesGroup.addSetting(WI.settings.experimentalEnableStylesJumpToEffective, WI.UIString("Show jump to effective property button"));
             stylesGroup.addSetting(WI.settings.experimentalEnableStylesJumpToVariableDeclaration, WI.UIString("Show jump to variable declaration button"));
+            stylesGroup.addSetting(WI.settings.experimentalCSSCompletionFuzzyMatching, WI.UIString("Use fuzzy matching for completion suggestions"));
 
             experimentalSettingsView.addSeparator();
         }

Modified: trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetStyleProperty.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetStyleProperty.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetStyleProperty.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -936,12 +936,12 @@
 
     _handleNameChange()
     {
-        this._property.name = this._nameElement.textContent.trim();
+        this._property.name = this._nameTextField.value;
     }
 
     _handleValueChange()
     {
-        let value = this._valueElement.textContent;
+        let value = this._valueTextField.value;
 
         this._property.rawValue = value.trim();
 
@@ -975,9 +975,9 @@
         }
     }
 
-    _nameCompletionDataProvider(text, {caretPosition, allowEmptyPrefix} = {})
+    _nameCompletionDataProvider(text, options = {})
     {
-        return WI.CSSKeywordCompletions.forPartialPropertyName(text, {caretPosition, allowEmptyPrefix});
+        return WI.CSSKeywordCompletions.forPartialPropertyName(text, options);
     }
 
     _handleValueBeforeInput(event)
@@ -999,9 +999,10 @@
         this.spreadsheetTextFieldDidCommit(this._valueTextField, {direction: "forward"});
     }
 
-    _valueCompletionDataProvider(text, {caretPosition, allowEmptyPrefix} = {})
+    _valueCompletionDataProvider(text, options = {})
     {
-        return WI.CSSKeywordCompletions.forPartialPropertyValue(text, this._nameElement.textContent.trim(), {caretPosition, additionalFunctionValueCompletionsProvider: this.additionalFunctionValueCompletionsProvider.bind(this)});
+        options.additionalFunctionValueCompletionsProvider = this.additionalFunctionValueCompletionsProvider.bind(this);
+        return WI.CSSKeywordCompletions.forPartialPropertyValue(text, this._nameElement.textContent.trim(), options);
     }
 
     _setupJumpToSymbol(element)

Modified: trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetTextField.js (286610 => 286611)


--- trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetTextField.js	2021-12-07 20:51:03 UTC (rev 286610)
+++ trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetTextField.js	2021-12-07 20:51:52 UTC (rev 286611)
@@ -29,6 +29,7 @@
     {
         this._delegate = delegate;
         this._element = element;
+        this._pendingValue = null;
 
         this._completionProvider = completionProvider || null;
         if (this._completionProvider) {
@@ -52,6 +53,7 @@
         this._keyDownCaretPosition = -1;
         this._valueBeforeEditing = "";
         this._completionPrefix = "";
+        this._completionText = "";
         this._controlSpaceKeyboardShortcut = new WI.KeyboardShortcut(WI.KeyboardShortcut.Modifier.Control, WI.KeyboardShortcut.Key.Space);
     }
 
@@ -61,9 +63,18 @@
 
     get editing() { return this._editing; }
 
-    get value() { return this._element.textContent; }
-    set value(value) { this._element.textContent = value; }
+    get value()
+    {
+        return this._pendingValue ?? this._element.textContent;
+    }
 
+    set value(value)
+    {
+        this._element.textContent = value;
+
+        this._pendingValue = null;
+    }
+
     valueWithoutSuggestion()
     {
         // The suggestion could appear anywhere within the element, and the text of the element can span multiple nodes.
@@ -94,9 +105,6 @@
         }
 
         this._suggestionHintElement.remove();
-
-        // Removing the suggestion hint element may leave the contents of `_element` fragmented into multiple text nodes.
-        this._combineEditorElementChildren();
     }
 
     startEditing()
@@ -130,6 +138,8 @@
 
         this._editing = false;
         this._valueBeforeEditing = "";
+        this._pendingValue = null;
+        this._completionText = "";
         this._element.classList.remove("editing");
         this._element.contentEditable = false;
 
@@ -143,10 +153,16 @@
 
         this._suggestionsView.hide();
 
-        let hadSuggestionHint = !!this.suggestionHint;
+        let hadCompletionText = this._completionText.length > 0;
+
+        // Resetting the suggestion hint removes any suggestion hint element that is attached.
         this.suggestionHint = "";
-        if (hadSuggestionHint && this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
-            this._delegate.spreadsheetTextFieldDidChange(this);
+        this._completionText = "";
+
+        if (hadCompletionText) {
+            this._pendingValue = this.valueWithoutSuggestion();
+            this._delegate?.spreadsheetTextFieldDidChange?.(this);
+        }
     }
 
     detached()
@@ -157,22 +173,26 @@
 
     // CompletionSuggestionsView delegate
 
-    completionSuggestionsSelectedCompletion(suggestionsView, selectedText = "")
+    completionSuggestionsSelectedCompletion(suggestionsView, completionText = "")
     {
-        this.suggestionHint = selectedText.slice(this._completionPrefix.length);
+        this._completionText = completionText;
 
-        if (this.suggestionHint.length)
-            this._reAttachSuggestionHint();
+        if (this._completionText.startsWith(this._completionPrefix))
+            this.suggestionHint = this._completionText.slice(this._completionPrefix.length);
+        else
+            this.suggestionHint = "";
 
+        this._updatePendingValueWithCompletionText();
+
         if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
             this._delegate.spreadsheetTextFieldDidChange(this);
     }
 
-    completionSuggestionsClickedCompletion(suggestionsView, selectedText)
+    completionSuggestionsClickedCompletion(suggestionsView, completionText)
     {
-        this.suggestionHint = selectedText.slice(this._completionPrefix.length);
-
-        this._applyCompletionHint({moveCaretToEndOfCompletion: true});
+        this._completionText = completionText;
+        this._updatePendingValueWithCompletionText();
+        this._applyPendingValue({moveCaretToEndOfCompletion: true});
         this.discardCompletion();
 
         if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
@@ -219,7 +239,7 @@
         if (document.activeElement === this._element)
             return;
 
-        this._applyCompletionHint();
+        this._applyPendingValue();
         this.discardCompletion();
 
         let changed = this._valueBeforeEditing !== this.value;
@@ -252,7 +272,7 @@
         let isTabKey = event.key === "Tab";
         if (isEnterKey || isTabKey) {
             event.stop();
-            this._applyCompletionHint();
+            this._applyPendingValue();
 
             let direction = (isTabKey && event.shiftKey) ? "backward" : "forward";
 
@@ -338,12 +358,12 @@
             return true;
         }
 
-        if (event.key === "ArrowRight" && this.suggestionHint.length) {
+        if (event.key === "ArrowRight" && this._completionText.length) {
             let selection = window.getSelection();
 
             if (selection.isCollapsed) {
                 event.stop();
-                this._applyCompletionHint({moveCaretToEndOfCompletion: true});
+                this._applyPendingValue({moveCaretToEndOfCompletion: true});
 
                 // When completing "background", don't hide the completion popover.
                 // Continue showing the popover with properties such as "background-color" and "background-image".
@@ -358,21 +378,14 @@
 
         if (event.key === "Escape" && this._suggestionsView.visible) {
             event.stop();
-
-            let willChange = !!this.suggestionHint;
             this.discardCompletion();
 
-            if (willChange && this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
-                this._delegate.spreadsheetTextFieldDidChange(this);
-
             return true;
         }
 
-        if (event.key === "ArrowLeft" && (this.suggestionHint || this._suggestionsView.visible)) {
+        if (event.key === "ArrowLeft" && (this._completionText.length || this._suggestionsView.visible)) {
             this.discardCompletion();
 
-            if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
-                this._delegate.spreadsheetTextFieldDidChange(this);
             return true;
         }
 
@@ -405,6 +418,7 @@
         if (!this._editing)
             return;
 
+        this._pendingValue = this.valueWithoutSuggestion().trim();
         this._preventDiscardingCompletionsOnKeyUp = true;
         this._updateCompletions();
 
@@ -417,8 +431,9 @@
         if (!this._completionProvider)
             return;
 
+        let useFuzzyMatching = WI.settings.experimentalCSSCompletionFuzzyMatching.value;
         let valueWithoutSuggestion = this.valueWithoutSuggestion();
-        let {completions, prefix} = this._completionProvider(valueWithoutSuggestion, {allowEmptyPrefix: forceCompletions, caretPosition: this._getCaretPosition()});
+        let {completions, prefix} = this._completionProvider(valueWithoutSuggestion, {allowEmptyPrefix: forceCompletions, caretPosition: this._getCaretPosition(), useFuzzyMatching});
         this._completionPrefix = prefix;
 
         if (!completions.length) {
@@ -427,7 +442,7 @@
         }
 
         // No need to show the completion popover with only one item that matches the entered value.
-        if (completions.length === 1 && completions[0] === valueWithoutSuggestion) {
+        if (completions.length === 1 && this._suggestionsView.getCompletionText(completions[0]) === valueWithoutSuggestion) {
             this.discardCompletion();
             return;
         }
@@ -440,8 +455,9 @@
 
         this._suggestionsView.update(completions);
 
-        if (completions.length === 1) {
-            // No need to show the completion popover that matches the suggestion hint.
+        if (completions.length === 1 && this._suggestionsView.getCompletionText(completions[0]).startsWith(this._completionPrefix)) {
+            // No need to show the completion popover with only one item that begins with the completion prefix.
+            // When using fuzzy matching, the completion prefix may not occur at the beginning of the suggestion.
             this._suggestionsView.hide();
         } else
             this._showSuggestionsView();
@@ -530,23 +546,17 @@
         return range;
     }
 
-    _applyCompletionHint({moveCaretToEndOfCompletion} = {})
+    _applyPendingValue({moveCaretToEndOfCompletion} = {})
     {
-        if (!this._completionProvider || !this.suggestionHint)
+        if (!this._pendingValue)
             return;
 
-        this._combineEditorElementChildren({newCaretPosition: moveCaretToEndOfCompletion ? this._getCaretPosition() + this.suggestionHint.length : null});
-    }
+        let caretPosition = this._getCaretPosition();
+        let newCaretPosition = moveCaretToEndOfCompletion ? caretPosition - this._completionPrefix.length + this._completionText.length : caretPosition;
 
-    _combineEditorElementChildren({newCaretPosition} = {})
-    {
-        newCaretPosition ??= this._getCaretPosition();
+        // Setting the value collapses the text selection. Get the caret position before doing this.
+        this.value = this._pendingValue;
 
-        // Setting the textContent of the element to its current textContent will take the text from the multiple
-        // potential child nodes (potentially a suggestion hint node and some number of existing text nodes) and turn
-        // them into a single text node within the element.
-        this._element.textContent = this._element.textContent;
-
         if (this._element.textContent.length) {
             let textChildNode = this._element.firstChild;
             window.getSelection().setBaseAndExtent(textChildNode, newCaretPosition, textChildNode, newCaretPosition);
@@ -553,6 +563,14 @@
         }
     }
 
+    _updatePendingValueWithCompletionText()
+    {
+        let caretPosition = this._getCaretPosition();
+        let value = this.valueWithoutSuggestion();
+
+        this._pendingValue = value.slice(0, caretPosition - this._completionPrefix.length) + this._completionText + value.slice(caretPosition + 1, value.length);
+    }
+
     _reAttachSuggestionHint()
     {
         console.assert(this.suggestionHint.length, "Suggestion hint should not be empty when attaching the suggestion hint element.");
_______________________________________________
webkit-changes mailing list
[email protected]
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to