]> git.donarmstrong.com Git - dactyl.git/blob - common/content/hints.js
bcf431ce06175ffa7664bae2304ad5f26d819f64
[dactyl.git] / common / content / hints.js
1 // Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
2 // Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
3 // Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
4 //
5 // This work is licensed for reuse under an MIT license. Details are
6 // given in the LICENSE.txt file included with this file.
7 "use strict";
8
9 /** @scope modules */
10 /** @instance hints */
11
12 var HintSession = Class("HintSession", CommandMode, {
13     get extendedMode() modes.HINTS,
14
15     init: function init(mode, opts) {
16         init.supercall(this);
17
18         opts = opts || {};
19
20         // Hack.
21         if (!opts.window && modes.main == modes.OUTPUT_MULTILINE)
22             opts.window = commandline.widgets.multilineOutput.contentWindow;
23
24         this.hintMode = hints.modes[mode];
25         dactyl.assert(this.hintMode);
26
27         this.activeTimeout = null; // needed for hinttimeout > 0
28         this.continue = Boolean(opts.continue);
29         this.docs = [];
30         this.hintKeys = events.fromString(options["hintkeys"]).map(events.closure.toString);
31         this.hintNumber = 0;
32         this.hintString = opts.filter || "";
33         this.pageHints = [];
34         this.prevInput = "";
35         this.usedTabKey = false;
36         this.validHints = []; // store the indices of the "hints" array with valid elements
37
38         this.open();
39
40         this.top = opts.window || content;
41         this.top.addEventListener("resize", hints.resizeTimer.closure.tell, true);
42         this.top.addEventListener("dactyl-commandupdate", hints.resizeTimer.closure.tell, false, true);
43
44         this.generate();
45
46         this.show();
47
48         if (this.validHints.length == 0) {
49             dactyl.beep();
50             modes.pop();
51         }
52         else if (this.validHints.length == 1 && !this.continue)
53             this.process(false);
54         else // Ticket #185
55             this.checkUnique();
56     },
57
58     Hint: {
59         get active() this._active,
60         set active(val) {
61             this._active = val;
62             if (val)
63                 this.span.setAttribute("active", true);
64             else
65                 this.span.removeAttribute("active");
66
67             hints.setClass(this.elem, this.valid ? val : null);
68             if (this.imgSpan)
69                 hints.setClass(this.imgSpan, this.valid ? val : null);
70         },
71
72         get valid() this._valid,
73         set valid(val) {
74             this._valid = val,
75
76             this.span.style.display = (val ? "" : "none");
77             if (this.imgSpan)
78                 this.imgSpan.style.display = (val ? "" : "none");
79
80             this.active = this.active;
81         }
82     },
83
84     get mode() modes.HINTS,
85
86     get prompt() ["Question", UTF8(this.hintMode.prompt) + ": "],
87
88     leave: function leave(stack) {
89         leave.superapply(this, arguments);
90
91         if (!stack.push) {
92             if (hints.hintSession == this)
93                 hints.hintSession = null;
94             if (this.top) {
95                 this.top.removeEventListener("resize", hints.resizeTimer.closure.tell, true);
96                 this.top.removeEventListener("dactyl-commandupdate", hints.resizeTimer.closure.tell, true);
97             }
98
99             this.removeHints(0);
100         }
101     },
102
103     checkUnique: function _checkUnique() {
104         if (this.hintNumber == 0)
105             return;
106         dactyl.assert(this.hintNumber <= this.validHints.length);
107
108         // if we write a numeric part like 3, but we have 45 hints, only follow
109         // the hint after a timeout, as the user might have wanted to follow link 34
110         if (this.hintNumber > 0 && this.hintNumber * this.hintKeys.length <= this.validHints.length) {
111             let timeout = options["hinttimeout"];
112             if (timeout > 0)
113                 this.activeTimeout = this.timeout(function () {
114                     this.process(true);
115                 }, timeout);
116         }
117         else // we have a unique hint
118             this.process(true);
119     },
120
121     /**
122      * Clear any timeout which might be active after pressing a number
123      */
124     clearTimeout: function () {
125         if (this.activeTimeout)
126             this.activeTimeout.cancel();
127         this.activeTimeout = null;
128     },
129
130     _escapeNumbers: false,
131     get escapeNumbers() this._escapeNumbers,
132     set escapeNumbers(val) {
133         this.clearTimeout();
134         this._escapeNumbers = !!val;
135         if (val && this.usedTabKey)
136             this.hintNumber = 0;
137
138         this.updateStatusline();
139     },
140
141     /**
142      * Returns the hint string for a given number based on the values of
143      * the 'hintkeys' option.
144      *
145      * @param {number} n The number to transform.
146      * @returns {string}
147      */
148     getHintString: function getHintString(n) {
149         let res = [], len = this.hintKeys.length;
150         do {
151             res.push(this.hintKeys[n % len]);
152             n = Math.floor(n / len);
153         }
154         while (n > 0);
155         return res.reverse().join("");
156     },
157
158     /**
159      * Returns true if the given key string represents a
160      * pseudo-hint-number.
161      *
162      * @param {string} key The key to test.
163      * @returns {boolean} Whether the key represents a hint number.
164      */
165     isHintKey: function isHintKey(key) this.hintKeys.indexOf(key) >= 0,
166
167     /**
168      * Gets the actual offset of an imagemap area.
169      *
170      * Only called by {@link #_generate}.
171      *
172      * @param {Object} elem The <area> element.
173      * @param {number} leftPos The left offset of the image.
174      * @param {number} topPos The top offset of the image.
175      * @returns [leftPos, topPos] The updated offsets.
176      */
177     getAreaOffset: function _getAreaOffset(elem, leftPos, topPos) {
178         try {
179             // Need to add the offset to the area element.
180             // Always try to find the top-left point, as per dactyl default.
181             let shape = elem.getAttribute("shape").toLowerCase();
182             let coordStr = elem.getAttribute("coords");
183             // Technically it should be only commas, but hey
184             coordStr = coordStr.replace(/\s+[;,]\s+/g, ",").replace(/\s+/g, ",");
185             let coords = coordStr.split(",").map(Number);
186
187             if ((shape == "rect" || shape == "rectangle") && coords.length == 4) {
188                 leftPos += coords[0];
189                 topPos += coords[1];
190             }
191             else if (shape == "circle" && coords.length == 3) {
192                 leftPos += coords[0] - coords[2] / Math.sqrt(2);
193                 topPos += coords[1] - coords[2] / Math.sqrt(2);
194             }
195             else if ((shape == "poly" || shape == "polygon") && coords.length % 2 == 0) {
196                 let leftBound = Infinity;
197                 let topBound = Infinity;
198
199                 // First find the top-left corner of the bounding rectangle (offset from image topleft can be noticeably suboptimal)
200                 for (let i = 0; i < coords.length; i += 2) {
201                     leftBound = Math.min(coords[i], leftBound);
202                     topBound = Math.min(coords[i + 1], topBound);
203                 }
204
205                 let curTop = null;
206                 let curLeft = null;
207                 let curDist = Infinity;
208
209                 // Then find the closest vertex. (we could generalize to nearest point on an edge, but I doubt there is a need)
210                 for (let i = 0; i < coords.length; i += 2) {
211                     let leftOffset = coords[i] - leftBound;
212                     let topOffset = coords[i + 1] - topBound;
213                     let dist = Math.sqrt(leftOffset * leftOffset + topOffset * topOffset);
214                     if (dist < curDist) {
215                         curDist = dist;
216                         curLeft = coords[i];
217                         curTop = coords[i + 1];
218                     }
219                 }
220
221                 // If we found a satisfactory offset, let's use it.
222                 if (curDist < Infinity)
223                     return [leftPos + curLeft, topPos + curTop];
224             }
225         }
226         catch (e) {} // badly formed document, or shape == "default" in which case we don't move the hint
227         return [leftPos, topPos];
228     },
229
230     // the containing block offsets with respect to the viewport
231     getContainerOffsets: function _getContainerOffsets(doc) {
232         let body = doc.body || doc.documentElement;
233         // TODO: getComputedStyle returns null for Facebook channel_iframe doc - probable Gecko bug.
234         let style = util.computedStyle(body);
235
236         if (style && /^(absolute|fixed|relative)$/.test(style.position)) {
237             let rect = body.getClientRects()[0];
238             return [-rect.left, -rect.top];
239         }
240         else
241             return [doc.defaultView.scrollX, doc.defaultView.scrollY];
242     },
243
244     /**
245      * Generate the hints in a window.
246      *
247      * Pushes the hints into the pageHints object, but does not display them.
248      *
249      * @param {Window} win The window for which to generate hints.
250      * @default content
251      */
252     generate: function _generate(win, offsets) {
253         if (!win)
254             win = this.top;
255
256         let doc = win.document;
257
258         let [offsetX, offsetY] = this.getContainerOffsets(doc);
259
260         offsets = offsets || { left: 0, right: 0, top: 0, bottom: 0 };
261         offsets.right  = win.innerWidth  - offsets.right;
262         offsets.bottom = win.innerHeight - offsets.bottom;
263
264         function isVisible(elem) {
265             let rect = elem.getBoundingClientRect();
266             if (!rect || !rect.width || !rect.height ||
267                 rect.top > offsets.bottom || rect.bottom < offsets.top ||
268                 rect.left > offsets.right || rect.right < offsets.left)
269                 return false;
270
271             let computedStyle = doc.defaultView.getComputedStyle(elem, null);
272             if (computedStyle.visibility != "visible" || computedStyle.display == "none")
273                 return false;
274             return true;
275         }
276
277         let body = doc.body || doc.querySelector("body");
278         if (body) {
279             let fragment = util.xmlToDom(<div highlight="hints"/>, doc);
280             body.appendChild(fragment);
281             util.computedStyle(fragment).height; // Force application of binding.
282             let container = doc.getAnonymousElementByAttribute(fragment, "anonid", "hints") || fragment;
283
284             let baseNodeAbsolute = util.xmlToDom(<span highlight="Hint" style="display: none"/>, doc);
285
286             let mode = this.hintMode;
287             let res = mode.matcher(doc);
288
289             let start = this.pageHints.length;
290             for (let elem in res) {
291                 let hint = { elem: elem, showText: false, __proto__: this.Hint };
292
293                 if (!isVisible(elem) || mode.filter && !mode.filter(elem))
294                     continue;
295
296                 if (elem.hasAttributeNS(NS, "hint"))
297                     [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
298                 else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]))
299                     [hint.text, hint.showText] = hints.getInputHint(elem, doc);
300                 else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent))
301                     [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
302                 else
303                     hint.text = elem.textContent.toLowerCase();
304
305                 hint.span = baseNodeAbsolute.cloneNode(true);
306
307                 let rect = elem.getClientRects()[0] || elem.getBoundingClientRect();
308                 let leftPos = Math.max((rect.left + offsetX), offsetX);
309                 let topPos  = Math.max((rect.top + offsetY), offsetY);
310
311                 if (elem instanceof HTMLAreaElement)
312                     [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
313
314                 hint.span.style.left = leftPos + "px";
315                 hint.span.style.top =  topPos + "px";
316                 container.appendChild(hint.span);
317
318                 this.pageHints.push(hint);
319             }
320
321             this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
322         }
323
324         Array.forEach(win.frames, function (f) {
325             if (isVisible(f.frameElement)) {
326                 let rect = f.frameElement.getBoundingClientRect();
327                 this.generate(f, {
328                     left: Math.max(offsets.left - rect.left, 0),
329                     right: Math.max(rect.right - offsets.right, 0),
330                     top: Math.max(offsets.top - rect.top, 0),
331                     bottom: Math.max(rect.bottom - offsets.bottom, 0)
332                 });
333             }
334         }, this);
335
336         return true;
337     },
338
339     /**
340      * Handle user input.
341      *
342      * Will update the filter on displayed hints and follow the final hint if
343      * necessary.
344      *
345      * @param {Event} event The keypress event.
346      */
347     onChange: function onChange(event) {
348         this.prevInput = "text";
349
350         this.clearTimeout();
351
352         this.hintNumber = 0;
353         this.hintString = commandline.command;
354         this.updateStatusline();
355         this.show();
356         if (this.validHints.length == 1)
357             this.process(false);
358     },
359
360     /**
361      * Handle a hint mode event.
362      *
363      * @param {Event} event The event to handle.
364      */
365     onKeyPress: function onKeyPress(eventList) {
366         const KILL = false, PASS = true;
367         let key = events.toString(eventList[0]);
368
369         this.clearTimeout();
370
371         if (!this.escapeNumbers && this.isHintKey(key)) {
372             this.prevInput = "number";
373
374             let oldHintNumber = this.hintNumber;
375             if (this.usedTabKey) {
376                 this.hintNumber = 0;
377                 this.usedTabKey = false;
378             }
379             this.hintNumber = this.hintNumber * this.hintKeys.length +
380                 this.hintKeys.indexOf(key);
381
382             this.updateStatusline();
383
384             if (this.docs.length)
385                 this.updateValidNumbers();
386             else {
387                 this.generate();
388                 this.show();
389             }
390
391             this.showActiveHint(this.hintNumber, oldHintNumber || 1);
392
393             dactyl.assert(this.hintNumber != 0);
394
395             this.checkUnique();
396             return KILL;
397         }
398
399         return PASS;
400     },
401
402     onResize: function () {
403         this.removeHints(0);
404         this.generate(this.top);
405         this.show();
406     },
407
408     /**
409      * Finish hinting.
410      *
411      * Called when there are one or zero hints in order to possibly activate it
412      * and, if activated, to clean up the rest of the hinting system.
413      *
414      * @param {boolean} followFirst Whether to force the following of the first
415      *     link (when 'followhints' is 1 or 2)
416      *
417      */
418     process: function _processHints(followFirst) {
419         dactyl.assert(this.validHints.length > 0);
420
421         // This "followhints" option is *too* confusing. For me, and
422         // presumably for users, too. --Kris
423         if (options["followhints"] > 0) {
424             if (!followFirst)
425                 return; // no return hit; don't examine uniqueness
426
427             // OK. return hit. But there's more than one hint, and
428             // there's no tab-selected current link. Do not follow in mode 2
429             dactyl.assert(options["followhints"] != 2 || this.validHints.length == 1 || this.hintNumber);
430         }
431
432         if (!followFirst) {
433             let firstHref = this.validHints[0].elem.getAttribute("href") || null;
434             if (firstHref) {
435                 if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref))
436                     return;
437             }
438             else if (this.validHints.length > 1)
439                 return;
440         }
441
442         let timeout = followFirst || events.feedingKeys ? 0 : 500;
443         let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
444         let elem = this.validHints[activeIndex].elem;
445         let top = this.top;
446
447         if (this.continue)
448             this._reset();
449         else
450             this.removeHints(timeout);
451
452         let n = 5;
453         (function next() {
454             let hinted = n || this.validHints.some(function (h) h.elem === elem);
455             if (!hinted)
456                 hints.setClass(elem, null);
457             else if (n)
458                 hints.setClass(elem, n % 2);
459             else
460                 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber-1)].elem === elem);
461
462             if (n--)
463                 this.timeout(next, 50);
464         }).call(this);
465
466         if (!this.continue) {
467             modes.pop();
468             if (timeout)
469                 modes.push(modes.IGNORE, modes.HINTS);
470         }
471
472         this.timeout(function () {
473             if ((modes.extended & modes.HINTS) && !this.continue)
474                 modes.pop();
475             commandline.lastEcho = null; // Hack.
476             dactyl.trapErrors("action", this.hintMode,
477                               elem, elem.href || elem.src || "",
478                               this.extendedhintCount, top);
479             if (this.continue && this.top)
480                 this.show();
481         }, timeout);
482     },
483
484     /**
485      * Remove all hints from the document, and reset the completions.
486      *
487      * Lingers on the active hint briefly to confirm the selection to the user.
488      *
489      * @param {number} timeout The number of milliseconds before the active
490      *     hint disappears.
491      */
492     removeHints: function _removeHints(timeout) {
493         for (let { doc, start, end } in values(this.docs)) {
494             for (let elem in util.evaluateXPath("//*[@dactyl:highlight='hints']", doc))
495                 elem.parentNode.removeChild(elem);
496             for (let i in util.range(start, end + 1))
497                 this.pageHints[i].valid = false;
498         }
499         styles.system.remove("hint-positions");
500
501         this.reset();
502     },
503
504     reset: function reset() {
505         this.pageHints = [];
506         this.validHints = [];
507         this.docs = [];
508         this.clearTimeout();
509     },
510     _reset: function _reset() {
511         if (!this.usedTabKey)
512             this.hintNumber = 0;
513         if (this.continue && this.validHints.length <= 1) {
514             this.hintString = "";
515             commandline.widgets.command = this.hintString;
516             this.show();
517         }
518         this.updateStatusline();
519     },
520
521     /**
522      * Display the hints in pageHints that are still valid.
523      */
524     show: function _show() {
525         let hintnum = 1;
526         let validHint = hints.hintMatcher(this.hintString.toLowerCase());
527         let activeHint = this.hintNumber || 1;
528         this.validHints = [];
529
530         for (let { doc, start, end } in values(this.docs)) {
531             let [offsetX, offsetY] = this.getContainerOffsets(doc);
532
533         inner:
534             for (let i in (util.interruptibleRange(start, end + 1, 500))) {
535                 let hint = this.pageHints[i];
536
537                 hint.valid = validHint(hint.text);
538                 if (!hint.valid)
539                     continue inner;
540
541                 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
542                     if (!hint.imgSpan) {
543                         let rect = hint.elem.firstChild.getBoundingClientRect();
544                         if (!rect)
545                             continue;
546
547                         hint.imgSpan = util.xmlToDom(<span highlight="Hint" dactyl:hl="HintImage" xmlns:dactyl={NS}/>, doc);
548                         hint.imgSpan.style.display = "none";
549                         hint.imgSpan.style.left = (rect.left + offsetX) + "px";
550                         hint.imgSpan.style.top = (rect.top + offsetY) + "px";
551                         hint.imgSpan.style.width = (rect.right - rect.left) + "px";
552                         hint.imgSpan.style.height = (rect.bottom - rect.top) + "px";
553                         hint.span.parentNode.appendChild(hint.imgSpan);
554                     }
555                 }
556
557                 let str = this.getHintString(hintnum);
558                 let text = [];
559                 if (hint.elem instanceof HTMLInputElement)
560                     if (hint.elem.type === "radio")
561                         text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
562                     else if (hint.elem.type === "checkbox")
563                         text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
564                 if (hint.showText)
565                     text.push(hint.text.substr(0, 50));
566
567                 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
568                 hint.span.setAttribute("number", str);
569                 if (hint.imgSpan)
570                     hint.imgSpan.setAttribute("number", str);
571                 hint.active = activeHint == hintnum;
572                 this.validHints.push(hint);
573                 hintnum++;
574             }
575         }
576
577         if (options["usermode"]) {
578             let css = [];
579             for (let hint in values(this.pageHints)) {
580                 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
581                 let imgSpan = "[dactyl|hl=HintImage]";
582                 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
583                 if (hint.imgSpan)
584                     css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
585             }
586             styles.system.add("hint-positions", "*", css.join("\n"));
587         }
588
589         return true;
590     },
591
592     /**
593      * Update the activeHint.
594      *
595      * By default highlights it green instead of yellow.
596      *
597      * @param {number} newId The hint to make active.
598      * @param {number} oldId The currently active hint.
599      */
600     showActiveHint: function _showActiveHint(newId, oldId) {
601         let oldHint = this.validHints[oldId - 1];
602         if (oldHint)
603             oldHint.active = false;
604
605         let newHint = this.validHints[newId - 1];
606         if (newHint)
607             newHint.active = true;
608     },
609
610     backspace: function () {
611         this.clearTimeout();
612         if (this.prevInput !== "number")
613             return Events.PASS;
614
615         if (this.hintNumber > 0 && !this.usedTabKey) {
616             this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
617             if (this.hintNumber == 0)
618                 this.prevInput = "text";
619             this.update(false);
620         }
621         else {
622             this.usedTabKey = false;
623             this.hintNumber = 0;
624             dactyl.beep();
625         }
626         return Events.KILL;
627     },
628
629     updateValidNumbers: function updateValidNumbers(always) {
630         let string = this.getHintString(this.hintNumber);
631         for (let hint in values(this.validHints))
632             hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
633     },
634
635     tab: function tab(previous) {
636         this.clearTimeout();
637         this.usedTabKey = true;
638         if (this.hintNumber == 0)
639             this.hintNumber = 1;
640
641         let oldId = this.hintNumber;
642         if (!previous) {
643             if (++this.hintNumber > this.validHints.length)
644                 this.hintNumber = 1;
645         }
646         else {
647             if (--this.hintNumber < 1)
648                 this.hintNumber = this.validHints.length;
649         }
650
651         this.updateValidNumbers(true);
652         this.showActiveHint(this.hintNumber, oldId);
653         this.updateStatusline();
654     },
655
656     update: function update(followFirst) {
657         this.clearTimeout();
658         this.updateStatusline();
659
660         if (this.docs.length == 0 && this.hintString.length > 0)
661             this.generate();
662
663         this.show();
664         this.process(followFirst);
665     },
666
667     /**
668      * Display the current status to the user.
669      */
670     updateStatusline: function _updateStatusline() {
671         statusline.inputBuffer = (this.escapeNumbers ? options["mapleader"] : "") +
672                                  (this.hintNumber ? this.getHintString(this.hintNumber) : "");
673     },
674 });
675
676 var Hints = Module("hints", {
677     init: function init() {
678         this.resizeTimer = Timer(100, 500, function () {
679             if (isinstance(modes.main, modes.HINTS))
680                 modes.getStack(0).params.onResize();
681         });
682
683         let appContent = document.getElementById("appcontent");
684         if (appContent)
685             events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
686
687         const Mode = Hints.Mode;
688         Mode.defaultValue("tags", function () function () options.get("hinttags").matcher);
689         Mode.prototype.__defineGetter__("matcher", function ()
690             options.get("extendedhinttags").getKey(this.name, this.tags()));
691
692         this.modes = {};
693         this.addMode(";", "Focus hint",                           buffer.closure.focusElement);
694         this.addMode("?", "Show information for hint",            function (elem) buffer.showElementInfo(elem));
695         this.addMode("s", "Save hint",                            function (elem) buffer.saveLink(elem, false));
696         this.addMode("f", "Focus frame",                          function (elem) dactyl.focus(elem.ownerDocument.defaultView));
697         this.addMode("F", "Focus frame or pseudo-frame",          buffer.closure.focusElement, null, isScrollable);
698         this.addMode("o", "Follow hint",                          function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
699         this.addMode("t", "Follow hint in a new tab",             function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
700         this.addMode("b", "Follow hint in a background tab",      function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
701         this.addMode("w", "Follow hint in a new window",          function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
702         this.addMode("O", "Generate an ‘:open URL’ prompt",       function (elem, loc) CommandExMode().open("open " + loc));
703         this.addMode("T", "Generate a ‘:tabopen URL’ prompt",     function (elem, loc) CommandExMode().open("tabopen " + loc));
704         this.addMode("W", "Generate a ‘:winopen URL’ prompt",     function (elem, loc) CommandExMode().open("winopen " + loc));
705         this.addMode("a", "Add a bookmark",                       function (elem) bookmarks.addSearchKeyword(elem));
706         this.addMode("S", "Add a search keyword",                 function (elem) bookmarks.addSearchKeyword(elem));
707         this.addMode("v", "View hint source",                     function (elem, loc) buffer.viewSource(loc, false));
708         this.addMode("V", "View hint source in external editor",  function (elem, loc) buffer.viewSource(loc, true));
709         this.addMode("y", "Yank hint location",                   function (elem, loc) dactyl.clipboardWrite(loc, true));
710         this.addMode("Y", "Yank hint description",                function (elem) dactyl.clipboardWrite(elem.textContent || "", true));
711         this.addMode("c", "Open context menu",                    function (elem) buffer.openContextMenu(elem));
712         this.addMode("i", "Show image",                           function (elem) dactyl.open(elem.src));
713         this.addMode("I", "Show image in a new tab",              function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
714
715         function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
716             Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
717     },
718
719     hintSession: Modes.boundProperty(),
720
721     /**
722      * Creates a new hint mode.
723      *
724      * @param {string} mode The letter that identifies this mode.
725      * @param {string} prompt The description to display to the user
726      *     about this mode.
727      * @param {function(Node)} action The function to be called with the
728      *     element that matches.
729      * @param {function():string} tags The function that returns an
730      *     XPath expression to decide which elements can be hinted (the
731      *     default returns options["hinttags"]).
732      * @optional
733      */
734     addMode: function (mode, prompt, action, tags) {
735         arguments[1] = UTF8(prompt);
736         this.modes[mode] = Hints.Mode.apply(Hints.Mode, arguments);
737     },
738
739     /**
740      * Get a hint for "input", "textarea" and "select".
741      *
742      * Tries to use <label>s if possible but does not try to guess that a
743      * neighboring element might look like a label. Only called by
744      * {@link #_generate}.
745      *
746      * If it finds a hint it returns it, if the hint is not the caption of the
747      * element it will return showText=true.
748      *
749      * @param {Object} elem The element used to generate hint text.
750      * @param {Document} doc The containing document.
751      *
752      * @returns [text, showText]
753      */
754     getInputHint: function _getInputHint(elem, doc) {
755         // <input type="submit|button|reset"/>   Always use the value
756         // <input type="radio|checkbox"/>        Use the value if it is not numeric or label or name
757         // <input type="password"/>              Never use the value, use label or name
758         // <input type="text|file"/> <textarea/> Use value if set or label or name
759         // <input type="image"/>                 Use the alt text if present (showText) or label or name
760         // <input type="hidden"/>                Never gets here
761         // <select/>                             Use the text of the selected item or label or name
762
763         let type = elem.type;
764
765         if (elem instanceof HTMLInputElement && set.has(util.editableInputs, elem.type))
766             return [elem.value, false];
767         else {
768             for (let [, option] in Iterator(options["hintinputs"])) {
769                 if (option == "value") {
770                     if (elem instanceof HTMLSelectElement) {
771                         if (elem.selectedIndex >= 0)
772                             return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
773                     }
774                     else if (type == "image") {
775                         if (elem.alt)
776                             return [elem.alt.toLowerCase(), true];
777                     }
778                     else if (elem.value && type != "password") {
779                         // radio's and checkboxes often use internal ids as values - maybe make this an option too...
780                         if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
781                             return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
782                     }
783                 }
784                 else if (option == "label") {
785                     if (elem.id) {
786                         // TODO: (possibly) do some guess work for label-like objects
787                         let label = util.evaluateXPath(["label[@for=" + elem.id.quote() + "]"], doc).snapshotItem(0);
788                         if (label)
789                             return [label.textContent.toLowerCase(), true];
790                     }
791                 }
792                 else if (option == "name")
793                     return [elem.name.toLowerCase(), true];
794             }
795         }
796
797         return ["", false];
798     },
799
800     /**
801      * Get the hintMatcher according to user preference.
802      *
803      * @param {string} hintString The currently typed hint.
804      * @returns {hintMatcher}
805      */
806     hintMatcher: function _hintMatcher(hintString) { //{{{
807         /**
808          * Divide a string by a regular expression.
809          *
810          * @param {RegExp|string} pat The pattern to split on.
811          * @param {string} str The string to split.
812          * @returns {Array(string)} The lowercased splits of the splitting.
813          */
814         function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
815
816         /**
817          * Get a hint matcher for hintmatching=contains
818          *
819          * The hintMatcher expects the user input to be space delimited and it
820          * returns true if each set of characters typed can be found, in any
821          * order, in the link.
822          *
823          * @param {string} hintString  The string typed by the user.
824          * @returns {function(String):boolean} A function that takes the text
825          *     of a hint and returns true if all the (space-delimited) sets of
826          *     characters typed by the user can be found in it.
827          */
828         function containsMatcher(hintString) { //{{{
829             let tokens = tokenize(/\s+/, hintString);
830             return function (linkText) {
831                 linkText = linkText.toLowerCase();
832                 return tokens.every(function (token) indexOf(linkText, token) >= 0);
833             };
834         } //}}}
835
836         /**
837          * Get a hintMatcher for hintmatching=firstletters|wordstartswith
838          *
839          * The hintMatcher will look for any division of the user input that
840          * would match the first letters of words. It will always only match
841          * words in order.
842          *
843          * @param {string} hintString The string typed by the user.
844          * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
845          *     words to match.
846          * @returns {function(String):boolean} A function that will filter only
847          *     hints that match as above.
848          */
849         function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
850             let hintStrings     = tokenize(/\s+/, hintString);
851             let wordSplitRegexp = util.regexp(options["wordseparators"]);
852
853             /**
854              * Match a set of characters to the start of words.
855              *
856              * What the **** does this do? --Kris
857              * This function matches hintStrings like 'hekho' to links
858              * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
859              * --Daniel
860              *
861              * @param {string} chars The characters to match.
862              * @param {Array(string)} words The words to match them against.
863              * @param {boolean} allowWordOverleaping Whether words may be
864              *     skipped during matching.
865              * @returns {boolean} Whether a match can be found.
866              */
867             function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
868                 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
869                     let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
870                     if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
871                         let nextWordIdx = wordIdx + 1;
872                         if (nextWordIdx == words.length)
873                             return false;
874
875                         return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
876                     }
877
878                     if (matches) {
879                         let nextCharIdx = charIdx + 1;
880                         if (nextCharIdx == chars.length)
881                             return true;
882
883                         let nextWordIdx = wordIdx + 1;
884                         let beyondLastWord = (nextWordIdx == words.length);
885                         let charMatched = false;
886                         if (beyondLastWord == false)
887                             charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
888
889                         if (charMatched)
890                             return true;
891
892                         if (charMatched == false || beyondLastWord == true) {
893                             let nextInWordIdx = inWordIdx + 1;
894                             if (nextInWordIdx == words[wordIdx].length)
895                                 return false;
896
897                             return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
898                         }
899                     }
900
901                     return false;
902                 }
903
904                 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
905             }
906
907             /**
908              * Check whether the array of strings all exist at the start of the
909              * words.
910              *
911              * i.e. ['ro', 'e'] would match ['rollover', 'effect']
912              *
913              * The matches must be in order, and, if allowWordOverleaping is
914              * false, contiguous.
915              *
916              * @param {Array(string)} strings The strings to search for.
917              * @param {Array(string)} words The words to search in.
918              * @param {boolean} allowWordOverleaping Whether matches may be
919              *     non-contiguous.
920              * @returns {boolean} Whether all the strings matched.
921              */
922             function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
923                 let strIdx = 0;
924                 for (let [, word] in Iterator(words)) {
925                     if (word.length == 0)
926                         continue;
927
928                     let str = strings[strIdx];
929                     if (str.length == 0 || indexOf(word, str) == 0)
930                         strIdx++;
931                     else if (!allowWordOverleaping)
932                         return false;
933
934                     if (strIdx == strings.length)
935                         return true;
936                 }
937
938                 for (; strIdx < strings.length; strIdx++) {
939                     if (strings[strIdx].length != 0)
940                         return false;
941                 }
942                 return true;
943             }
944
945             return function (linkText) {
946                 if (hintStrings.length == 1 && hintStrings[0].length == 0)
947                     return true;
948
949                 let words = tokenize(wordSplitRegexp, linkText);
950                 if (hintStrings.length == 1)
951                     return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
952                 else
953                     return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
954             };
955         } //}}}
956
957         let indexOf = String.indexOf;
958         if (options.get("hintmatching").has("transliterated"))
959             indexOf = Hints.indexOf;
960
961         switch (options["hintmatching"][0]) {
962         case "contains"      : return containsMatcher(hintString);
963         case "wordstartswith": return wordStartsWithMatcher(hintString, true);
964         case "firstletters"  : return wordStartsWithMatcher(hintString, false);
965         case "custom"        : return dactyl.plugins.customHintMatcher(hintString);
966         default              : dactyl.echoerr(_("hints.noMatcher", hintMatching));
967         }
968         return null;
969     }, //}}}
970
971     open: function open(mode, opts) {
972         this._extendedhintCount = opts.count;
973         commandline.input(["Normal", mode], "", {
974             completer: function (context) {
975                 context.compare = function () 0;
976                 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
977             },
978             onSubmit: function (arg) {
979                 if (arg)
980                     hints.show(arg, opts);
981             },
982             onChange: function () {
983                 this.accepted = true;
984                 modes.pop();
985             },
986         });
987     },
988
989     /**
990      * Toggle the highlight of a hint.
991      *
992      * @param {Object} elem The element to toggle.
993      * @param {boolean} active Whether it is the currently active hint or not.
994      */
995     setClass: function _setClass(elem, active) {
996         if (elem.dactylHighlight == null)
997             elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
998
999         let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1000         if (active)
1001             highlight.highlightNode(elem, prefix + "HintActive");
1002         else if (active != null)
1003             highlight.highlightNode(elem, prefix + "HintElem");
1004         else {
1005             highlight.highlightNode(elem, elem.dactylHighlight);
1006             // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1007             elem.dactylHighlight = null;
1008         }
1009     },
1010
1011     show: function show(mode, opts) {
1012         this.hintSession = HintSession(mode, opts);
1013     }
1014 }, {
1015     translitTable: Class.memoize(function () {
1016         const table = {};
1017         [
1018             [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1019             [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1020             [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1021             [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1022             [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1023             [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1024             [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1025             [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1026             [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1027             [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1028             [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1029             [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1030             [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1031             [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1032             [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1033             [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1034             [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1035             [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1036             [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1037             [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1038             [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1039             [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1040             [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1041             [0x0193, 0x0194, ["G"]],
1042             [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1043             [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1044             [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1045             [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1046             [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1047                "I", "i", "O", "o"]],
1048             [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1049             [0x01e2, 0x01e3, ["AE", "ae"]],
1050             [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1051             [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1052             [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1053             [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1054                "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1055                "u", "U", "u"]],
1056             [0x0253, 0x0257, ["b", "c", "d", "d"]],
1057             [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1058             [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1059             [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1060             [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1061             [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1062             [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1063             [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1064             [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1065                "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1066                "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1067                "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1068                "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1069                "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1070                "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1071                "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1072             [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1073             [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1074             [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1075             [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1076             [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1077             [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1078             [0x24d0, 0x24e9, "a"],
1079             [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1080             [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1081         ].forEach(function (start, stop, val) {
1082             if (typeof val != "string")
1083                 for (let i = start; i <= stop; i++)
1084                     table[String.fromCharCode(i)] = val[(i - start) % val.length];
1085             else {
1086                 let n = val.charCodeAt(0);
1087                 for (let i = start; i <= stop; i++)
1088                     table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1089             }
1090         });
1091         return table;
1092     }),
1093     indexOf: function indexOf(dest, src) {
1094         let table = this.translitTable;
1095         var end = dest.length - src.length;
1096         if (src.length == 0)
1097             return 0;
1098     outer:
1099         for (var i = 0; i < end; i++) {
1100                 var j = i;
1101                 for (var k = 0; k < src.length;) {
1102                     var s = dest[j++];
1103                     s = table[s] || s;
1104                     for (var l = 0; l < s.length; l++, k++) {
1105                         if (s[l] != src[k])
1106                             continue outer;
1107                         if (k == src.length - 1)
1108                             return i;
1109                     }
1110                 }
1111             }
1112         return -1;
1113     },
1114
1115     Mode: Struct("HintMode", "name", "prompt", "action", "tags", "filter")
1116             .localize("prompt")
1117 }, {
1118     modes: function initModes() {
1119         initModes.require("commandline");
1120         modes.addMode("HINTS", {
1121             extended: true,
1122             description: "Active when selecting elements in QuickHint or ExtendedHint mode",
1123             bases: [modes.COMMAND_LINE],
1124             input: true,
1125             ownsBuffer: true
1126         });
1127     },
1128     mappings: function () {
1129         var myModes = config.browserModes.concat(modes.OUTPUT_MULTILINE);
1130         mappings.add(myModes, ["f"],
1131             "Start QuickHint mode",
1132             function () { hints.show("o"); });
1133
1134         mappings.add(myModes, ["F"],
1135             "Start QuickHint mode, but open link in a new tab",
1136             function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1137
1138         mappings.add(myModes, [";"],
1139             "Start an extended hint mode",
1140             function ({ count }) { hints.open(";", { count: count }); },
1141             { count: true });
1142
1143         mappings.add(myModes, ["g;"],
1144             "Start an extended hint mode and stay there until <Esc> is pressed",
1145             function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1146             { count: true });
1147
1148         mappings.add(modes.HINTS, ["<Return>"],
1149             "Follow the selected hint",
1150             function ({ self }) { self.update(true); });
1151
1152         mappings.add(modes.HINTS, ["<Tab>"],
1153             "Focus the next matching hint",
1154             function ({ self }) { self.tab(false); });
1155
1156         mappings.add(modes.HINTS, ["<S-Tab>"],
1157             "Focus the previous matching hint",
1158             function ({ self }) { self.tab(true); });
1159
1160         mappings.add(modes.HINTS, ["<BS>", "<C-h>"],
1161             "Delete the previous character",
1162             function ({ self }) self.backspace());
1163
1164         mappings.add(modes.HINTS, ["<Leader>"],
1165             "Toggle hint filtering",
1166             function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1167     },
1168     options: function () {
1169         function xpath(arg) util.makeXPath(arg);
1170
1171         options.add(["extendedhinttags", "eht"],
1172             "XPath or CSS selector strings of hintable elements for extended hint modes",
1173             "regexpmap", {
1174                 "[iI]": "img",
1175                 "[asOTivVWy]": ["a[href]", "area[href]", "img[src]", "iframe[src]"],
1176                 "[f]": "body",
1177                 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1178                 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1179             },
1180             {
1181                 keepQuotes: true,
1182                 getKey: function (val, default_)
1183                     let (res = array.nth(this.value, function (re) re.test(val), 0))
1184                         res ? res.matcher : default_,
1185                 setter: function (vals) {
1186                     for (let value in values(vals))
1187                         value.matcher = util.compileMatcher(Option.splitList(value.result));
1188                     return vals;
1189                 },
1190                 validator: util.validateMatcher
1191             });
1192
1193         options.add(["hinttags", "ht"],
1194             "XPath string of hintable elements activated by 'f' and 'F'",
1195             "stringlist", "input:not([type=hidden]),a,area,iframe,textarea,button,select," +
1196                           "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1197                           "[tabindex],[role=link],[role=button]",
1198             {
1199                 setter: function (values) {
1200                     this.matcher = util.compileMatcher(values);
1201                     return values;
1202                 },
1203                 validator: util.validateMatcher
1204             });
1205
1206         options.add(["hintkeys", "hk"],
1207             "The keys used to label and select hints",
1208             "string", "0123456789",
1209             {
1210                 values: {
1211                     "0123456789": "Numbers",
1212                     "asdfg;lkjh": "Home Row"
1213                 },
1214                 validator: function (value) {
1215                     let values = events.fromString(value).map(events.closure.toString);
1216                     return Option.validIf(array.uniq(values).length === values.length,
1217                                             "Duplicate keys not allowed");
1218                 }
1219             });
1220
1221         options.add(["hinttimeout", "hto"],
1222             "Timeout before automatically following a non-unique numerical hint",
1223             "number", 0,
1224             { validator: function (value) value >= 0 });
1225
1226         options.add(["followhints", "fh"],
1227             // FIXME: this description isn't very clear but I can't think of a
1228             // better one right now.
1229             "Change the behavior of <Return> in hint mode",
1230             "number", 0,
1231             {
1232                 values: {
1233                     "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1234                     "1": "Follow the selected hint on <Return>.",
1235                     "2": "Follow the selected hint on <Return> only it's been <Tab>-selected."
1236                 }
1237             });
1238
1239         options.add(["hintmatching", "hm"],
1240             "How hints are filtered",
1241             "stringlist", "contains",
1242             {
1243                 values: {
1244                     "contains":       "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1245                     "custom":         "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1246                     "firstletters":   "Behaves like wordstartswith, but all groups must match a sequence of words.",
1247                     "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1248                     "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1249                 },
1250                 validator: function (values) Option.validateCompleter.call(this, values) &&
1251                     1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
1252             });
1253
1254         options.add(["wordseparators", "wsp"],
1255             "Regular expression defining which characters separate words when matching hints",
1256             "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1257             { validator: function (value) RegExp(value) });
1258
1259         options.add(["hintinputs", "hin"],
1260             "Which text is used to filter hints for input elements",
1261             "stringlist", "label,value",
1262             {
1263                 values: {
1264                     "value": "Match against the value of the input field",
1265                     "label": "Match against the text of a label for the input field, if one can be found",
1266                     "name":  "Match against the name of the input field"
1267                 }
1268             });
1269     }
1270 });
1271
1272 // vim: set fdm=marker sw=4 ts=4 et: