chiark / gitweb /
doxygen: autocomplete search input if all results have common prefix.
authorVladimír Vondruš <mosra@centrum.cz>
Wed, 2 Jan 2019 17:30:42 +0000 (18:30 +0100)
committerVladimír Vondruš <mosra@centrum.cz>
Wed, 2 Jan 2019 21:07:36 +0000 (22:07 +0100)
Handling of truncated UTF-8 was a fun side-task.

doxygen/search.js
doxygen/test/test-search.js

index a796b53097f818368f3ac66ae78406b04267f874..d0b9f7f605b66856b616de45d69fcaa2c9fbc732 100644 (file)
@@ -44,6 +44,11 @@ var Search = {
        not. We can't do that if we arrived directly on #search from outside. */
     canGoBackToHideSearch: false,
 
+    /* Autocompletion in the input field is whitelisted only for character
+       input (so not deletion, cut, or anything else). This is flipped in the
+       onkeypress event and reset after each oninput event. */
+    autocompleteNextInputEvent: false,
+
     init: function(buffer, maxResults) {
         let view = new DataView(buffer);
 
@@ -184,6 +189,41 @@ var Search = {
     toUtf8: function(string) { return unescape(encodeURIComponent(string)); },
     fromUtf8: function(string) { return decodeURIComponent(escape(string)); },
 
+    autocompletedCharsToString: function(chars) {
+        /* Strip incomplete UTF-8 chars from the autocompletion end */
+        for(let i = chars.length - 1; i >= 0; --i) {
+            let c = chars[i];
+
+            /* We're safe, finish */
+            if(
+                /* ASCII value at the end */
+                (c < 128 && i + 1 == chars.length) ||
+
+                /* Full two-byte character at the end */
+                ((c & 0xe0) == 0xc0 && i + 2 == chars.length) ||
+
+                /* Full three-byte character at the end */
+                ((c & 0xf0) == 0xe0 && i + 3 == chars.length) ||
+
+                /* Full four-byte character at the end */
+                ((c & 0xf8) == 0xf0 && i + 4 == chars.length)
+            ) break;
+
+            /* Continuing UTF-8 character, go further back */
+            if((c & 0xc0) == 0x80) continue;
+
+            /* Otherwise the character is not complete, drop it from the end */
+            chars.length = i;
+            break;
+        }
+
+        /* Convert the autocompleted UTF-8 sequence to a string */
+        let suggestedTabAutocompletionString = '';
+        for(let i = 0; i != chars.length; ++i)
+            suggestedTabAutocompletionString += String.fromCharCode(chars[i]);
+        return this.fromUtf8(suggestedTabAutocompletionString);
+    },
+
     /* Returns the values in UTF-8, but input is in whatever shitty 16bit
        encoding JS has */
     search: function(searchString) {
@@ -240,10 +280,11 @@ var Search = {
                 if(link)
                     link.href = link.dataset.searchEngine.replace('{query}', encodeURIComponent(searchString));
             }
-            return [];
+            return [[], ''];
         }
 
         /* Otherwise gather the results */
+        let suggestedTabAutocompletionChars = [];
         let results = [];
         let leaves = [[this.searchStack[this.searchStack.length - 1], 0]];
         while(leaves.length) {
@@ -259,7 +300,8 @@ var Search = {
                 results.push(this.gatherResult(index, suffixLength, 0xffffff)); /* should be enough haha */
 
                 /* 'nuff said. */
-                if(results.length >= this.maxResults) return results;
+                if(results.length >= this.maxResults)
+                    return [results, this.autocompletedCharsToString(suggestedTabAutocompletionChars)];
             }
 
             /* Dig deeper */
@@ -275,10 +317,21 @@ var Search = {
 
                 /* Append to the queue */
                 leaves.push([offsetBarrier & 0x007fffff, suffixLength + 1]);
+
+                /* We don't have anything yet and this is the only path
+                   forward, add the char to suggested Tab autocompletion. Can't
+                   extract it from the leftmost 8 bits of offsetBarrier because
+                   that would make it negative, have to load as Uint8 instead.
+                   Also can't use String.fromCharCode(), because later doing
+                   str.charCodeAt() would give me back UTF-16 values, which is
+                   absolutely unwanted when all I want is check for truncated
+                   UTF-8. */
+                if(!results.length && leaves.length == 1 && childCount == 1)
+                    suggestedTabAutocompletionChars.push(this.trie.getUint8(childOffset + j*4 + 3));
             }
         }
 
-        return results;
+        return [results, this.autocompletedCharsToString(suggestedTabAutocompletionChars)];
     },
 
     gatherResult: function(index, suffixLength, maxUrlPrefix) {
@@ -375,7 +428,7 @@ var Search = {
         return this.escape(name).replace(/[:=]/g, '&lrm;$&').replace(/(\)|&gt;|&amp;|\/)/g, '&lrm;$&&lrm;');
     },
 
-    renderResults: /* istanbul ignore next */ function(value, results) {
+    renderResults: /* istanbul ignore next */ function(value, resultsSuggestedTabAutocompletion) {
         /* Normalize the value and encode as UTF-8 so the slicing works
            properly */
         value = this.toUtf8(value.trim());
@@ -389,7 +442,10 @@ var Search = {
 
         document.getElementById('search-help').style.display = 'none';
 
-        if(results.length) {
+        /* Results found */
+        if(resultsSuggestedTabAutocompletion[0].length) {
+            let results = resultsSuggestedTabAutocompletion[0];
+
             document.getElementById('search-results').style.display = 'block';
             document.getElementById('search-notfound').style.display = 'none';
 
@@ -476,6 +532,18 @@ var Search = {
             document.getElementById('search-results').innerHTML = this.fromUtf8(list);
             document.getElementById('search-current').scrollIntoView(true);
 
+            /* Append the suggested tab autocompletion, if any, and if the user
+               didn't just delete it */
+            let searchInput = document.getElementById('search-input');
+            if(this.autocompleteNextInputEvent && resultsSuggestedTabAutocompletion[1].length && searchInput.selectionEnd == searchInput.value.length) {
+                let suggestedTabAutocompletion = this.fromUtf8(resultsSuggestedTabAutocompletion[1]);
+
+                let lengthBefore = searchInput.value.length;
+                searchInput.value += suggestedTabAutocompletion;
+                searchInput.setSelectionRange(lengthBefore, searchInput.value.length);
+            }
+
+        /* Nothing found */
         } else {
             document.getElementById('search-results').style.display = 'none';
             document.getElementById('search-notfound').style.display = 'block';
@@ -484,16 +552,20 @@ var Search = {
         /* Don't allow things to be selected just by motionless mouse cursor
            suddenly appearing over a search result */
         this.mouseMovedSinceLastRender = false;
+
+        /* Reset autocompletion, if it was allowed. It'll get whitelisted next
+           time a character gets inserted. */
+        this.autocompleteNextInputEvent = false;
     },
 
-    searchAndRender: function(value) {
+    searchAndRender: /* istanbul ignore next */ function(value) {
         let prev = performance.now();
         let results = this.search(value);
         let after = performance.now();
         this.renderResults(value, results);
         if(value.trim().length) {
             document.getElementById('search-symbolcount').innerHTML =
-                results.length + (results.length >= this.maxResults ? '+' : '') + " results (" + Math.round((after - prev)*10)/10 + " ms)";
+                results[0].length + (results.length >= this.maxResults ? '+' : '') + " results (" + Math.round((after - prev)*10)/10 + " ms)";
         } else
             document.getElementById('search-symbolcount').innerHTML =
                 this.symbolCount + " symbols (" + Math.round(this.dataSize/102.4)/10 + " kB)";
@@ -575,8 +647,15 @@ if(typeof document !== 'undefined') {
                 document.getElementById('search-input').focus();
                 return false; /* so T doesn't get entered into the box */
 
+            /* Fill in the autocompleted selection */
+            } else if(event.key == 'Tab' && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
+                /* But only if the input has selection at the end */
+                let input = document.getElementById('search-input');
+                if(input.selectionEnd == input.value.length && input.selectionStart != input.selectionEnd)
+                    input.setSelectionRange(input.value.length, input.value.length);
+
             /* Select next item */
-            } else if(event.key == 'ArrowDown' || (event.key == 'Tab' && !event.shiftKey)) {
+            } else if(event.key == 'ArrowDown') {
                 let current = document.getElementById('search-current');
                 if(current) {
                     let next = current.nextSibling;
@@ -589,7 +668,7 @@ if(typeof document !== 'undefined') {
                 return false; /* so the keypress doesn't affect input cursor */
 
             /* Select prev item */
-            } else if(event.key == 'ArrowUp' || (event.key == 'Tab' && event.shiftKey)) {
+            } else if(event.key == 'ArrowUp') {
                 let current = document.getElementById('search-current');
                 if(current) {
                     let prev = current.previousSibling;
@@ -610,6 +689,25 @@ if(typeof document !== 'undefined') {
                 document.body.style.overflow = 'auto';
                 document.body.style.paddingRight = '0';
                 return false; /* so the form doesn't get sent */
+
+            /* Looks like the user is inserting some text (and not cutting,
+               copying or whatever), allow autocompletion for the new
+               character. The oninput event resets this back to false, so this
+               basically whitelists only keyboard input, including Shift-key
+               and special chars using right Alt (or equivalent on Mac), but
+               excluding Ctrl-key, which is usually not for text input. In the
+               worst case the autocompletion won't be allowed ever, which is
+               much more acceptable behavior than having no ability to disable
+               it and annoying the users. See also this WONTFIX Android bug:
+               https://bugs.chromium.org/p/chromium/issues/detail?id=118639 */
+            } else if(event.key != 'Backspace' && event.key != 'Delete' && !event.metaKey && (!event.ctrlKey || event.altKey)) {
+                Search.autocompleteNextInputEvent = true;
+            /* Otherwise reset the flag, because when the user would press e.g.
+               the 'a' key and then e.g. ArrowRight (which doesn't trigger
+               oninput), a Backspace after would still result in
+               autocompleteNextInputEvent, because nothing reset it back. */
+            } else {
+                Search.autocompleteNextInputEvent = false;
             }
 
         /* Search hidden */
index 8886adcc8a1e12243873650e6c6c411a6b7b43da..ead76881707f348791e61773468df5187dc6e21b 100644 (file)
@@ -98,7 +98,7 @@ const { StringDecoder } = require('string_decoder');
     let buffer = fs.readFileSync(path.join(__dirname, "js-test-data/empty.bin"));
     assert.ok(Search.init(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)));
     assert.equal(Search.symbolCount, 0);
-    assert.deepEqual(Search.search(''), []);
+    assert.deepEqual(Search.search(''), [[], '']);
 }
 
 /* Search */
@@ -110,7 +110,7 @@ const { StringDecoder } = require('string_decoder');
     assert.equal(Search.maxResults, 100);
 
     /* Blow up */
-    let resultsForM = [
+    let resultsForM = [[
         { name: 'Math',
           url: 'namespaceMath.html',
           flags: 16,
@@ -126,11 +126,11 @@ const { StringDecoder } = require('string_decoder');
         { name: 'Math::Range::min() const',
           url: 'classMath_1_1Range.html#min',
           flags: 109,
-          suffixLength: 10 }];
+          suffixLength: 10 }], ''];
     assert.deepEqual(Search.search('m'), resultsForM);
 
     /* Add more characters */
-    assert.deepEqual(Search.search('min'), [
+    assert.deepEqual(Search.search('min'), [[
         { name: 'Math::min(int, int)',
           url: 'namespaceMath.html#min',
           flags: 105,
@@ -142,34 +142,34 @@ const { StringDecoder } = require('string_decoder');
         { name: 'Math::Range::min() const',
           url: 'classMath_1_1Range.html#min',
           flags: 109,
-          suffixLength: 8 }]);
+          suffixLength: 8 }], '()']);
 
     /* Go back, get the same thing */
     assert.deepEqual(Search.search('m'), resultsForM);
 
     /* Search for something else */
-    let resultsForVec = [
+    let resultsForVec = [[
         { name: 'Math::Vector',
           url: 'classMath_1_1Vector.html',
           flags: 40|2, /* Deprecated */
-          suffixLength: 3 }];
+          suffixLength: 3 }], 'tor'];
     assert.deepEqual(Search.search('vec'), resultsForVec);
 
     /* Uppercase things and spaces */
     assert.deepEqual(Search.search(' Vec  '), resultsForVec);
 
     /* Not found */
-    assert.deepEqual(Search.search('pizza'), []);
+    assert.deepEqual(Search.search('pizza'), [[], '']);
 
     /* UTF-8 decoding */
-    assert.deepEqual(Search.search('su'), [
+    assert.deepEqual(Search.search('su'), [[
         { name: Search.toUtf8('Page » Subpage'),
           url: 'subpage.html',
           flags: 192,
-          suffixLength: 5 }]);
+          suffixLength: 5 }], 'bpage']);
 
     /* Alias */
-    assert.deepEqual(Search.search('r'), [
+    assert.deepEqual(Search.search('r'), [[
         { name: 'Rectangle::Rect()',
           alias: 'Math::Range',
           url: 'classMath_1_1Range.html',
@@ -183,7 +183,7 @@ const { StringDecoder } = require('string_decoder');
           alias: 'Math::Range',
           url: 'classMath_1_1Range.html',
           flags: 40,
-          suffixLength: 8 }]);
+          suffixLength: 8 }], '']);
 }
 
 /* Search, limiting the results to 3 */
@@ -193,7 +193,7 @@ const { StringDecoder } = require('string_decoder');
     assert.equal(Search.dataSize, 638);
     assert.equal(Search.symbolCount, 7);
     assert.equal(Search.maxResults, 3);
-    assert.deepEqual(Search.search('m'), [
+    assert.deepEqual(Search.search('m'), [[
         { name: 'Math',
           url: 'namespaceMath.html',
           flags: 16,
@@ -205,7 +205,7 @@ const { StringDecoder } = require('string_decoder');
         { name: 'Math::Vector::min() const',
           url: 'classMath_1_1Vector.html#min',
           flags: 105,
-          suffixLength: 10 }]);
+          suffixLength: 10 }], '']);
 }
 
 /* Search loaded from a base85-encoded file should work properly */
@@ -215,7 +215,7 @@ const { StringDecoder } = require('string_decoder');
     assert.equal(Search.dataSize, 640); /* some padding on the end, that's okay */
     assert.equal(Search.symbolCount, 7);
     assert.equal(Search.maxResults, 100);
-    assert.deepEqual(Search.search('min'), [
+    assert.deepEqual(Search.search('min'), [[
         { name: 'Math::min(int, int)',
           url: 'namespaceMath.html#min',
           flags: 105,
@@ -227,7 +227,7 @@ const { StringDecoder } = require('string_decoder');
         { name: 'Math::Range::min() const',
           url: 'classMath_1_1Range.html#min',
           flags: 109,
-          suffixLength: 8 }]);
+          suffixLength: 8 }], '()']);
 }
 
 /* Search, Unicode */
@@ -236,7 +236,9 @@ const { StringDecoder } = require('string_decoder');
     assert.ok(Search.init(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)));
     assert.equal(Search.dataSize, 124);
     assert.equal(Search.symbolCount, 2);
-    assert.deepEqual(Search.search('h'), [
+    /* Both "Hýždě" and "Hárá" have common autocompletion to "h\xA1", which is
+       not valid UTF-8, so it has to get truncated */
+    assert.deepEqual(Search.search('h'), [[
         { name: Search.toUtf8('Hárá'),
           url: '#b',
           flags: 192,
@@ -244,17 +246,18 @@ const { StringDecoder } = require('string_decoder');
         { name: Search.toUtf8('Hýždě'),
           url: '#a',
           flags: 192,
-          suffixLength: 7 }]);
-    assert.deepEqual(Search.search('hý'), [
+          suffixLength: 7 }], '']);
+    /* These autocompletions are valid UTF-8, so nothing gets truncated */
+    assert.deepEqual(Search.search('hý'), [[
         { name: Search.toUtf8('Hýždě'),
           url: '#a',
           flags: 192,
-          suffixLength: 5 }]);
-    assert.deepEqual(Search.search('há'), [
+          suffixLength: 5 }], 'ždě']);
+    assert.deepEqual(Search.search('há'), [[
         { name: Search.toUtf8('Hárá'),
           url: '#b',
           flags: 192,
-          suffixLength: 3 }]);
+          suffixLength: 3 }], 'rá']);
 }
 
 /* Properly combine heavily nested URLs */
@@ -263,17 +266,17 @@ const { StringDecoder } = require('string_decoder');
     assert.ok(Search.init(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)));
     assert.equal(Search.dataSize, 295);
     assert.equal(Search.symbolCount, 4);
-    assert.deepEqual(Search.search('geo'), [
+    assert.deepEqual(Search.search('geo'), [[
         { name: 'Magnum::Math::Geometry',
           url: 'namespaceMagnum_1_1Math_1_1Geometry.html',
           flags: 24,
-          suffixLength: 5 }]);
+          suffixLength: 5 }], 'metry']);
 
-    assert.deepEqual(Search.search('ra'), [
+    assert.deepEqual(Search.search('ra'), [[
         { name: 'Magnum::Math::Range',
           url: 'classMagnum_1_1Math_1_1Range.html',
           flags: 40,
-          suffixLength: 3 }]);
+          suffixLength: 3 }], 'nge']);
 }
 
 /* Not testing Search.download() because the xmlhttprequest npm package is *crap* */