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