Bug 1430558 - add closing parenthesis automatically in CSS autocompletes;r=gl draft
authorJulian Descottes <jdescottes@mozilla.com>
Sat, 13 Jan 2018 00:41:19 +0100
changeset 776538 0108824983d3b1e810a1443ab7ef6895e7e6b343
parent 776537 7eb1d554882b521521e6549523ab5db452617a4a
push id104900
push userjdescottes@mozilla.com
push dateTue, 03 Apr 2018 09:15:05 +0000
reviewersgl
bugs1430558
milestone61.0a1
Bug 1430558 - add closing parenthesis automatically in CSS autocompletes;r=gl MozReview-Commit-ID: LLBrLC3Bq0t
devtools/client/inspector/markup/test/browser_markup_css_completion_style_attribute_02.js
devtools/client/shared/inplace-editor.js
devtools/client/shared/test/browser.ini
devtools/client/shared/test/browser_inplace-editor_autoclose_parentheses.js
devtools/client/shared/test/browser_inplace-editor_autocomplete_css_variable.js
--- a/devtools/client/inspector/markup/test/browser_markup_css_completion_style_attribute_02.js
+++ b/devtools/client/inspector/markup/test/browser_markup_css_completion_style_attribute_02.js
@@ -78,20 +78,20 @@ const TEST_DATA_INNER = [
   ["\"", "style=\"", 7, 7, false],
   ["b", "style=\"border", 8, 13, true],
   ["a", "style=\"background", 9, 17, true],
   ["VK_RIGHT", "style=\"background", 17, 17, false],
   [":", "style=\"background:aliceblue", 18, 27, true],
   ["u", "style=\"background:unset", 19, 23, true],
   ["r", "style=\"background:url", 20, 21, false],
   ["l", "style=\"background:url", 21, 21, false],
-  ["(", "style=\"background:url(", 22, 22, false],
-  ["'", "style=\"background:url('", 23, 23, false],
-  ["1", "style=\"background:url('1", 24, 24, false],
-  ["'", "style=\"background:url('1'", 25, 25, false],
+  ["(", "style=\"background:url()", 22, 22, false],
+  ["'", "style=\"background:url(')", 23, 23, false],
+  ["1", "style=\"background:url('1)", 24, 24, false],
+  ["'", "style=\"background:url('1')", 25, 25, false],
   [")", "style=\"background:url('1')", 26, 26, false],
   [";", "style=\"background:url('1');", 27, 27, false],
   [" ", "style=\"background:url('1'); ", 28, 28, false],
   ["c", "style=\"background:url('1'); color", 29, 33, true],
   ["VK_RIGHT", "style=\"background:url('1'); color", 33, 33, false],
   [":", "style=\"background:url('1'); color:aliceblue", 34, 43, true],
   ["b", "style=\"background:url('1'); color:beige", 35, 39, true],
   ["VK_RETURN", "style=\"background:url('1'); color:beige\"", -1, -1, false]
--- a/devtools/client/shared/inplace-editor.js
+++ b/devtools/client/shared/inplace-editor.js
@@ -21,16 +21,18 @@
  * See editableField() for more options.
  */
 
 "use strict";
 
 const Services = require("Services");
 const focusManager = Services.focus;
 const {KeyCodes} = require("devtools/client/shared/keycodes");
+const EventEmitter = require("devtools/shared/event-emitter");
+const { findMostRelevantCssPropertyIndex } = require("./suggestion-picker");
 
 loader.lazyRequireGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm", true);
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const CONTENT_TYPES = {
   PLAIN_TEXT: 0,
   CSS_VALUE: 1,
   CSS_MIXED: 2,
@@ -39,18 +41,20 @@ const CONTENT_TYPES = {
 
 // The limit of 500 autocomplete suggestions should not be reached but is kept
 // for safety.
 const MAX_POPUP_ENTRIES = 500;
 
 const FOCUS_FORWARD = focusManager.MOVEFOCUS_FORWARD;
 const FOCUS_BACKWARD = focusManager.MOVEFOCUS_BACKWARD;
 
-const EventEmitter = require("devtools/shared/event-emitter");
-const { findMostRelevantCssPropertyIndex } = require("./suggestion-picker");
+const WORD_REGEXP = /\w/;
+const isWordChar = function(str) {
+  return str && WORD_REGEXP.test(str);
+};
 
 /**
  * Helper to check if the provided key matches one of the expected keys.
  * Keys will be prefixed with DOM_VK_ and should match a key in KeyCodes.
  *
  * @param {String} key
  *        the key to check (can be a keyCode).
  * @param {...String} keys
@@ -1046,16 +1050,20 @@ InplaceEditor.prototype = {
    * Handle the input field's keypress event.
    */
   _onKeyPress: function(event) {
     let prevent = false;
 
     let key = event.keyCode;
     let input = this.input;
 
+    // We want to autoclose some characters, remember the pressed key in order to process
+    // it later on in maybeSuggestionCompletion().
+    this._pressedKey = event.key;
+
     let multilineNavigation = !this._isSingleLine() &&
       isKeyIn(key, "UP", "DOWN", "LEFT", "RIGHT");
     let isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT;
     let isPopupOpen = this.popup && this.popup.isOpen;
 
     let increment = 0;
     if (!isPlainText && !multilineNavigation) {
       increment = this._getIncrement(event);
@@ -1288,16 +1296,17 @@ InplaceEditor.prototype = {
    * @param {Boolean} autoInsert
    *        Pass true to automatically insert the most relevant suggestion.
    */
   _maybeSuggestCompletion: function(autoInsert) {
     // Input can be null in cases when you intantaneously switch out of it.
     if (!this.input) {
       return;
     }
+
     let preTimeoutQuery = this.input.value;
 
     // Since we are calling this method from a keypress event handler, the
     // |input.value| does not include currently typed character. Thus we perform
     // this method async.
     this._openPopupTimeout = this.doc.defaultView.setTimeout(() => {
       if (this._preventSuggestions) {
         this._preventSuggestions = false;
@@ -1470,23 +1479,69 @@ InplaceEditor.prototype = {
         let selectedIndex = autoInsert ? index : -1;
 
         // Open the suggestions popup.
         this.popup.setItems(finalList);
         this._openAutocompletePopup(offset, selectedIndex);
       } else {
         this._hideAutocompletePopup();
       }
+
+      this._autocloseParenthesis();
+
       // This emit is mainly for the purpose of making the test flow simpler.
       this.emit("after-suggest");
       this._doValidation();
     }, 0);
   },
 
   /**
+   * Automatically add closing parenthesis and skip closing parenthesis when needed.
+   */
+  _autocloseParenthesis: function() {
+    // Split the current value at the cursor index to rebuild the string.
+    let parts = this._splitStringAt(this.input.value, this.input.selectionStart);
+
+    // Lookup the character following the caret to know if the string should be modified.
+    let nextChar = parts[1][0];
+
+    // Autocomplete closing parenthesis if the last key pressed was "(" and the next
+    // character is not a "word" character.
+    if (this._pressedKey == "(" && !isWordChar(nextChar)) {
+      this._updateValue(parts[0] + ")" + parts[1]);
+    }
+
+    // Skip inserting ")" if the next character is already a ")" (note that we actually
+    // insert and remove the extra ")" here, as the input has already been modified).
+    if (this._pressedKey == ")" && nextChar == ")") {
+      this._updateValue(parts[0] + parts[1].substring(1));
+    }
+
+    this._pressedKey = null;
+  },
+
+  /**
+   * Update the current value of the input while preserving the caret position.
+   */
+  _updateValue: function(str) {
+    let start = this.input.selectionStart;
+    this.input.value = str;
+    this.input.setSelectionRange(start, start);
+    this._updateSize();
+  },
+
+  /**
+   * Split the provided string at the provided index. Returns an array of two strings.
+   * _splitStringAt("1234567", 3) will return ["123", "4567"]
+   */
+  _splitStringAt: function(str, index) {
+    return [str.substring(0, index), str.substring(index, str.length)];
+  },
+
+  /**
    * Check if the current input is displaying more than one line of text.
    *
    * @return {Boolean} true if the input has a single line of text
    */
   _isSingleLine: function() {
     let inputRect = this.input.getBoundingClientRect();
     return inputRect.height < 2 * this.inputCharDimensions.height;
   },
--- a/devtools/client/shared/test/browser.ini
+++ b/devtools/client/shared/test/browser.ini
@@ -142,16 +142,17 @@ skip-if = e10s # Bug 1221911, bug 122228
 [browser_html_tooltip_hover.js]
 [browser_html_tooltip_offset.js]
 [browser_html_tooltip_rtl.js]
 [browser_html_tooltip_variable-height.js]
 [browser_html_tooltip_width-auto.js]
 [browser_html_tooltip_xul-wrapper.js]
 [browser_inplace-editor-01.js]
 [browser_inplace-editor-02.js]
+[browser_inplace-editor_autoclose_parentheses.js]
 [browser_inplace-editor_autocomplete_01.js]
 [browser_inplace-editor_autocomplete_02.js]
 [browser_inplace-editor_autocomplete_offset.js]
 [browser_inplace-editor_autocomplete_css_variable.js]
 [browser_inplace-editor_maxwidth.js]
 [browser_keycodes.js]
 [browser_key_shortcuts.js]
 [browser_layoutHelpers.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_inplace-editor_autoclose_parentheses.js
@@ -0,0 +1,72 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* import-globals-from helper_inplace_editor.js */
+
+"use strict";
+
+const AutocompletePopup = require("devtools/client/shared/autocomplete-popup");
+const { InplaceEditor } = require("devtools/client/shared/inplace-editor");
+loadHelperScript("helper_inplace_editor.js");
+
+// Test the inplace-editor closes parentheses automatically.
+
+// format :
+//  [
+//    what key to press,
+//    expected input box value after keypress,
+//    selected suggestion index (-1 if popup is hidden),
+//    number of suggestions in the popup (0 if popup is hidden),
+//  ]
+const testData = [
+  ["u", "u", -1, 0],
+  ["r", "ur", -1, 0],
+  ["l", "url", -1, 0],
+  ["(", "url()", -1, 0],
+  ["v", "url(v)", -1, 0],
+  ["a", "url(va)", -1, 0],
+  ["r", "url(var)", -1, 0],
+  ["(", "url(var())", -1, 0],
+  ["-", "url(var(-))", -1, 0],
+  ["-", "url(var(--))", -1, 0],
+  ["a", "url(var(--a))", -1, 0],
+  [")", "url(var(--a))", -1, 0],
+  [")", "url(var(--a))", -1, 0],
+];
+
+add_task(function* () {
+  yield addTab("data:text/html;charset=utf-8," +
+    "inplace editor parentheses autoclose");
+  let [host, win, doc] = yield createHost();
+
+  let xulDocument = win.top.document;
+  let popup = new AutocompletePopup(xulDocument, { autoSelect: true });
+  yield new Promise(resolve => {
+    createInplaceEditorAndClick({
+      start: runPropertyAutocompletionTest,
+      contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE,
+      property: {
+        name: "background-image"
+      },
+      done: resolve,
+      popup: popup
+    }, doc);
+  });
+
+  popup.destroy();
+  host.destroy();
+  gBrowser.removeCurrentTab();
+});
+
+let runPropertyAutocompletionTest = Task.async(function* (editor) {
+  info("Starting to test for css property completion");
+
+  // No need to test autocompletion here, return an empty array.
+  editor._getCSSValuesForPropertyName = () => [];
+
+  for (let data of testData) {
+    yield testCompletion(data, editor);
+  }
+
+  EventUtils.synthesizeKey("VK_RETURN", {}, editor.input.defaultView);
+});
--- a/devtools/client/shared/test/browser_inplace-editor_autocomplete_css_variable.js
+++ b/devtools/client/shared/test/browser_inplace-editor_autocomplete_css_variable.js
@@ -28,26 +28,26 @@ const CSS_VARIABLES = [
 //    selected suggestion index (-1 if popup is hidden),
 //    number of suggestions in the popup (0 if popup is hidden),
 //    expected post label corresponding with the input box value,
 //  ]
 const testData = [
   ["v", "v", -1, 0, null],
   ["a", "va", -1, 0, null],
   ["r", "var", -1, 0, null],
-  ["(", "var(", -1, 0, null],
-  ["-", "var(--abc", 0, 4, "blue"],
-  ["VK_BACK_SPACE", "var(-", -1, 0, null],
-  ["-", "var(--abc", 0, 4, "blue"],
-  ["VK_DOWN", "var(--def", 1, 4, "red"],
-  ["VK_DOWN", "var(--ghi", 2, 4, "green"],
-  ["VK_DOWN", "var(--jkl", 3, 4, "yellow"],
-  ["VK_DOWN", "var(--abc", 0, 4, "blue"],
-  ["VK_DOWN", "var(--def", 1, 4, "red"],
-  ["VK_LEFT", "var(--def", -1, 0, null],
+  ["(", "var()", -1, 0, null],
+  ["-", "var(--abc)", 0, 4, "blue"],
+  ["VK_BACK_SPACE", "var(-)", -1, 0, null],
+  ["-", "var(--abc)", 0, 4, "blue"],
+  ["VK_DOWN", "var(--def)", 1, 4, "red"],
+  ["VK_DOWN", "var(--ghi)", 2, 4, "green"],
+  ["VK_DOWN", "var(--jkl)", 3, 4, "yellow"],
+  ["VK_DOWN", "var(--abc)", 0, 4, "blue"],
+  ["VK_DOWN", "var(--def)", 1, 4, "red"],
+  ["VK_LEFT", "var(--def)", -1, 0, null],
 ];
 
 const mockGetCSSValuesForPropertyName = function(propertyName) {
   return [];
 };
 
 add_task(async function() {
   await addTab("data:text/html;charset=utf-8," +