diff --git a/common/content/completion.js b/common/content/completion.js index 7787c8af..bf125b96 100644 --- a/common/content/completion.js +++ b/common/content/completion.js @@ -160,10 +160,10 @@ const CompletionContext = Class("CompletionContext", { * @property {CompletionContext} The top-level completion context. */ this.top = this; - this.__defineGetter__("incomplete", function () this.contextList.some(function (c) c.parent && c.incomplete)); - this.__defineGetter__("waitingForTab", function () this.contextList.some(function (c) c.parent && c.waitingForTab)); - this.__defineSetter__("incomplete", function (val) {}); - this.__defineSetter__("waitingForTab", function (val) {}); + this.__defineGetter__("incomplete", function () this._incomplete || this.contextList.some(function (c) c.parent && c.incomplete)); + this.__defineGetter__("waitingForTab", function () this._waitingForTab || this.contextList.some(function (c) c.parent && c.waitingForTab)); + this.__defineSetter__("incomplete", function (val) { this._incomplete = val; }); + this.__defineSetter__("waitingForTab", function (val) { this._waitingForTab = val; }); this.reset(); } /** @@ -315,9 +315,17 @@ const CompletionContext = Class("CompletionContext", { this.process = format.process || this.process; }, + /** + * @property {string | xml | null} + * The message displayed at the head of the completions for the + * current context. + */ get message() this._message || (this.waitingForTab ? "Waiting for " : null), set message(val) this._message = val, + /** + * The prototype object for items returned by {@link items}. + */ get itemPrototype() { let res = {}; function result(quote) { @@ -339,18 +347,39 @@ const CompletionContext = Class("CompletionContext", { return res; }, + /** + * Returns true when the completions generated by {@link #generate} + * must be regenerated. May be set to true to invalidate the current + * completions. + */ get regenerate() this._generate && (!this.completions || !this.itemCache[this.key] || this.cache.offset != this.offset), set regenerate(val) { if (val) delete this.itemCache[this.key]; }, - get generate() !this._generate ? null : function () { + /** + * A property which may be set to a function to generate the value + * of {@link completions} only when necessary. The generated + * completions are linked to the value in {@link #key} and may be + * invalidated by setting the {@link #regenerate} property. + */ + get generate() this._generate || null, + set generate(arg) { + this.hasItems = true; + this._generate = arg; + }, + /** + * Generates the item list in {@link #completions} via the + * {@link #generate} method if the previously generated value is no + * longer valid. + */ + generateCompletions: function generateCompletions() { if (this.offset != this.cache.offset || this.lastActivated != this.top.runCount) { this.itemCache = {}; this.cache.offset = this.offset; this.lastActivated = this.top.runCount; } - if (!this.itemCache[this.key]) { + if (!this.itemCache[this.key]) try { - let res = this._generate.call(this); + let res = this._generate(); if (res != null) this.itemCache[this.key] = res; } @@ -358,37 +387,42 @@ const CompletionContext = Class("CompletionContext", { dactyl.reportError(e); this.message = "Error: " + e; } - } - return this.itemCache[this.key]; - }, - set generate(arg) { - this.hasItems = true; - this._generate = arg; + // XXX + this.noUpdate = true; + this.completions = this.itemCache[this.key]; + this.noUpdate = false; }, get ignoreCase() { - if ("_ignoreCase" in this) - return this._ignoreCase; - let mode = this.wildcase; - if (mode == "match") - return this._ignoreCase = false; - if (mode == "ignore") - return this._ignoreCase = true; - return this._ignoreCase = !/[A-Z]/.test(this.filter); + if (this._ignoreCase == null) { + let mode = this.wildcase; + if (mode == "match") + this._ignoreCase = false; + if (mode == "ignore") + this._ignoreCase = true; + this._ignoreCase = !/[A-Z]/.test(this.filter); + } + return this._ignoreCase; }, set ignoreCase(val) this._ignoreCase = val, + /** + * Returns a list of all completion items which match the current + * filter. The items returned are objects containing one property + * for each corresponding property in {@link keys}. The returned + * list is generated on-demand from the item list in {@link completions} + * or generated by {@link generate}, and is cached as long as no + * properties which would invalidate the result are changed. + */ get items() { + // Don't return any items if completions or generator haven't + // been set during this completion cycle. if (!this.hasItems) return []; // Regenerate completions if we must - if (this.generate) { - // XXX - this.noUpdate = true; - this.completions = this.generate(); - this.noUpdate = false; - } + if (this.generate) + this.generateCompletions(); let items = this.completions; // Check for cache miss @@ -404,7 +438,7 @@ const CompletionContext = Class("CompletionContext", { this.cache.rows = []; this.cache.filter = this.filter; if (items == null) - return items; + return null; let self = this; delete this._substrings; @@ -430,9 +464,10 @@ const CompletionContext = Class("CompletionContext", { try { // Item prototypes - let proto = this.itemPrototype; - if (!this.cache.constructed) + if (!this.cache.constructed) { + let proto = this.itemPrototype; this.cache.constructed = items.map(function (item) ({ __proto__: proto, item: item })); + } // Filters let filtered = this.filterFunc(this.cache.constructed); @@ -452,6 +487,10 @@ const CompletionContext = Class("CompletionContext", { } }, + /** + * Returns a list of all substrings common to all items which + * include the current filter. + */ get substrings() { let items = this.items; if (items.length == 0 || !this.hasItems) @@ -460,11 +499,13 @@ const CompletionContext = Class("CompletionContext", { return this._substrings; let fixCase = this.ignoreCase ? String.toLowerCase : util.identity; - let text = fixCase(items[0].text); + let text = fixCase(items[0].text); + let filter = fixCase(this.filter); + // Exceedingly long substrings cause Gecko to go into convulsions if (text.length > 100) text = text.substr(0, 100); - let filter = fixCase(this.filter); + if (this.anchored) { var compare = function compare(text, s) text.substr(0, s.length) == s; var substrings = [text]; @@ -480,15 +521,18 @@ const CompletionContext = Class("CompletionContext", { start = idx + 1; } } + substrings = items.reduce(function (res, item) - res.map(function (list) { - var m, len = list.length; - var n = list.length; + res.map(function (substring) { + // A simple binary search to find the longest substring + // of the given string which also matches the current + // item's text. + var m, len = substring.length; + var n = substring.length; var i = 0; while (n) { m = Math.floor(n / 2); - let s = list[i + m]; - let keep = compare(fixCase(item.text), list.substring(0, i + m)); + let keep = compare(fixCase(item.text), substring.substring(0, i + m)); if (!keep) len = i + m - 1; if (!keep || m == 0) @@ -498,9 +542,10 @@ const CompletionContext = Class("CompletionContext", { n = n - m; } } - return len == list.length ? list : list.substr(0, Math.max(len, 0)); + return len == substring.length ? substring : substring.substr(0, Math.max(len, 0)); }), substrings); + let quote = this.quote; if (quote) substrings = substrings.map(function (str) quote[0] + quote[1](str)); @@ -532,6 +577,10 @@ const CompletionContext = Class("CompletionContext", { this._filter = this._filter.substr(count); }, + /** + * Calls the {@link #cancel} method of all currently active + * sub-contexts. + */ cancelAll: function () { for (let [, context] in Iterator(this.contextList)) { if (context.cancel) @@ -571,6 +620,25 @@ const CompletionContext = Class("CompletionContext", { yield [i, cache[i] = cache[i] || util.xmlToDom(self.createRow(items[i]), doc)]; }, + /** + * Forks this completion context to create a new sub-context named + * as {this.name}/{name}. The new context is automatically advanced + * *offset* characters. If *completer* is provided, it is called + * with *self* as its 'this' object, the new context as its first + * argument, and any subsequent arguments after *completer* as its + * following arguments. + * + * If *completer* is provided, this function returns its return + * value, otherwise it returns the new completion context. + * + * @param {string} name The name of the new context. + * @param {number} offset The offset of the new context relative to + * the current context's offset. + * @param {object} self *completer*'s 'this' object. @optional + * @param {function|string} completer A completer function to call + * for the new context. If a string is provided, it is + * interpreted as a method to access on *self*. + */ fork: function fork(name, offset, self, completer) { if (typeof completer == "string") completer = self[completer]; @@ -591,6 +659,21 @@ const CompletionContext = Class("CompletionContext", { return context; }, + /** + * Highlights text in the nsIEditor associated with this completion + * context. *length* characters are highlighted from the position + * *start*, relative to the current context's offset, with the + * selection type *type* as defined in nsISelectionController. + * + * When called with no arguments, all highlights are removed. When + * called with a 0 length, all highlights of type *type* are + * removed. + * + * @param {number} start The position at which to start + * highlighting. + * @param {number} length The length of the substring to highlight. + * @param {string} type The selection type to highlight with. + */ highlight: function highlight(start, length, type) { if (arguments.length == 0) { for (let type in this.selectionTypes) @@ -614,15 +697,35 @@ const CompletionContext = Class("CompletionContext", { catch (e) {} }, - match: function match(str) { - return this.matchString(this.filter, str); - }, - - pushProcessor: function pushProcess(i, fn) { - let next = this.process[i]; - this.process[i] = function (item, text) fn(item, text, next); + /** + * Tests the given string for a match against the current filter, + * taking into account anchoring and case sensitivity rules. + * + * @param {string} str The string to match. + * @returns {boolean} True if the string matches, false otherwise. + */ + match: function match(str) this.matchString(this.filter, str), + + /** + * Pushes a new output processor onto the processor chain of + * {@link #process}. The provided function is called with the item + * and text to process along with a reference to the processor + * previously installed in the given *index* of {@link #process}. + * + * @param {number} index The index into {@link #process}. + * @param {function(object, string, function)} func The new + * processor. + */ + pushProcessor: function pushProcess(index, func) { + let next = this.process[index]; + this.process[index] = function (item, text) func(item, text, next); }, + /** + * Resets this completion context and all sub-contexts for use in a + * new completion cycle. May only be called on the top-level + * context. + */ reset: function reset() { let self = this; if (this.parent) @@ -649,9 +752,9 @@ const CompletionContext = Class("CompletionContext", { // delete this.contexts[key]; for each (let context in this.contexts) { context.hasItems = false; - if (context != context.top) - context.incomplete = false; + context.incomplete = false; } + this.waitingForTab = false; this.runCount++; for each (let context in this.contextList) context.lastActivated = this.runCount;