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