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