diff --git a/pd/nw/dialog_prefs.html b/pd/nw/dialog_prefs.html index 1af561488fa718d47ae520a9df7f59067781c5ef..6a01a941ea727701343e962e5ef4bfd6948a702c 100644 --- a/pd/nw/dialog_prefs.html +++ b/pd/nw/dialog_prefs.html @@ -453,6 +453,11 @@ select { <input type="checkbox" id="autocomplete_prefix" name="autocomplete_prefix"> <span data-i18n="prefs.gui.autocomplete.autocomplete_prefix"></span> </label> + <br/> + <label data-i18n="[title]prefs.gui.autocomplete.autocomplete_relevance_tt"> + <input type="checkbox" id="autocomplete_relevance" name="autocomplete_relevance"> + <span data-i18n="prefs.gui.autocomplete.autocomplete_relevance"></span> + </label> <br/><br/> <span data-i18n="prefs.gui.browser.browser_title"></span> <br/> @@ -806,6 +811,7 @@ function apply(save_prefs) { get_bool_elem("save_zoom"), get_bool_elem("autocomplete"), get_bool_elem("autocomplete_prefix"), + get_bool_elem("autocomplete_relevance"), get_bool_elem("browser_doc"), get_bool_elem("browser_path"), get_bool_elem("browser_init"), @@ -817,7 +823,8 @@ function apply(save_prefs) { get_bool_elem("browser_doc"), get_bool_elem("browser_path"), get_bool_elem("autocomplete"), - get_bool_elem("autocomplete_prefix") + get_bool_elem("autocomplete_prefix"), + get_bool_elem("autocomplete_relevance") ); // Update the grid on all open windows. pdgui.update_grid(get_bool_elem("show_grid"), @@ -1071,7 +1078,7 @@ function autopatch_yoffset_toggle(checked) { } function gui_prefs_callback(name, show_grid, grid_size, save_zoom, - autocomplete, autocomplete_prefix, + autocomplete, autocomplete_prefix, autocomplete_relevance, browser_doc, browser_path, browser_init, autopatch_yoffset) { var s = document.getElementById("gui_preset"); @@ -1107,6 +1114,7 @@ function gui_prefs_callback(name, show_grid, grid_size, save_zoom, document.getElementById("save_zoom").checked = !!save_zoom; document.getElementById("autocomplete").checked = !!autocomplete; document.getElementById("autocomplete_prefix").checked = !!autocomplete_prefix; + document.getElementById("autocomplete_relevance").checked = !!autocomplete_relevance; document.getElementById("browser_doc").checked = !!browser_doc; document.getElementById("browser_path").checked = !!browser_path; document.getElementById("browser_init").checked = !!browser_init; diff --git a/pd/nw/dialog_search.html b/pd/nw/dialog_search.html index 74a7e95ef33e3008c762a833fe7c2e2a6f673fab..618804612a0b65c778a6e6f588fe4d389617a7ab 100644 --- a/pd/nw/dialog_search.html +++ b/pd/nw/dialog_search.html @@ -837,7 +837,8 @@ function display_doc(doc) { } if (doc.related_objects) { var p_rel_objs = document.createElement("p"); - var ref_rel_objs = doc.ref_related_objects.split("\,"); + var ref_rel_objs = doc.ref_related_objects == null + ? [] : doc.ref_related_objects.split("\,"); p_rel_objs.innerHTML = "Related objects: ".bold(); doc.related_objects.split(" ").forEach(function (rel_obj, i, a) { let link_rel_obj = rel_obj; diff --git a/pd/nw/locales/de/translation.json b/pd/nw/locales/de/translation.json index 5ff39dab72c248374969be60f7a4a3ba1c88e59b..8a5d495192d2aa3d9604e57c129b5764adb08e0c 100644 --- a/pd/nw/locales/de/translation.json +++ b/pd/nw/locales/de/translation.json @@ -444,7 +444,9 @@ "autocomplete": "Autovervollständigung von Objekt-Namen und Argumenten (experimentell)", "autocomplete_tt": "Schlägt bei der Eingabe Vervollständigungen von Objekt-Namen und Argumenten vor", "autocomplete_prefix": "Vervollständigung per Objektnamens-Präfix", - "autocomplete_prefix_tt": "Vervollständigung nur per Objektnamens-Präfix (statt Übereinstimmung irgendwo im Objektnamen)" + "autocomplete_prefix_tt": "Vervollständigung nur per Objektnamens-Präfix (statt Übereinstimmung irgendwo im Objektnamen)", + "autocomplete_relevance": "Sortiere Vervollständigungen nach Relevanz", + "autocomplete_relevance_tt": "Zeigt die relevantesten Vervollständigungen zuerst (basierend auf der Nutzungshäufigkeit)" }, "browser": { "browser_title": "Hilfe-Browser-Einstellungen (WARNUNG: Änderungen können Startup-Zeiten beeinflussen!)", diff --git a/pd/nw/locales/en/translation.json b/pd/nw/locales/en/translation.json index b18fd87d2dfd276122b6e312e76cf2132a734baf..d340835b204976d47a3c24a0b4d396540c971780 100644 --- a/pd/nw/locales/en/translation.json +++ b/pd/nw/locales/en/translation.json @@ -444,7 +444,9 @@ "autocomplete": "auto-complete object names and arguments (experimental)", "autocomplete_tt": "Offers completions for object names and arguments as you type", "autocomplete_prefix": "match completions by object name prefix", - "autocomplete_prefix_tt": "Only list completions whose prefix matches (rather than matches anywhere in object names)" + "autocomplete_prefix_tt": "Only list completions whose prefix matches (rather than matches anywhere in object names)", + "autocomplete_relevance": "sort completions by relevance", + "autocomplete_relevance_tt": "List most relevant completions first (based on how frequently they are used)" }, "browser": { "browser_title": "Help browser settings (WARNING: changing these may affect startup times!)", diff --git a/pd/nw/locales/fr/translation.json b/pd/nw/locales/fr/translation.json index f5dd974a93fe38c6b18b4a21d98d83cd9cb5920e..4721a9c13089a19b1fc91554409a9815f30da211 100644 --- a/pd/nw/locales/fr/translation.json +++ b/pd/nw/locales/fr/translation.json @@ -444,7 +444,9 @@ "autocomplete": "autocompléter les noms et arguments des objets (expérimental)", "autocomplete_tt": "Offre complétion pour les noms et arguments des objets au fur et à mesure de la saisie", "autocomplete_prefix": "complétion par le préfixe du nom de l'objet", - "autocomplete_prefix_tt": "Complétion uniquement par le préfixe du nom de l'objet (ignorer les correspondances au milieu du nom des objets)" + "autocomplete_prefix_tt": "Complétion uniquement par le préfixe du nom de l'objet (ignorer les correspondances au milieu du nom des objets)", + "autocomplete_relevance": "Trier les complétions par pertinence", + "autocomplete_relevance_tt": "Affiche les complétions les plus pertinentes en premier (basé sur la fréquence d'utilisation)" }, "browser": { "browser_title": "Paramètres du navigateur d'Aide (AVERTISSEMENT: les modifier peut affecter les temps de démarrage!)", diff --git a/pd/nw/pd_canvas.js b/pd/nw/pd_canvas.js index 00cc64efa30dec713ec16fd5cbc8790688e72f44..3f438eacb50bdca0a6a574cb53f0571bbf2549a7 100644 --- a/pd/nw/pd_canvas.js +++ b/pd/nw/pd_canvas.js @@ -50,9 +50,40 @@ var canvas_events = (function() { last_dropdown_menu_y, last_search_term = "", svg_view = document.getElementById("patchsvg").viewBox.baseVal, + last_results = [], // last completion results (autocomplete) + last_completed = -1, // last Tab completion (autocomplete) + last_offset = -1, // offset of last Tab completion (autocomplete) + last_yanked = "", // last yanked completion (to confirm deletion) textbox = function () { return document.getElementById("new_object_textentry"); }, + caret_end = function () { + /* ag: Move the caret to the end of the texbox while editing. This + is needed for the autcompletion. We essentially fake pressing + the End key here; maybe there's an easier way to do this, but + the following seems to work alright, so... We first grab the + textbox content and determine its length, which is where we + want the caret to be. */ + var t = textbox(); + var x = t.innerText; + var p = x.length; + //console.log("move "+p+": "+x); + /* The DOM doesn't make this easy. We first have to define a + range, set its start to the desired caret position, and + collapse it to a single position (i.e., end = start). Next we + grab the current selection, remove all currently selected + ranges, and set our new range. Quite a hullaballoo for such a + simple task. */ + var r = document.createRange(); + var s = window.getSelection(); + r.setStart(t.childNodes[0], p); + r.collapse(true); + s.removeAllRanges(); + s.addRange(r); + // Defer this to the event loop to prevent losing the + // keyboard focus. + setTimeout(function () { t.focus() }, 0); + }, current_events = {}, // keep track of our current listeners edit_events = function(elem, events, action, init) { // convenience routine for adding an object full of @@ -294,6 +325,31 @@ var canvas_events = (function() { } } }, + ac_dropdown = function() { + return document.getElementById("autocomplete_dropdown") + }, + // AG: Little helper function to do all the necessary steps to + // re-create the autocompletion dropdown after changes. We factored + // this out since it will be needed to deal with changes to both the + // edited object text and the autocompletion index. + ac_repopulate = function() { + // GB: Finding the class from obj: find obj through tag of + // textbox, get obj class and remove from the class the word + // "selected". This is necessary because in textbox obj and + // comment both have class 'obj' and it's import here to + // differentiate them. + let obj_class = document + .getElementById(textbox().getAttribute("tag")+"gobj") + .getAttribute("class").toString() + .split(" ").slice(0,1).toString(); + if (obj_class === "obj") { // autocomplete only works for objects + pdgui.create_autocomplete_dd(document, ac_dropdown(), textbox()); + if (ac_dropdown().getAttribute("searched_text") !== textbox().innerText) { + last_results = pdgui.repopulate_autocomplete_dd(document, ac_dropdown, obj_class, textbox().innerText); + last_offset = 0; + } + } + }, events = { mousemove: function(evt) { //pdgui.post("x: " + evt.pageX + " y: " + evt.pageY + @@ -494,8 +550,17 @@ var canvas_events = (function() { return false; }, text_mousedown: function(evt) { - if (evt.target.parentNode === document.getElementById("autocomplete_dropdown")) { - pdgui.select_result_autocomplete_dd(textbox(), document.getElementById("autocomplete_dropdown")); + if (evt.target.parentNode === ac_dropdown()) { + pdgui.select_result_autocomplete_dd(textbox(), ac_dropdown()); + last_yanked = ""; + // ag: Don't do the usual object instantiation thing if + // we've clicked on the autocompletion dropdown. This + // means that the user can just go on editing, entering + // object arguments, etc. + evt.stopPropagation(); + //evt.preventDefault(); + caret_end(); + return false; } if (textbox() !== evt.target && !target_is_scrollbar(evt)) { utils.create_obj(); @@ -528,15 +593,14 @@ var canvas_events = (function() { evt.stopPropagation(); // GB: Autocomplete feature - let ac_dropdown = function() { - return document.getElementById("autocomplete_dropdown") - } switch (evt.keyCode) { case 40: // arrowdown pdgui.update_autocomplete_dd_arrowdown(ac_dropdown()) + last_yanked = ""; break; case 38: // arrowup pdgui.update_autocomplete_dd_arrowup(ac_dropdown()) + last_yanked = ""; break; case 13: // enter // if there is no item selected on autocomplete dropdown, enter make the obj box bigger @@ -544,29 +608,98 @@ var canvas_events = (function() { grow_svg_for_element(textbox()); } else { // else, if there is a selected item on autocompletion tool, the selected item is written on the box pdgui.select_result_autocomplete_dd(textbox(), ac_dropdown()); - // TODO: Substitute the editing box by the object itself - // utils.create_obj(); // not working, it's not that simple. - // canvas_events.normal(); + caret_end(); + // No need to instantiate the object here, + // presumably the user wants to go on editing. } + last_yanked = ""; break; case 9: // tab - // TODO: Substitute this function by one that autocomplete with the prefix in common in all results - pdgui.select_result_autocomplete_dd(textbox(), ac_dropdown()); + [last_completed, last_offset] = pdgui.select_result_autocomplete_dd(textbox(), ac_dropdown(), last_completed, last_offset, last_results, evt.shiftKey?-1:1); + last_yanked = ""; + caret_end(); break; - default: - if (textbox().innerText === "") { - pdgui.delete_autocomplete_dd(ac_dropdown()); - } else { - let obj_class = document.getElementById(textbox().getAttribute("tag")+"gobj") - .getAttribute("class").toString().split(" ").slice(0,1).toString(); - if (obj_class === "obj") { // autocomplete only works for objects - pdgui.create_autocomplete_dd(document, ac_dropdown(), textbox()); - if (ac_dropdown().getAttribute("searched_text") !== textbox().innerText) { - // finding the class from obj: find obj throwout tag of textbox, get obj class and remove from the class the word "selected". - // this has to be done because in textbox obj and comment have class: 'obj' - // and it's import here to differentiate them - pdgui.repopulate_autocomplete_dd(document, ac_dropdown, obj_class, textbox().innerText); + case 36: + if (evt.altKey) { // alt-home + [last_completed, last_offset] = pdgui.select_result_autocomplete_dd(textbox(), ac_dropdown(), 0, last_offset, last_results, 0); + last_yanked = ""; + caret_end(); + } + break; + case 35: + if (evt.altKey) { // alt-end + [last_completed, last_offset] = pdgui.select_result_autocomplete_dd(textbox(), ac_dropdown(), last_results.length-1, last_offset, last_results, 0); + last_yanked = ""; + caret_end(); + } + break; + case 27: // esc + pdgui.delete_autocomplete_dd(ac_dropdown()); + last_completed = last_offset = -1; + last_results = []; + if (last_yanked != "") { + pdgui.post("Operation aborted.") + } + last_yanked = ""; + break; + case 89: + if (evt.ctrlKey) { // ctrl-y + // AG: Note that this key is usually bound to the + // Tidy Up operation in the Edit menu, but this + // presumably won't interfere with our use here, + // which is to "yank" the current completion + // (remove it from the completion index). + last_completed = last_offset = -1; + last_results = []; + if (textbox().innerText === "") { + pdgui.delete_autocomplete_dd(ac_dropdown()); + last_yanked = ""; + } else if (textbox().innerText === last_yanked) { + // confirmed, really yank now + if (pdgui.remove_completion(textbox().innerText, console.log)) { + pdgui.post("Removed completion: "+textbox().innerText); + ac_repopulate(); + last_results = []; + last_completed = last_offset = -1; } + last_yanked = ""; + } else if (pdgui.check_completion(textbox().innerText)) { + // for some safety, ask the user to confirm with another ctrl+y + pdgui.post("Really remove completion "+textbox().innerText+"?"); + pdgui.post("Press ctrl+y again to confirm (Esc to abort)."); + last_yanked = textbox().innerText; + } else { + pdgui.post("No completion "+textbox().innerText); + } + } + break; + default: + // ag: Only update the state if a "valid key" is + // pressed, not some modifier or other special key. + function is_valid_key(e) + { + // See https://stackoverflow.com/questions/51296562 + // This is a quick hack which works because the + // names of special keys are all multiple chars + // long and only contain letters and numbers. + return e.key.length == 1 // ASCII + // non-ASCII: + || (e.key.length > 1 && /[^a-zA-Z0-9]/.test(e.key)) + // Spacebar: + || e.keyCode == 32 + // We also include Backspace and Del here + // since they are used in editing. Note that + // Return (key code 13) is handled above. + || e.keyCode == 8 || e.keyCode == 46; + } + if (is_valid_key(evt)) { + last_results = []; + last_completed = last_offset = -1; + last_yanked = ""; + if (textbox().innerText === "") { + pdgui.delete_autocomplete_dd(ac_dropdown()); + } else { + ac_repopulate(); } } } diff --git a/pd/nw/pdgui.js b/pd/nw/pdgui.js index 09dd35b1d80570c63ea8a0b02e6f5c17a93766af..60947ffef80cba9f85e026c60aaec2f197df2017 100644 --- a/pd/nw/pdgui.js +++ b/pd/nw/pdgui.js @@ -3,7 +3,7 @@ var pwd; var lib_dir; var help_path, browser_doc, browser_path, browser_init; -var autocomplete, autocomplete_prefix; +var autocomplete, autocomplete_prefix, autocomplete_relevance; var pd_engine_id; exports.autocomplete_enabled = function() { @@ -33,7 +33,7 @@ exports.set_pd_engine_id = function (id) { exports.defunkify_windows_path = defunkify_windows_path; function gui_set_browser_config(doc_flag, path_flag, init_flag, - ac_flag, ac_prefix_flag, + ac_flag, ac_prefix_flag, ac_relevance_flag, helppath) { // post("gui_set_browser_config: " + helppath.join(":")); browser_doc = doc_flag; @@ -49,12 +49,20 @@ function gui_set_browser_config(doc_flag, path_flag, init_flag, // user decides to enable it later. autocomplete = ac_flag; autocomplete_prefix = ac_prefix_flag; + autocomplete_relevance = ac_relevance_flag; make_completion_index(); // AG: Start building the keyword index for dialog_search.html. We do this - // here so that we can be sure that lib_dir and help_path are known already. - // (This may also be deferred until the browser is launched for the first - // time, depending on the value of browser_init.) - if (browser_init == 1) make_index(); + // here so that we can be sure that lib_dir and help_path are known + // already. (This may also be deferred until the browser is launched for + // the first time, depending on the value of browser_init, unless + // autocomplete is enabled in which case we have to build it anyway.) + if (autocomplete == 1 && !fs.existsSync(expand_tilde(compl_name))) { + // if the completion.json file has gone missing, rebuild it + rebuild_index(); + } else if (browser_init == 1 || autocomplete == 1) { + // otherwise we only generate the index as needed + make_index(); + } } function gui_set_lib_dir(dir) { @@ -151,6 +159,8 @@ function index_entry_esc(s) { // keywords, and description of help patches. function add_doc_details_to_index(filename, data) { var title = path.basename(filename, "-help.pd"), + // AG: This is confusing. I'm not sure why we just replace the first + // newline here. Maybe there's a reason to do so, but I don't get it. big_line = data.replace("\n", " "), keywords, desc, @@ -225,10 +235,113 @@ function add_doc_details_to_index(filename, data) { keywords = keywords && keywords.length > 1 ? keywords[1].trim() : null; desc = desc && desc.length > 1 ? desc[1].trim() : null; // Remove the Pd escapes for commas - desc = desc ? desc.replace(" \\,", ",") : null; + // AG: We want to do a global replacement here. + desc = desc ? desc.replace(/ \\,/g, ",") : null; if (desc) { - // format Pd's "comma atoms" as normal commas - desc = desc.replace(" \\,", ","); + // AG: And we want to get rid of the newlines, there's no reason + // whatsover to preserve the original formatting. + desc = desc.replace(/\n/g, " "); + } + + // AG: Deal with a bunch of special cases which have multiple objects + // documented in them, listing the object names (and arguments) in the + // NAME field of the META data. + var names = big_line + .match(/#X text \-?[0-9]+ \-?[0-9]+ NAME ([\s\S]*?);/); + // Some NAME fields span multiple lines, pretend that they're spaces. + names = names && names.length > 1 + ? names[1].trim().replace(/\n/g, " ").split(" ") + : []; + if (names.length > 1) { + // special help file, not a single object, remove from completions + let obj_result = obj_exact_match(title); + if (obj_result.length !== 0) { + let obj_ref = obj_result[0].refIndex; + //post("scanning "+filename); + completion_index.removeAt(obj_ref); + } + // Some NAME entries (e.g., list) list different variations of the + // same object (list append, list trim etc.), we try to be clever + // about these. + let prefix = names[0]; + let count = names.filter(name => name == prefix).length; + if (count > 3) { + // Chances are good that we're looking at variations of the same + // command, and not just some oddball NAME list with repeated + // entries. First collect the arguments. + let myargs = []; + for (let i = 0; i < names.length; i++) { + if (names[i] == prefix) { + let a = []; + while (i+1 < names.length && names[i+1] != prefix) { + a.push(names[i+1]); i++; + } + if (a.length > 0) { + myargs.push({"occurrences" : 0, "text" : a.join(' ')}); + } + } + } + // Next check for an existing object which can be updated. + obj_result = obj_exact_match(prefix); + if (obj_result.length !== 0) { + // Found an object, add new arg entries in-place. + let args = obj_result[0].item.args; + for (let i = 0; i < myargs.length; i++) { + let arg_result = arg_exact_match(title, myargs[i].text); + if (arg_result.length == 0) { + // New arg completion. Note that the args array is + // live data straight from the fuse, so we can just + // add it in-place. + args.push(myargs[i]); + } else { + // keep track of which args we actually added + myargs[i] = null + } + } + } else { + // the object doesn't exist yet in the index, add it + completion_index.add({ + "occurrences" : 0, "title" : prefix, "args" : myargs + }); + } + //post("add completion "+prefix+" "+myargs.filter(a => a != null).map(a => a.text).join(', ')); + } else { + // just a list of object names here, add them all + for (let i = 0; i < names.length; i++) { + let title = names[i]; + // check for existing entries, skip those + obj_result = obj_exact_match(title); + if (obj_result.length === 0) { + //post("add completion "+title); + completion_index.add({ + "occurrences" : 0, + "title" : title, + "args" : [] + }); + } + } + } + } + + /* AG: Some help patches have library information in them, we might want + to use that information, so we record it in the completion entries if + it is available. NOTE: The lib field will only be added to entries for + which library information is available, so you *must* check for its + existence before trying to access that data.*/ + var libs = big_line + .match(/#X text \-?[0-9]+ \-?[0-9]+ LIBRARY ([\s\S]*?);/); + libs = libs && libs.length > 1 + ? libs[1].trim().replace(/\n/g, " ").split(" ") + : []; + // Filter out some unwanted noise. + libs = libs.filter(x => x!="external" && x!="addons"); + if (libs.length > 0) { + //post(title+" lib: "+libs.join(' ')); + let obj_result = obj_exact_match(title); + if (obj_result.length !== 0) { + // The first LIBRARY entry seems to be most useful. + obj_result[0].item.lib = libs[0]; + } } index_cache[index_cache.length] = [filename, title, keywords, desc, rel_objs, ref_rel_objs] @@ -469,11 +582,10 @@ function build_index(cb) { exports.build_index = build_index; -// this doesn't actually rebuild the index, it just clears it, so that it -// will be rebuilt the next time the help browser is opened +// normally, this doesn't actually rebuild the index, it just clears it, so +// that it will be rebuilt the next time the help browser is opened function rebuild_index() { - post("clearing help index (reopen browser to rebuild!)"); index = init_elasticlunr(); index_started = index_done = false; try { @@ -482,14 +594,22 @@ function rebuild_index() } catch (err) { //console.log(err); } + if (browser_init == 1 || autocomplete == 1) { + // if autocomplete is enabled, we *have* to rebuild the index now + make_index(); + } else { + // we can defer rebuilding of the index until the browser is reopened + post("clearing help index (reopen the browser to rebuild!)"); + } } // this is called from the gui tab of the prefs dialog -function update_browser(doc_flag, path_flag, ac_flag, ac_prefix_flag) +function update_browser(doc_flag, path_flag, ac_flag, ac_prefix_flag, ac_relevance_flag) { - var changed = false; + var changed = ac_flag == 1 && autocomplete == 0; autocomplete = ac_flag; autocomplete_prefix = ac_prefix_flag; + autocomplete_relevance = ac_relevance_flag; doc_flag = doc_flag?1:0; path_flag = path_flag?1:0; if (browser_doc !== doc_flag) { @@ -558,7 +678,25 @@ function search_arg(title, arg) { if (!autocomplete) return []; // for the arguments, we are only interested on the obj that match exactly the 'title', so we return only the args from this obj let results = completion_index.search({$and: [{"title": "=\"" + title + "\""}, {"args.text": "^\"" + arg + "\""}]}); - return (results.length > 0) ? results[0].matches : []; + if (results.length > 0) { + let args = results[0].item.args; + // AG: Matched args are in matches.slice(1,), extract them. + // This code originally just returned matches itself, which has the + // text of all matched arguments, but not the occurrence data, and we + // need the latter to sort based on relevance. + results = results[0].matches.slice(1,).map(a => args[a.refIndex]); + } + return results; +} + +function search_args(title) { + // like above, but look up *all* arg completions for a given object + if (!autocomplete) return []; + // for the arguments, we are only interested on the obj that match exactly the 'title', so we return only the args from this obj + let results = obj_exact_match(title); + // item.args is live data from the fuse, make sure to take a shallow copy + // with slice() which can be safely sorted in-place later + return (results.length > 0) ? results[0].item.args.slice() : []; } function index_obj_completion(obj_or_msg, obj_or_msg_text) { @@ -631,34 +769,142 @@ function update_autocomplete_dd_arrowup(ac_dropdown) { } } -function select_result_autocomplete_dd(textbox, ac_dropdown) { +function select_result_autocomplete_dd(textbox, ac_dropdown, last, offs, res, dir) { if (ac_dropdown !== null) { let sel = ac_dropdown.getAttribute("selected_item"); if (sel > -1) { textbox.innerText = ac_dropdown.children.item(sel).innerText; delete_autocomplete_dd(ac_dropdown); - } else { // it only passes here when the user presses 'tab' and there is no option selected - textbox.innerText = ac_dropdown.children.item(0).innerText; + return [sel+offs, offs]; + } else { + // We only come here if the user presses 'tab' and there is no + // option selected. + var n = res.length; + if (n == 0) { + // It seems that while pondering the mysteries of the + // universe, your computer has lost our completion list. This + // shouldn't happen, but a little bit of defensive programming + // can't hurt. + return [-1,-1]; + } + var next = + (dir==0 ? last : dir>0 ? last+1 : last<=0 ? n-1 : last-1) % n; + // If the new index is outside the current scope of the popup, + // repopulate the popup by shifting the entries accordingly (poor + // man's scroll). + if (next < offs) { + offs = next; + let c = ac_dropdown.childNodes; + c.forEach((r, i) => r.textContent = res[offs+i]); + } else if (next > offs+7) { + offs = next-7; + let c = ac_dropdown.childNodes; + c.forEach((r, i) => r.textContent = res[offs+i]); + } + textbox.innerText = res[next]; + return [next, offs]; } + } else { + return [-1,-1]; } } // GB: update autocomplete dropdown with new results function repopulate_autocomplete_dd(doc, ac_dropdown, obj_class, text) { ac_dropdown().setAttribute("searched_text", text); - let title, arg; + let title, arg, have_arg; if (obj_class === "obj") { let text_array = text.split(" "); title = text_array[0].toString(); arg = text_array.slice(1, text_array.length); + // check whether *anything* follows the obj name, even an empty arg + have_arg = arg.length !== 0; arg = (arg.length !== 0) ? arg.toString().replace(/\,/g, " ") : ""; } else { // the autocomplete feature doesn't work with messages and comments return; } - // If there are arg, we are autocompleting the arg field, and if there isn't we are autocompleting the obj_title field - let results = (arg.length > 0) ? (search_arg(title, arg).slice(1,)) : (search_obj(title)); + /* AG: We're dealing with three different cases here which must be handled + separately. + + (1) We're completing an object name; in this case have_arg is false and + arg is empty as well. + + (2) We're about to start argument completion; here we have that + have_arg is true even though arg is still empty (which happens as soon + as you enter a blank after the object name). + + (3) We're in the middle of argument completion (started typing some + arguments) in which case have_arg is true and arg is non-empty as + well. */ + let results = (arg.length > 0) ? (search_arg(title, arg)) : have_arg ? (search_args(title)) : (search_obj(title)); + + /* AG: Here we massage the result list from what Fuse delivers, which is + based on scoring similarity and can look pretty random at times. What + we actually want here is an order which also takes into account + relevance (as determined by the occurences and library information that + we have), and the alphabetic order of the available completions. The + latter tends to make the list tidier and easier to navigate. + + The final step then is to condense the result list to a simple string + list, since we don't use the other data anymore beyond this point. + + NB: The use of library information was an idea proposed by JW, and we + record that now, but unfortunately the information available in the + help patches is incomplete and inconsistent. Notable exceptions are + 'internal' and 'cyclone', for which there are plenty of entries. At + present we only use 'internal' to identify built-ins, so that they will + be preferred in relevance ordering. This has the advantage that it + works from the get-go when there's not much live usage data yet, as + these objects will tend to be used most frequently by most Pd + users. But only time and user feedback will tell whether it actually + makes sense to put this criterion above the live usage data that we + have in the occurrences field. */ + if (arg.length > 0 || have_arg) { + // argument completions, these don't have scores, order them by + // just relevance and text + results.sort((a, b) => + a.occurrences == b.occurrences || !autocomplete_relevance + ? (a.text == b.text ? 0 : a.text < b.text ? -1 : 1) + : b.occurrences - a.occurrences); + results = results.map(a => title + " " + a.text); + } else { + // object completions, order by score, relevance and item.title + results.sort(function (a, b) { + /* XXXREVIEW: We might have to revisit this. We currently use a + * numeric relevance score to make this easy to adjust. The boost + * factor determines the relative weight of built-ins over live + * usage data. We currently have this at 50, so that you need 50 + * uses of an external to win against a built-in. I hope that this + * will work well in practice, but currently noone knows. Also, + * should score take precedence over relevance? Currently we + * demote it to a secondary criterion, but score can be pretty + * important if the auto-completion prefix option is disabled + * (which it is by default). */ + const boost = 50; /* NOTE: Increasing the boost to a very large + * value >> 1 makes built-ins effectively take + * priority over live usage data, decreasing it + * to a very small value << 1 makes live usage + * data the most important. */ + let aflag = a.item.hasOwnProperty("lib") && a.item.lib == "internal" ? 1 : 0; + let bflag = b.item.hasOwnProperty("lib") && b.item.lib == "internal" ? 1 : 0; + let afreq = a.item.occurrences, bfreq = b.item.occurrences; + let relevance = (bflag-aflag)*boost + (bfreq-afreq); + if (autocomplete_relevance && relevance !== 0) { + return relevance; + } else if (a.score == b.score) { + return a.item.title == b.item.title ? 0 + : a.item.title < b.item.title ? -1 : 1; + } else { + let d = a.score - b.score; + return d == 0 ? 0 : d < 0 ? -1 : 1; + } + }); + results = results.map(a => a.item.title); + } + // record the complete results, we need them for tab completion + let all_results = results; // GB TODO: ideally we should be able to show all the results in a limited window with a scroll bar let n = 8; // Maximum number of suggestions if (results.length > n) results = results.slice(0,n); @@ -666,8 +912,8 @@ function repopulate_autocomplete_dd(doc, ac_dropdown, obj_class, text) { ac_dropdown().innerHTML = ""; // clear all old results if (results.length > 0) { // for each result, make a paragraph child of autocomplete_dropdown + let h = ac_dropdown().getAttribute("font_height"); results.forEach(function (f,i,a) { - let h = ac_dropdown().getAttribute("font_height"); let y = h*(i+1); let r = doc.createElement("p"); r.setAttribute("width", "150"); @@ -675,17 +921,14 @@ function repopulate_autocomplete_dd(doc, ac_dropdown, obj_class, text) { r.setAttribute("y", y); r.setAttribute("class", "border"); r.setAttribute("idx", i); - if (arg.length < 1) { // autocomplete object - r.textContent = f.item.title; - } else { // autocomplete argument, message or comment - r.textContent = ((obj_class==="obj")?(title+" "):"") + f.value; - } + r.textContent = f; ac_dropdown().appendChild(r); }) ac_dropdown().setAttribute("selected_item", "-1"); } else { // if there is no suggestion candidate, the autocompletion dropdown should disappear delete_autocomplete_dd (ac_dropdown()); } + return all_results; } // GB: create autocomplete dropdown based on the properties of the textbox for new_obj_element @@ -729,6 +972,81 @@ function delete_autocomplete_dd (ac_dropdown) { } } +function check_completion(text) +{ + if (!autocomplete) return false; + // checks whether an exact match (title+args) exists for the given text + let text_array = text.split(" "); + let title = text_array[0].toString(); + let arg = text_array.slice(1, text_array.length); + if (arg.length > 0) { + arg = arg.toString().replace(/\,/g, " "); + } else { + arg = ""; + } + let obj_result = obj_exact_match(title); + if (obj_result.length !== 0) { + let args = obj_result[0].item.args; + if (arg !== "") { + let arg_result = arg_exact_match(title, arg); + if (arg_result.length !== 0) { + // found exact title+args match + return true; + } + } else { + // no args, found exact title match + return true; + } + } + // no matches found + return false; +} + +function remove_completion(text, log) +{ + if (!autocomplete) return false; + // Check to see whether we're removing an object or just its arguments + // from the index. + let text_array = text.split(" "); + let title = text_array[0].toString(); + let arg = text_array.slice(1, text_array.length); + if (arg.length > 0) { + arg = arg.toString().replace(/\,/g, " "); + //log("title: "+title+", args: "+arg); + } else { + arg = ""; + //log("title: "+title); + } + let obj_result = obj_exact_match(title); + if (obj_result.length !== 0) { + let obj_ref = obj_result[0].refIndex; + let obj_freq = obj_result[0].item.occurrences; + let args = obj_result[0].item.args; + if (arg !== "") { + let arg_result = arg_exact_match(title, arg); + if (arg_result.length !== 0) { + // found exact title+args match + let arg_ref = arg_result[0].matches[1].refIndex; + //log("removing %s %s at index %d %d", title, arg, obj_ref, arg_ref); + // remove from args + args.splice(arg_ref, 1); + // update the object + let obj = {"occurrences" : obj_freq, "title" : title, "args" : args}; + //log("updating %d with %o", obj_ref, obj); + completion_index.update(obj, obj_ref); + return true; + } + } else { + // no args, found exact title match, remove it + //log("removing %s at index %d", title, obj_ref); + completion_index.removeAt(obj_ref); + return true; + } + } + // no matches found + return false; +} + exports.index_obj_completion = index_obj_completion; exports.write_completion_index = write_completion_index; exports.update_autocomplete_selected = update_autocomplete_selected; @@ -738,6 +1056,8 @@ exports.select_result_autocomplete_dd = select_result_autocomplete_dd; exports.repopulate_autocomplete_dd = repopulate_autocomplete_dd; exports.create_autocomplete_dd = create_autocomplete_dd; exports.delete_autocomplete_dd = delete_autocomplete_dd; +exports.check_completion = check_completion; +exports.remove_completion = remove_completion; // Modules @@ -6656,12 +6976,12 @@ function gui_midi_properties(gfxstub, sys_indevs, sys_outdevs, } function gui_gui_properties(dummy, name, show_grid, grid_size, save_zoom, - autocomplete, autocomplete_prefix, + autocomplete, autocomplete_prefix, autocomplete_relevance, browser_doc, browser_path, browser_init, autopatch_yoffset) { if (dialogwin["prefs"] !== null) { dialogwin["prefs"].window.gui_prefs_callback(name, show_grid, grid_size, - save_zoom, autocomplete, autocomplete_prefix, + save_zoom, autocomplete, autocomplete_prefix, autocomplete_relevance, browser_doc, browser_path, browser_init, autopatch_yoffset); } } diff --git a/pd/src/m_glob.c b/pd/src/m_glob.c index d302ea9748dd717b061d0a03352db71c1da4e606..e4f9f85c92288149410c972668e9de98fc8ba83f 100644 --- a/pd/src/m_glob.c +++ b/pd/src/m_glob.c @@ -81,7 +81,7 @@ static void glob_perf(t_pd *dummy, float f) } extern int sys_snaptogrid, sys_gridsize, sys_zoom, - sys_autocomplete, sys_autocomplete_prefix, + sys_autocomplete, sys_autocomplete_prefix, sys_autocomplete_relevance, sys_browser_doc, sys_browser_path, sys_browser_init, sys_autopatch_yoffset; extern t_symbol *sys_gui_preset; @@ -93,6 +93,7 @@ static void glob_gui_prefs(t_pd *dummy, t_symbol *s, int argc, t_atom *argv) sys_zoom = !!atom_getintarg(0, argc--, argv++); sys_autocomplete = !!atom_getintarg(0, argc--, argv++); sys_autocomplete_prefix = !!atom_getintarg(0, argc--, argv++); + sys_autocomplete_relevance = !!atom_getintarg(0, argc--, argv++); sys_browser_doc = !!atom_getintarg(0, argc--, argv++); sys_browser_path = !!atom_getintarg(0, argc--, argv++); sys_browser_init = !!atom_getintarg(0, argc--, argv++); @@ -102,7 +103,7 @@ static void glob_gui_prefs(t_pd *dummy, t_symbol *s, int argc, t_atom *argv) /* just the gui-preset, the save-zoom toggle and various help browser options for now */ static void glob_gui_properties(t_pd *dummy) { - gui_vmess("gui_gui_properties", "xsiiiiiiiii", + gui_vmess("gui_gui_properties", "xsiiiiiiiiii", dummy, sys_gui_preset->s_name, sys_snaptogrid, @@ -110,6 +111,7 @@ static void glob_gui_properties(t_pd *dummy) sys_zoom, sys_autocomplete, sys_autocomplete_prefix, + sys_autocomplete_relevance, sys_browser_doc, sys_browser_path, sys_browser_init, diff --git a/pd/src/s_file.c b/pd/src/s_file.c index f6c00e169dbd649707c22a3821feee5631a498de..5f8f570e65fc88fdf7379053292c8a112f4c0b8c 100644 --- a/pd/src/s_file.c +++ b/pd/src/s_file.c @@ -43,7 +43,8 @@ #endif int sys_defeatrt, sys_autopatch_yoffset, sys_snaptogrid = 1, sys_gridsize = 10, - sys_zoom, sys_autocomplete, sys_autocomplete_prefix, + sys_zoom, sys_autocomplete = 1, sys_autocomplete_prefix, + sys_autocomplete_relevance = 1, sys_browser_doc = 1, sys_browser_path, sys_browser_init; t_symbol *sys_flags = &s_; void sys_doflags( void); @@ -681,6 +682,8 @@ void sys_loadpreferences( void) sscanf(prefbuf, "%d", &sys_autocomplete); if (sys_getpreference("autocomplete_prefix", prefbuf, MAXPDSTRING)) sscanf(prefbuf, "%d", &sys_autocomplete_prefix); + if (sys_getpreference("autocomplete_relevance", prefbuf, MAXPDSTRING)) + sscanf(prefbuf, "%d", &sys_autocomplete_relevance); if (sys_getpreference("browser_doc", prefbuf, MAXPDSTRING)) sscanf(prefbuf, "%d", &sys_browser_doc); if (sys_getpreference("browser_path", prefbuf, MAXPDSTRING)) @@ -836,6 +839,8 @@ void glob_savepreferences(t_pd *dummy) sys_putpreference("autocomplete", buf1); sprintf(buf1, "%d", sys_autocomplete_prefix); sys_putpreference("autocomplete_prefix", buf1); + sprintf(buf1, "%d", sys_autocomplete_relevance); + sys_putpreference("autocomplete_relevance", buf1); sprintf(buf1, "%d", sys_browser_doc); sys_putpreference("browser_doc", buf1); sprintf(buf1, "%d", sys_browser_path); diff --git a/pd/src/s_main.c b/pd/src/s_main.c index e002b2c3f8945d9e44c34c61faf77197466c74be..0fb8cc06556a53053ba314b961296393bee3221e 100644 --- a/pd/src/s_main.c +++ b/pd/src/s_main.c @@ -331,7 +331,8 @@ void glob_forward_files_from_secondary_instance(void) extern void glob_recent_files(t_pd *dummy); extern int sys_browser_doc, sys_browser_path, sys_browser_init; -extern int sys_autocomplete, sys_autocomplete_prefix; +extern int sys_autocomplete, sys_autocomplete_prefix, + sys_autocomplete_relevance; /* this is called from main() in s_entry.c */ int sys_main(int argc, char **argv) @@ -412,9 +413,10 @@ int sys_main(int argc, char **argv) glob_recent_files(0); /* AG: send the browser config; this must come *after* gui_set_lib_dir so that the lib_dir is available when help indexing starts */ - gui_start_vmess("gui_set_browser_config", "iiiii", + gui_start_vmess("gui_set_browser_config", "iiiiii", sys_browser_doc, sys_browser_path, sys_browser_init, - sys_autocomplete, sys_autocomplete_prefix); + sys_autocomplete, sys_autocomplete_prefix, + sys_autocomplete_relevance); gui_start_array(); for (nl = sys_helppath; nl; nl = nl->nl_next) {