]> git.donarmstrong.com Git - dactyl.git/blob - common/content/hints.js
Import 1.0 supporting Firefox up to 14.*
[dactyl.git] / common / content / hints.js
1 // Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
2 // Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
3 // Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
4 //
5 // This work is licensed for reuse under an MIT license. Details are
6 // given in the LICENSE.txt file included with this file.
7 /* use strict */
8
9 /** @scope modules */
10 /** @instance hints */
11
12 var HintSession = Class("HintSession", CommandMode, {
13     get extendedMode() modes.HINTS,
14
15     init: function init(mode, opts) {
16         init.supercall(this);
17
18         opts = opts || {};
19
20         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             let hinted = n || this.validHints.some(function (h) h.elem === elem);
494             if (!hinted)
495                 hints.setClass(elem, null);
496             else if (n)
497                 hints.setClass(elem, n % 2);
498             else
499                 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem);
500
501             if (n--)
502                 this.timeout(next, 50);
503         }).call(this);
504
505         mappings.pushCommand();
506         if (!this.continue) {
507             modes.pop();
508             if (timeout)
509                 modes.push(modes.IGNORE, modes.HINTS);
510         }
511
512         dactyl.trapErrors("action", this.hintMode,
513                           elem, elem.href || elem.src || "",
514                           this.extendedhintCount, top);
515         mappings.popCommand();
516
517         this.timeout(function () {
518             if (modes.main == modes.IGNORE && !this.continue)
519                 modes.pop();
520             commandline.lastEcho = null; // Hack.
521             if (this.continue && this.top)
522                 this.show();
523         }, timeout);
524     },
525
526     /**
527      * Remove all hints from the document, and reset the completions.
528      *
529      * Lingers on the active hint briefly to confirm the selection to the user.
530      *
531      * @param {number} timeout The number of milliseconds before the active
532      *     hint disappears.
533      */
534     removeHints: function _removeHints(timeout) {
535         for (let { doc, start, end } in values(this.docs)) {
536             DOM(doc.documentElement).highlight.remove("Hinting");
537             // Goddamn stupid fucking Gecko 1.x security manager bullshit.
538             try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; }
539
540             for (let elem in DOM.XPath("//*[@dactyl:highlight='hints']", doc))
541                 elem.parentNode.removeChild(elem);
542             for (let i in util.range(start, end + 1)) {
543                 this.pageHints[i].ambiguous = false;
544                 this.pageHints[i].valid = false;
545             }
546         }
547         styles.system.remove("hint-positions");
548
549         this.reset();
550     },
551
552     reset: function reset() {
553         this.pageHints = [];
554         this.validHints = [];
555         this.docs = [];
556         this.clearTimeout();
557     },
558     _reset: function _reset() {
559         if (!this.usedTabKey)
560             this.hintNumber = 0;
561         if (this.continue && this.validHints.length <= 1) {
562             this.hintString = "";
563             commandline.widgets.command = this.hintString;
564             this.show();
565         }
566         this.updateStatusline();
567     },
568
569     /**
570      * Display the hints in pageHints that are still valid.
571      */
572     show: function _show() {
573         let hintnum = 1;
574         let validHint = hints.hintMatcher(this.hintString.toLowerCase());
575         let activeHint = this.hintNumber || 1;
576         this.validHints = [];
577
578         for (let { doc, start, end } in values(this.docs)) {
579             DOM(doc.documentElement).highlight.add("Hinting");
580             let [offsetX, offsetY] = this.getContainerOffsets(doc);
581
582         inner:
583             for (let i in (util.interruptibleRange(start, end + 1, 500))) {
584                 let hint = this.pageHints[i];
585
586                 hint.valid = validHint(hint.text);
587                 if (!hint.valid)
588                     continue inner;
589
590                 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
591                     if (!hint.imgSpan) {
592                         let rect = hint.elem.firstChild.getBoundingClientRect();
593                         if (!rect)
594                             continue;
595
596                         hint.imgSpan = DOM(<span highlight="Hint" dactyl:hl="HintImage" xmlns:dactyl={NS}/>, doc).css({
597                             display: "none",
598                             left: (rect.left + offsetX) + "px",
599                             top: (rect.top + offsetY) + "px",
600                             width: (rect.right - rect.left) + "px",
601                             height: (rect.bottom - rect.top) + "px"
602                         }).appendTo(hint.span.parentNode)[0];
603                     }
604                 }
605
606                 let str = this.getHintString(hintnum);
607                 let text = [];
608                 if (hint.elem instanceof HTMLInputElement)
609                     if (hint.elem.type === "radio")
610                         text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
611                     else if (hint.elem.type === "checkbox")
612                         text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
613                 if (hint.showText && !/^\s*$/.test(hint.text))
614                     text.push(hint.text.substr(0, 50));
615
616                 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
617                 hint.span.setAttribute("number", str);
618                 if (hint.imgSpan)
619                     hint.imgSpan.setAttribute("number", str);
620
621                 hint.active = activeHint == hintnum;
622
623                 this.validHints.push(hint);
624                 hintnum++;
625             }
626         }
627
628         let base = this.hintKeys.length;
629         for (let [i, hint] in Iterator(this.validHints))
630             hint.ambiguous = (i + 1) * base <= this.validHints.length;
631
632         if (options["usermode"]) {
633             let css = [];
634             for (let hint in values(this.pageHints)) {
635                 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
636                 let imgSpan = "[dactyl|hl=HintImage]";
637                 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
638                 if (hint.imgSpan)
639                     css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
640             }
641             styles.system.add("hint-positions", "*", css.join("\n"));
642         }
643
644         return true;
645     },
646
647     /**
648      * Update the activeHint.
649      *
650      * By default highlights it green instead of yellow.
651      *
652      * @param {number} newId The hint to make active.
653      * @param {number} oldId The currently active hint.
654      */
655     showActiveHint: function _showActiveHint(newId, oldId) {
656         let oldHint = this.validHints[oldId - 1];
657         if (oldHint)
658             oldHint.active = false;
659
660         let newHint = this.validHints[newId - 1];
661         if (newHint)
662             newHint.active = true;
663     },
664
665     backspace: function () {
666         this.clearTimeout();
667         if (this.prevInput !== "number")
668             return Events.PASS;
669
670         if (this.hintNumber > 0 && !this.usedTabKey) {
671             this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
672             if (this.hintNumber == 0)
673                 this.prevInput = "text";
674             this.update(false);
675         }
676         else {
677             this.usedTabKey = false;
678             this.hintNumber = 0;
679             dactyl.beep();
680         }
681         return Events.KILL;
682     },
683
684     updateValidNumbers: function updateValidNumbers(always) {
685         let string = this.getHintString(this.hintNumber);
686         for (let hint in values(this.validHints))
687             hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
688     },
689
690     tab: function tab(previous) {
691         this.clearTimeout();
692         this.usedTabKey = true;
693         if (this.hintNumber == 0)
694             this.hintNumber = 1;
695
696         let oldId = this.hintNumber;
697         if (!previous) {
698             if (++this.hintNumber > this.validHints.length)
699                 this.hintNumber = 1;
700         }
701         else {
702             if (--this.hintNumber < 1)
703                 this.hintNumber = this.validHints.length;
704         }
705
706         this.updateValidNumbers(true);
707         this.showActiveHint(this.hintNumber, oldId);
708         this.updateStatusline();
709     },
710
711     update: function update(followFirst) {
712         this.clearTimeout();
713         this.updateStatusline();
714
715         if (this.docs.length == 0 && this.hintString.length > 0)
716             this.generate();
717
718         this.show();
719         this.process(followFirst);
720     },
721
722     /**
723      * Display the current status to the user.
724      */
725     updateStatusline: function _updateStatusline() {
726         statusline.inputBuffer = (this.escapeNumbers ? "\\" : "") +
727                                  (this.hintNumber ? this.getHintString(this.hintNumber) : "");
728     },
729 });
730
731 var Hints = Module("hints", {
732     init: function init() {
733         this.resizeTimer = Timer(100, 500, function () {
734             if (isinstance(modes.main, modes.HINTS))
735                 modes.getStack(0).params.onResize();
736         });
737
738         let appContent = document.getElementById("appcontent");
739         if (appContent)
740             events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
741
742         const Mode = Hints.Mode;
743         Mode.prototype.__defineGetter__("matcher", function ()
744             options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher));
745
746         this.modes = {};
747         this.addMode(";", "Focus hint",                           buffer.closure.focusElement);
748         this.addMode("?", "Show information for hint",            function (elem) buffer.showElementInfo(elem));
749         this.addMode("s", "Save hint",                            function (elem) buffer.saveLink(elem, false));
750         this.addMode("f", "Focus frame",                          function (elem) dactyl.focus(elem.ownerDocument.defaultView));
751         this.addMode("F", "Focus frame or pseudo-frame",          buffer.closure.focusElement, isScrollable);
752         this.addMode("o", "Follow hint",                          function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
753         this.addMode("t", "Follow hint in a new tab",             function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
754         this.addMode("b", "Follow hint in a background tab",      function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
755         this.addMode("w", "Follow hint in a new window",          function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
756         this.addMode("O", "Generate an ‘:open URL’ prompt",       function (elem, loc) CommandExMode().open("open " + loc));
757         this.addMode("T", "Generate a ‘:tabopen URL’ prompt",     function (elem, loc) CommandExMode().open("tabopen " + loc));
758         this.addMode("W", "Generate a ‘:winopen URL’ prompt",     function (elem, loc) CommandExMode().open("winopen " + loc));
759         this.addMode("a", "Add a bookmark",                       function (elem) bookmarks.addSearchKeyword(elem));
760         this.addMode("S", "Add a search keyword",                 function (elem) bookmarks.addSearchKeyword(elem));
761         this.addMode("v", "View hint source",                     function (elem, loc) buffer.viewSource(loc, false));
762         this.addMode("V", "View hint source in external editor",  function (elem, loc) buffer.viewSource(loc, true));
763         this.addMode("y", "Yank hint location",                   function (elem, loc) editor.setRegister(null, loc, true));
764         this.addMode("Y", "Yank hint description",                function (elem) editor.setRegister(null, elem.textContent || "", true));
765         this.addMode("A", "Yank hint anchor url",                 function (elem) {
766             let uri = elem.ownerDocument.documentURIObject.clone();
767             uri.ref = elem.id || elem.name;
768             dactyl.clipboardWrite(uri.spec, true);
769         });
770         this.addMode("c", "Open context menu",                    function (elem) DOM(elem).contextmenu());
771         this.addMode("i", "Show image",                           function (elem) dactyl.open(elem.src));
772         this.addMode("I", "Show image in a new tab",              function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
773
774         function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
775             Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
776     },
777
778     hintSession: Modes.boundProperty(),
779
780     /**
781      * Creates a new hints mode.
782      *
783      * @param {string} mode The letter that identifies this mode.
784      * @param {string} prompt The description to display to the user
785      *     about this mode.
786      * @param {function(Node)} action The function to be called with the
787      *     element that matches.
788      * @param {function(Node):boolean} filter A function used to filter
789      *     the returned node set.
790      * @param {[string]} tags A value to add to the default
791      *     'extendedhinttags' value for this mode.
792      *     @optional
793      */
794     addMode: function (mode, prompt, action, filter, tags) {
795         function toString(regexp) RegExp.prototype.toString.call(regexp);
796
797         if (tags != null) {
798             let eht = options.get("extendedhinttags");
799             let update = eht.isDefault;
800
801             let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0];
802             eht.defaultValue = eht.defaultValue.filter(function (re) toString(re) != toString(value))
803                                   .concat(value);
804
805             if (update)
806                 eht.reset();
807         }
808
809         this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter);
810     },
811
812     /**
813      * Get a hint for "input", "textarea" and "select".
814      *
815      * Tries to use <label>s if possible but does not try to guess that a
816      * neighboring element might look like a label. Only called by
817      * {@link #_generate}.
818      *
819      * If it finds a hint it returns it, if the hint is not the caption of the
820      * element it will return showText=true.
821      *
822      * @param {Object} elem The element used to generate hint text.
823      * @param {Document} doc The containing document.
824      *
825      * @returns [text, showText]
826      */
827     getInputHint: function _getInputHint(elem, doc) {
828         // <input type="submit|button|reset"/>   Always use the value
829         // <input type="radio|checkbox"/>        Use the value if it is not numeric or label or name
830         // <input type="password"/>              Never use the value, use label or name
831         // <input type="text|file"/> <textarea/> Use value if set or label or name
832         // <input type="image"/>                 Use the alt text if present (showText) or label or name
833         // <input type="hidden"/>                Never gets here
834         // <select/>                             Use the text of the selected item or label or name
835
836         let type = elem.type;
837
838         if (DOM(elem).isInput)
839             return [elem.value, false];
840         else {
841             for (let [, option] in Iterator(options["hintinputs"])) {
842                 if (option == "value") {
843                     if (elem instanceof HTMLSelectElement) {
844                         if (elem.selectedIndex >= 0)
845                             return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
846                     }
847                     else if (type == "image") {
848                         if (elem.alt)
849                             return [elem.alt.toLowerCase(), true];
850                     }
851                     else if (elem.value && type != "password") {
852                         // radios and checkboxes often use internal ids as values - maybe make this an option too...
853                         if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
854                             return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
855                     }
856                 }
857                 else if (option == "label") {
858                     if (elem.id) {
859                         let label = elem.ownerDocument.dactylLabels[elem.id];
860                         if (label)
861                             return [label.textContent.toLowerCase(), true];
862                     }
863                 }
864                 else if (option == "name")
865                     return [elem.name.toLowerCase(), true];
866             }
867         }
868
869         return ["", false];
870     },
871
872     /**
873      * Get the hintMatcher according to user preference.
874      *
875      * @param {string} hintString The currently typed hint.
876      * @returns {hintMatcher}
877      */
878     hintMatcher: function _hintMatcher(hintString) { //{{{
879         /**
880          * Divide a string by a regular expression.
881          *
882          * @param {RegExp|string} pat The pattern to split on.
883          * @param {string} str The string to split.
884          * @returns {Array(string)} The lowercased splits of the splitting.
885          */
886         function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
887
888         /**
889          * Get a hint matcher for hintmatching=contains
890          *
891          * The hintMatcher expects the user input to be space delimited and it
892          * returns true if each set of characters typed can be found, in any
893          * order, in the link.
894          *
895          * @param {string} hintString The string typed by the user.
896          * @returns {function(String):boolean} A function that takes the text
897          *     of a hint and returns true if all the (space-delimited) sets of
898          *     characters typed by the user can be found in it.
899          */
900         function containsMatcher(hintString) { //{{{
901             let tokens = tokenize(/\s+/, hintString);
902             return function (linkText) {
903                 linkText = linkText.toLowerCase();
904                 return tokens.every(function (token) indexOf(linkText, token) >= 0);
905             };
906         } //}}}
907
908         /**
909          * Get a hintMatcher for hintmatching=firstletters|wordstartswith
910          *
911          * The hintMatcher will look for any division of the user input that
912          * would match the first letters of words. It will always only match
913          * words in order.
914          *
915          * @param {string} hintString The string typed by the user.
916          * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
917          *     words to match.
918          * @returns {function(String):boolean} A function that will filter only
919          *     hints that match as above.
920          */
921         function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
922             let hintStrings     = tokenize(/\s+/, hintString);
923             let wordSplitRegexp = util.regexp(options["wordseparators"]);
924
925             /**
926              * Match a set of characters to the start of words.
927              *
928              * What the **** does this do? --Kris
929              * This function matches hintStrings like 'hekho' to links
930              * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
931              * --Daniel
932              *
933              * @param {string} chars The characters to match.
934              * @param {Array(string)} words The words to match them against.
935              * @param {boolean} allowWordOverleaping Whether words may be
936              *     skipped during matching.
937              * @returns {boolean} Whether a match can be found.
938              */
939             function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
940                 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
941                     let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
942                     if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
943                         let nextWordIdx = wordIdx + 1;
944                         if (nextWordIdx == words.length)
945                             return false;
946
947                         return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
948                     }
949
950                     if (matches) {
951                         let nextCharIdx = charIdx + 1;
952                         if (nextCharIdx == chars.length)
953                             return true;
954
955                         let nextWordIdx = wordIdx + 1;
956                         let beyondLastWord = (nextWordIdx == words.length);
957                         let charMatched = false;
958                         if (beyondLastWord == false)
959                             charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
960
961                         if (charMatched)
962                             return true;
963
964                         if (charMatched == false || beyondLastWord == true) {
965                             let nextInWordIdx = inWordIdx + 1;
966                             if (nextInWordIdx == words[wordIdx].length)
967                                 return false;
968
969                             return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
970                         }
971                     }
972
973                     return false;
974                 }
975
976                 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
977             }
978
979             /**
980              * Check whether the array of strings all exist at the start of the
981              * words.
982              *
983              * i.e. ['ro', 'e'] would match ['rollover', 'effect']
984              *
985              * The matches must be in order, and, if allowWordOverleaping is
986              * false, contiguous.
987              *
988              * @param {Array(string)} strings The strings to search for.
989              * @param {Array(string)} words The words to search in.
990              * @param {boolean} allowWordOverleaping Whether matches may be
991              *     non-contiguous.
992              * @returns {boolean} Whether all the strings matched.
993              */
994             function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
995                 let strIdx = 0;
996                 for (let [, word] in Iterator(words)) {
997                     if (word.length == 0)
998                         continue;
999
1000                     let str = strings[strIdx];
1001                     if (str.length == 0 || indexOf(word, str) == 0)
1002                         strIdx++;
1003                     else if (!allowWordOverleaping)
1004                         return false;
1005
1006                     if (strIdx == strings.length)
1007                         return true;
1008                 }
1009
1010                 for (; strIdx < strings.length; strIdx++) {
1011                     if (strings[strIdx].length != 0)
1012                         return false;
1013                 }
1014                 return true;
1015             }
1016
1017             return function (linkText) {
1018                 if (hintStrings.length == 1 && hintStrings[0].length == 0)
1019                     return true;
1020
1021                 let words = tokenize(wordSplitRegexp, linkText);
1022                 if (hintStrings.length == 1)
1023                     return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
1024                 else
1025                     return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
1026             };
1027         } //}}}
1028
1029         let indexOf = String.indexOf;
1030         if (options.get("hintmatching").has("transliterated"))
1031             indexOf = Hints.closure.indexOf;
1032
1033         switch (options["hintmatching"][0]) {
1034         case "contains"      : return containsMatcher(hintString);
1035         case "wordstartswith": return wordStartsWithMatcher(hintString, true);
1036         case "firstletters"  : return wordStartsWithMatcher(hintString, false);
1037         case "custom"        : return dactyl.plugins.customHintMatcher(hintString);
1038         default              : dactyl.echoerr(_("hints.noMatcher", hintMatching));
1039         }
1040         return null;
1041     }, //}}}
1042
1043     open: function open(mode, opts) {
1044         this._extendedhintCount = opts.count;
1045
1046         opts = opts || {};
1047
1048         mappings.pushCommand();
1049         commandline.input(["Normal", mode], null, {
1050             autocomplete: false,
1051             completer: function (context) {
1052                 context.compare = function () 0;
1053                 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
1054             },
1055             onCancel: mappings.closure.popCommand,
1056             onSubmit: function (arg) {
1057                 if (arg)
1058                     hints.show(arg, opts);
1059                 mappings.popCommand();
1060             },
1061             onChange: function (arg) {
1062                 if (Object.keys(hints.modes).some(function (m) m != arg && m.indexOf(arg) == 0))
1063                     return;
1064
1065                 this.accepted = true;
1066                 modes.pop();
1067             }
1068         });
1069     },
1070
1071     /**
1072      * Toggle the highlight of a hint.
1073      *
1074      * @param {Object} elem The element to toggle.
1075      * @param {boolean} active Whether it is the currently active hint or not.
1076      */
1077     setClass: function _setClass(elem, active) {
1078         if (elem.dactylHighlight == null)
1079             elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
1080
1081         let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1082         if (active)
1083             highlight.highlightNode(elem, prefix + "HintActive");
1084         else if (active != null)
1085             highlight.highlightNode(elem, prefix + "HintElem");
1086         else {
1087             highlight.highlightNode(elem, elem.dactylHighlight);
1088             // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1089             elem.dactylHighlight = null;
1090         }
1091     },
1092
1093     show: function show(mode, opts) {
1094         this.hintSession = HintSession(mode, opts);
1095     }
1096 }, {
1097     isVisible: function isVisible(elem, offScreen) {
1098         let rect = elem.getBoundingClientRect();
1099         if (!rect.width || !rect.height)
1100             if (!Array.some(elem.childNodes, function (elem) elem instanceof Element && DOM(elem).style.float != "none" && isVisible(elem)))
1101                 return false;
1102
1103         let win = elem.ownerDocument.defaultView;
1104         if (offScreen && (rect.top + win.scrollY < 0 || rect.left + win.scrollX < 0 ||
1105                           rect.bottom + win.scrollY > win.scrolMaxY + win.innerHeight ||
1106                           rect.right + win.scrollX > win.scrolMaxX + win.innerWidth))
1107             return false;
1108
1109         if (!DOM(elem).isVisible)
1110             return false;
1111         return true;
1112     },
1113
1114     translitTable: Class.Memoize(function () {
1115         const table = {};
1116         [
1117             [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1118             [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1119             [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1120             [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1121             [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1122             [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1123             [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1124             [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1125             [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1126             [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1127             [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1128             [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1129             [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1130             [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1131             [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1132             [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1133             [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1134             [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1135             [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1136             [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1137             [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1138             [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1139             [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1140             [0x0193, 0x0194, ["G"]],
1141             [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1142             [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1143             [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1144             [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1145             [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1146                "I", "i", "O", "o"]],
1147             [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1148             [0x01e2, 0x01e3, ["AE", "ae"]],
1149             [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1150             [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1151             [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1152             [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1153                "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1154                "u", "U", "u"]],
1155             [0x0253, 0x0257, ["b", "c", "d", "d"]],
1156             [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1157             [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1158             [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1159             [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1160             [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1161             [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1162             [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1163             [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1164                "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1165                "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1166                "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1167                "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1168                "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1169                "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1170                "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1171             [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1172             [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1173             [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1174             [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1175             [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1176             [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1177             [0x24d0, 0x24e9, "a"],
1178             [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1179             [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1180         ].forEach(function ([start, stop, val]) {
1181             if (typeof val != "string")
1182                 for (let i = start; i <= stop; i++)
1183                     table[String.fromCharCode(i)] = val[(i - start) % val.length];
1184             else {
1185                 let n = val.charCodeAt(0);
1186                 for (let i = start; i <= stop; i++)
1187                     table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1188             }
1189         });
1190         return table;
1191     }),
1192     indexOf: function indexOf(dest, src) {
1193         let table = this.translitTable;
1194         var end = dest.length - src.length;
1195         if (src.length == 0)
1196             return 0;
1197     outer:
1198         for (var i = 0; i <= end; i++) {
1199                 var j = i;
1200                 for (var k = 0; k < src.length;) {
1201                     var s = dest[j++];
1202                     s = table[s] || s;
1203                     for (var l = 0; l < s.length; l++, k++) {
1204                         if (s[l] != src[k])
1205                             continue outer;
1206                         if (k == src.length - 1)
1207                             return i;
1208                     }
1209                 }
1210             }
1211         return -1;
1212     },
1213
1214     Mode: Struct("HintMode", "name", "prompt", "action", "filter")
1215             .localize("prompt")
1216 }, {
1217     modes: function initModes() {
1218         initModes.require("commandline");
1219         modes.addMode("HINTS", {
1220             extended: true,
1221             description: "Active when selecting elements with hints",
1222             bases: [modes.COMMAND_LINE],
1223             input: true,
1224             ownsBuffer: true
1225         });
1226     },
1227     mappings: function () {
1228         let bind = function bind(names, description, action, params)
1229             mappings.add(config.browserModes, names, description,
1230                          action, params);
1231
1232         bind(["f"],
1233             "Start Hints mode",
1234             function () { hints.show("o"); });
1235
1236         bind(["F"],
1237             "Start Hints mode, but open link in a new tab",
1238             function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1239
1240         bind([";"],
1241             "Start an extended hints mode",
1242             function ({ count }) { hints.open(";", { count: count }); },
1243             { count: true });
1244
1245         bind(["g;"],
1246             "Start an extended hints mode and stay there until <Esc> is pressed",
1247             function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1248             { count: true });
1249
1250         let bind = function bind(names, description, action, params)
1251             mappings.add([modes.HINTS], names, description,
1252                          action, params);
1253
1254         bind(["<Return>"],
1255             "Follow the selected hint",
1256             function ({ self }) { self.update(true); });
1257
1258         bind(["<Tab>"],
1259             "Focus the next matching hint",
1260             function ({ self }) { self.tab(false); });
1261
1262         bind(["<S-Tab>"],
1263             "Focus the previous matching hint",
1264             function ({ self }) { self.tab(true); });
1265
1266         bind(["<BS>", "<C-h>"],
1267             "Delete the previous character",
1268             function ({ self }) self.backspace());
1269
1270         bind(["\\"],
1271             "Toggle hint filtering",
1272             function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1273     },
1274     options: function () {
1275         options.add(["extendedhinttags", "eht"],
1276             "XPath or CSS selector strings of hintable elements for extended hint modes",
1277             "regexpmap", {
1278                 // Make sure to update the docs when you change this.
1279                 "[iI]": "img",
1280                 "[asOTvVWy]": [":-moz-any-link", "area[href]", "img[src]", "iframe[src]"],
1281                 "[A]": ["[id]", "a[name]"],
1282                 "[f]": "body",
1283                 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1284                 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1285             },
1286             {
1287                 keepQuotes: true,
1288                 getKey: function (val, default_)
1289                     let (res = array.nth(this.value, function (re) let (match = re.exec(val)) match && match[0] == val, 0))
1290                         res ? res.matcher : default_,
1291                 parse: function parse(val) {
1292                     let vals = parse.supercall(this, val);
1293                     for (let value in values(vals))
1294                         value.matcher = DOM.compileMatcher(Option.splitList(value.result));
1295                     return vals;
1296                 },
1297                 testValues: function testValues(vals, validator) vals.every(function (re) Option.splitList(re).every(validator)),
1298                 validator: DOM.validateMatcher
1299             });
1300
1301         options.add(["hinttags", "ht"],
1302             "XPath or CSS selector strings of hintable elements for Hints mode",
1303             // Make sure to update the docs when you change this.
1304             "stringlist", ":-moz-any-link,area,button,iframe,input:not([type=hidden]),select,textarea," +
1305                           "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1306                           "[tabindex],[role=link],[role=button],[contenteditable=true]",
1307             {
1308                 setter: function (values) {
1309                     this.matcher = DOM.compileMatcher(values);
1310                     return values;
1311                 },
1312                 validator: DOM.validateMatcher
1313             });
1314
1315         options.add(["hintkeys", "hk"],
1316             "The keys used to label and select hints",
1317             "string", "0123456789",
1318             {
1319                 values: {
1320                     "0123456789": "Numbers",
1321                     "asdfg;lkjh": "Home Row"
1322                 },
1323                 validator: function (value) {
1324                     let values = DOM.Event.parse(value).map(DOM.Event.closure.stringify);
1325                     return Option.validIf(array.uniq(values).length === values.length && values.length > 1,
1326                                           _("option.hintkeys.duplicate"));
1327                 }
1328             });
1329
1330         options.add(["hinttimeout", "hto"],
1331             "Timeout before automatically following a non-unique numerical hint",
1332             "number", 0,
1333             { validator: function (value) value >= 0 });
1334
1335         options.add(["followhints", "fh"],
1336             "Define the conditions under which selected hints are followed",
1337             "number", 0,
1338             {
1339                 values: {
1340                     "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1341                     "1": "Follow the selected hint on <Return>.",
1342                 }
1343             });
1344
1345         options.add(["hintmatching", "hm"],
1346             "How hints are filtered",
1347             "stringlist", "contains",
1348             {
1349                 values: {
1350                     "contains":       "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1351                     "custom":         "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1352                     "firstletters":   "Behaves like wordstartswith, but all groups must match a sequence of words.",
1353                     "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1354                     "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1355                 },
1356                 validator: function (values) Option.validateCompleter.call(this, values) &&
1357                     1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
1358             });
1359
1360         options.add(["wordseparators", "wsp"],
1361             "Regular expression defining which characters separate words when matching hints",
1362             "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1363             { validator: function (value) RegExp(value) });
1364
1365         options.add(["hintinputs", "hin"],
1366             "Which text is used to filter hints for input elements",
1367             "stringlist", "label,value",
1368             {
1369                 values: {
1370                     "value": "Match against the value of the input field",
1371                     "label": "Match against the text of a label for the input field, if one can be found",
1372                     "name":  "Match against the name of the input field"
1373                 }
1374             });
1375     }
1376 });
1377
1378 // vim: set fdm=marker sw=4 ts=4 et: