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