Add generic label mechanism
[conkeror.git] / modules / hints.js
blobd36e080558b33cab2b0dd997341701c711275539
1 /**
2  * (C) Copyright 2007-2008 Jeremy Maitin-Shepard
3  *
4  * Portions of this file are derived from Vimperator,
5  * (C) Copyright 2006-2007 Martin Stubenschrott.
6  *
7  * Use, modification, and distribution are subject to the terms specified in the
8  * COPYING file.
9 **/
11 define_variable("active_img_hint_background_color", "#88FF00", "Color for the active image hint background.");
12 define_variable("img_hint_background_color", "yellow", "Color for inactive image hint backgrounds.");
13 define_variable("active_hint_background_color", "#88FF00", "Color for the active hint background.");
14 define_variable("hint_background_color", "yellow", "Color for the inactive hint.");
16 /**
17  * Register hints style sheet
18  */
19 const hints_stylesheet = "chrome://conkeror/content/hints.css";
20 register_user_stylesheet(hints_stylesheet);
22 /**
23  * buffer is a content_buffer
24  *
25  */
26 function hint_manager(window, xpath_expr, focused_frame, focused_element)
28     this.window = window;
29     this.hints = [];
30     this.valid_hints = [];
31     this.xpath_expr = xpath_expr;
32     this.focused_frame = focused_frame;
33     this.focused_element = focused_element;
34     this.last_selected_hint = null;
36     // Generate
37     this.generate_hints();
40 hint_manager.prototype = {
41     current_hint_string : "",
42     current_hint_number : -1,
44     /**
45      * Create an initially hidden hint span element absolutely
46      * positioned over each element that matches
47      * hint_xpath_expression.  This is done recursively for all frames
48      * and iframes.  Information about the resulting hints are also
49      * stored in the hints array.
50      */
51     generate_hints : function () {
52         var topwin = this.window;
53         var top_height = topwin.innerHeight;
54         var top_width = topwin.innerWidth;
55         var hints = this.hints;
56         var xpath_expr = this.xpath_expr;
57         var focused_frame_hint = null, focused_element_hint = null;
58         var focused_frame = this.focused_frame;
59         var focused_element = this.focused_element;
60         function helper(window, offsetX, offsetY) {
61             var win_height = window.height;
62             var win_width = window.width;
64             // Bounds
65             var minX = offsetX < 0 ? -offsetX : 0;
66             var minY = offsetY < 0 ? -offsetY : 0;
67             var maxX = offsetX + win_width > top_width ? top_width - offsetX : top_width;
68             var maxY = offsetY + win_height > top_height ? top_height - offsetY : top_height;
70             var scrollX = window.scrollX;
71             var scrollY = window.scrollY;
73             var doc = window.document;
74             var res = doc.evaluate(xpath_expr, doc, xpath_lookup_namespace,
75                                    Ci.nsIDOMXPathResult.UNORDERED_NODE_ITERATOR_TYPE,
76                                    null /* existing results */);
78             var base_node = doc.createElementNS(XHTML_NS, "span");
79             base_node.className = "__conkeror_hint";
81             var fragment = doc.createDocumentFragment();
82             var rect, elem, text, node, show_text;
83             while (true)
84             {
85                 try {
86                     elem = res.iterateNext();
87                     if (!elem)
88                         break;
89                 } catch (e) {
90                     break; // Iterator may have been invalidated by page load activity
91                 }
92                 rect = elem.getBoundingClientRect();
93                 if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
94                     continue;
95                 let style = topwin.getComputedStyle(elem, "");
96                 if (style.display == "none" || style.visibility == "hidden")
97                     continue;
98                 rect = elem.getClientRects()[0];
99                 if (!rect)
100                     continue;
101                 show_text = false;
102                 if (elem instanceof Ci.nsIDOMHTMLInputElement || elem instanceof Ci.nsIDOMHTMLTextAreaElement)
103                     text = elem.value;
104                 else if (elem instanceof Ci.nsIDOMHTMLSelectElement) {
105                     if (elem.selectedIndex >= 0)
106                         text = elem.item(elem.selectedIndex).text;
107                     else
108                         text = "";
109                 } else if (elem instanceof Ci.nsIDOMHTMLFrameElement) {
110                     text = elem.name ? elem.name : "";
111                 } else if (/^\s*$/.test(elem.textContent) &&
112                            elem.childNodes.length == 1 &&
113                            elem.childNodes.item(0) instanceof Ci.nsIDOMHTMLImageElement) {
114                     text = elem.childNodes.item(0).alt;
115                     show_text = true;
116                 } else
117                     text = elem.textContent;
118                 text = text.toLowerCase();
120                 node = base_node.cloneNode(true);
121                 node.style.left = (rect.left + scrollX) + "px";
122                 node.style.top = (rect.top + scrollY) + "px";
123                 fragment.appendChild(node);
125                 let hint = {text: text,
126                             elem: elem,
127                             hint: node,
128                             img_hint: null,
129                             visible: false,
130                             show_text: show_text};
131                 if (elem.style) {
132                     hint.saved_color = elem.style.color;
133                     hint.saved_bgcolor = elem.style.backgroundColor;
134                 }
135                 hints.push(hint);
137                 if (elem == focused_element)
138                     focused_element_hint = hint;
139                 else if ((elem instanceof Ci.nsIDOMHTMLFrameElement ||
140                           elem instanceof Ci.nsIDOMHTMLIFrameElement) &&
141                          elem.contentWindow == focused_frame)
142                     focused_frame_hint = hint;
143             }
144             doc.documentElement.appendChild(fragment);
146             /* Recurse into any IFRAME or FRAME elements */
147             var frametag = "frame";
148             while (true) {
149                 var frames = doc.getElementsByTagName(frametag);
150                 for (var i = 0; i < frames.length; ++i)
151                 {
152                     elem = frames[i];
153                     rect = elem.getBoundingClientRect();
154                     if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
155                         continue;
156                     helper(elem.contentWindow, offsetX + rect.left, offsetY + rect.top);
157                 }
158                 if (frametag == "frame") frametag = "iframe"; else break;
159             }
160         }
161         helper(topwin, 0, 0);
162         this.last_selected_hint = focused_element_hint || focused_frame_hint;
163     },
165     /* Updates valid_hints and also re-numbers and re-displays all hints. */
166     update_valid_hints : function () {
167         this.valid_hints = [];
168         var active_number = this.current_hint_number;
170         var tokens = this.current_hint_string.split(" ");
171         var rect, h, text, img_hint, doc, scrollX, scrollY;
172         var hints = this.hints;
174     outer:
175         for (var i = 0; i < hints.length; ++i)
176         {
177             h = hints[i];
178             text = h.text;
179             for (var j = 0; j < tokens.length; ++j)
180             {
181                 if (text.indexOf(tokens[j]) == -1)
182                 {
183                     if (h.visible)
184                     {
185                         h.visible = false;
186                         h.hint.style.display = "none";
187                         if (h.img_hint)
188                             h.img_hint.style.display = "none";
189                         if (h.saved_color != null) {
190                             h.elem.style.backgroundColor = h.saved_bgcolor;
191                             h.elem.style.color = h.saved_color;
192                         }
193                     }
194                     continue outer;
195                 }
196             }
198             var cur_number = this.valid_hints.length + 1;
199             h.visible = true;
201             if (h == this.last_selected_hint && active_number == -1)
202                 this.current_hint_number = active_number = cur_number;
204             var img_elem = null;
206             if (text.length == 0 && h.elem.firstChild &&
207                 h.elem.firstChild instanceof Ci.nsIDOMHTMLImageElement)
208                 img_elem = h.elem.firstChild;
209             else if (h.elem instanceof Ci.nsIDOMHTMLImageElement)
210                 img_elem = h.elem;
212             if (img_elem)
213             {
214                 if (!h.img_hint)
215                 {
216                     rect = img_elem.getBoundingClientRect();
217                     if (rect) {
218                         doc = h.elem.ownerDocument;
219                         scrollX = doc.defaultView.scrollX;
220                         scrollY = doc.defaultView.scrollY;
221                         img_hint = doc.createElementNS(XHTML_NS, "span");
222                         img_hint.className = "__conkeror_img_hint";
223                         img_hint.style.left = (rect.left + scrollX) + "px";
224                         img_hint.style.top = (rect.top + scrollY) + "px";
225                         img_hint.style.width = (rect.right - rect.left) + "px";
226                         img_hint.style.height = (rect.bottom - rect.top) + "px";
227                         h.img_hint = img_hint;
228                         doc.documentElement.appendChild(img_hint);
229                     } else
230                         img_elem = null;
231                 }
232                 if (img_elem) {
233                     var bgcolor = (active_number == cur_number) ?
234                         active_img_hint_background_color : img_hint_background_color;
235                     h.img_hint.style.backgroundColor = bgcolor;
236                     h.img_hint.style.display = "inline";
237                 }
238             }
240             if (!h.img_hint && h.elem.style)
241                 h.elem.style.backgroundColor = (active_number == cur_number) ?
242                     active_hint_background_color : hint_background_color;
244             if (h.elem.style)
245                 h.elem.style.color = "black";
247             var label = "" + cur_number;
248             if (h.elem instanceof Ci.nsIDOMHTMLFrameElement) {
249                 label +=  " " + text;
250             } else if (h.show_text && !/^\s*$/.test(text)) {
251                 let substrs = [[0,4]];
252                 for (j = 0; j < tokens.length; ++j)
253                 {
254                     let pos = text.indexOf(tokens[j]);
255                     if(pos == -1) continue;
256                     splice_range(substrs, pos, pos + tokens[j].length + 2);
257                 }
258                 label += " " + substrs.map(function(x) {
259                     return text.substring(x[0],Math.min(x[1], text.length));
260                 }).join("..") + "..";
261             }
262             h.hint.textContent = label;
263             h.hint.style.display = "inline";
264             this.valid_hints.push(h);
265         }
267         if (active_number == -1)
268             this.select_hint(1);
269     },
271     select_hint : function (index) {
272         var old_index = this.current_hint_number;
273         if (index == old_index)
274             return;
275         var vh = this.valid_hints;
276         if (old_index >= 1 && old_index <= vh.length)
277         {
278             var h = vh[old_index - 1];
279             if (h.img_hint)
280                 h.img_hint.style.backgroundColor = img_hint_background_color;
281             if (h.elem.style)
282                 h.elem.style.backgroundColor = hint_background_color;
283         }
284         this.current_hint_number = index;
285         if (index >= 1 && index <= vh.length)
286         {
287             var h = vh[index - 1];
288             if (h.img_hint)
289                 h.img_hint.style.backgroundColor = active_img_hint_background_color;
290             if (h.elem.style)
291                 h.elem.style.backgroundColor = active_hint_background_color;
292             this.last_selected_hint = h;
293         }
294     },
296     hide_hints : function () {
297         for (var i = 0; i < this.hints.length; ++i)
298         {
299             var h = this.hints[i];
300             if (h.visible) {
301                 h.visible = false;
302                 if (h.saved_color != null)
303                 {
304                     h.elem.style.color = h.saved_color;
305                     h.elem.style.backgroundColor = h.saved_bgcolor;
306                 }
307                 if (h.img_hint)
308                     h.img_hint.style.display = "none";
309                 h.hint.style.display = "none";
310             }
311         }
312     },
314     remove : function () {
315         for (var i = 0; i < this.hints.length; ++i)
316         {
317             var h = this.hints[i];
318             if (h.visible && h.saved_color != null) {
319                 h.elem.style.color = h.saved_color;
320                 h.elem.style.backgroundColor = h.saved_bgcolor;
321             }
322             if (h.img_hint)
323                 h.img_hint.parentNode.removeChild(h.img_hint);
324             h.hint.parentNode.removeChild(h.hint);
325         }
326         this.hints = [];
327         this.valid_hints = [];
328     }
332  * keyword arguments:
334  * $prompt
335  * $callback
336  * $abort_callback
337  */
338 define_keywords("$keymap", "$auto", "$hint_xpath_expression", "$multiple");
339 function hints_minibuffer_state(continuation, buffer)
341     keywords(arguments, $keymap = hint_keymap, $auto);
342     basic_minibuffer_state.call(this, $prompt = arguments.$prompt);
343     this.original_prompt = arguments.$prompt;
344     this.continuation = continuation;
345     this.keymap = arguments.$keymap;
346     this.auto_exit = arguments.$auto ? true : false;
347     this.xpath_expr = arguments.$hint_xpath_expression;
348     this.auto_exit_timer_ID = null;
349     this.multiple = arguments.$multiple;
350     this.focused_element = buffer.focused_element;
351     this.focused_frame = buffer.focused_frame;
353 hints_minibuffer_state.prototype = {
354     __proto__: basic_minibuffer_state.prototype,
355     manager : null,
356     typed_string : "",
357     typed_number : "",
358     load : function (window) {
359         if (!this.manager) {
360             var buf = window.buffers.current;
361             this.manager = new hint_manager(buf.top_frame, this.xpath_expr,
362                                             this.focused_frame, this.focused_element);
363         }
364         this.manager.update_valid_hints();
365     },
366     unload : function (window) {
367         if (this.auto_exit_timer_ID) {
368             window.clearTimeout(this.auto_exit_timer_ID);
369             this.auto_exit_timer_ID = null;
370         }
371         this.manager.hide_hints();
372     },
373     destroy : function () {
374         if (this.auto_exit_timer_ID) {
375             this.manager.window.clearTimeout(this.auto_exit_timer_ID);
376             this.auto_exit_timer_ID = null;
377         }
378         this.manager.remove();
379     },
380     update_minibuffer : function (m) {
381         if (this.typed_number.length > 0)
382             m.prompt = this.original_prompt + " #" + this.typed_number;
383         else
384             m.prompt = this.original_prompt;
385     },
387     handle_auto_exit : function (m, delay) {
388         let window = m.window;
389         var num = this.manager.current_hint_number;
390         if (this.auto_exit_timer_ID)
391             window.clearTimeout(this.auto_exit_timer_ID);
392         let s = this;
393         this.auto_exit_timer_ID = window.setTimeout(function() { hints_exit(window, s); },
394                                                     delay);
395     },
397     handle_input : function (m) {
398         m._set_selection();
400         this.typed_number = "";
401         this.typed_string = m._input_text;
402         this.manager.current_hint_string = this.typed_string;
403         this.manager.current_hint_number = -1;
404         this.manager.update_valid_hints();
405         if (this.auto_exit) {
406             if (this.manager.valid_hints.length == 1)
407                 this.handle_auto_exit(m, hints_auto_exit_delay);
408             else if (this.manager.valid_hints.length > 1
409                      && hints_ambiguous_auto_exit_delay > 0)
410                 this.handle_auto_exit(m, hints_ambiguous_auto_exit_delay);
411         }
412         this.update_minibuffer(m);
413     }
416 define_variable("hints_auto_exit_delay", 500, "Delay (in milliseconds) after the most recent key stroke before a sole matching element is automatically selected.  If this is set to 0, automatic selection is disabled.");
418 define_variable("hints_ambiguous_auto_exit_delay", 0, "Delay (in milliseconds) after the most recent key stroke before the first of an ambiguous match is automatically selected.  If this is set to 0, automatic selection in ambiguous matches is disabled.");
420 interactive("hints-handle-number", null, function (I) {
421     let s = I.minibuffer.check_state(hints_minibuffer_state);
422     var ch = String.fromCharCode(I.event.charCode);
423     var auto_exit = false;
424     /* TODO: implement number escaping */
425     // Number entered
426     s.typed_number += ch;
428     s.manager.select_hint(parseInt(s.typed_number));
429     var num = s.manager.current_hint_number;
430     if (s.auto_exit) {
431         if (num > 0 && num <= s.manager.valid_hints.length) {
432             if (num * 10 > s.manager.valid_hints.length)
433                 auto_exit = hints_auto_exit_delay;
434             else if (hints_ambiguous_auto_exit_delay > 0)
435                 auto_exit = hints_ambiguous_auto_exit_delay;
436         }
437         if (num == 0) {
438             if (!s.multiple) {
439                 hints_exit(I.window, s);
440                 return;
441             }
442             auto_exit = hints_auto_exit_delay;
443         }
444     }
445     if (auto_exit)
446         s.handle_auto_exit(I.minibuffer, auto_exit);
447     s.update_minibuffer(I.minibuffer);
450 function hints_backspace(window, s) {
451     let m = window.minibuffer;
452     if (s.auto_exit_timer_ID) {
453         window.clearTimeout(s.auto_exit_timer_ID);
454         s.auto_exit_timer_ID = null;
455     }
456     if (s.typed_number.length > 0) {
457         s.typed_number = s.typed_number.substring(0, s.typed_number.length - 1);
458         var num = s.typed_number.length > 0 ? parseInt(s.typed_number) : 1;
459         s.manager.select_hint(num);
460     } else if (s.typed_string.length > 0) {
461         s.typed_string = s.typed_string.substring(0, s.typed_string.length - 1);
462         m._input_text = s.typed_string;
463         m._set_selection();
464         s.manager.current_hint_string = s.typed_string;
465         s.manager.current_hint_number = -1;
466         s.manager.update_valid_hints();
467     }
468     s.update_minibuffer(m);
470 interactive("hints-backspace", null, function (I) {
471     hints_backspace(I.window, I.minibuffer.check_state(hints_minibuffer_state));
474 function hints_next(window, s, count) {
475     if (s.auto_exit_timer_ID) {
476         window.clearTimeout(s.auto_exit_timer_ID);
477         s.auto_exit_timer_ID = null;
478     }
479     s.typed_number = "";
480     var cur = s.manager.current_hint_number - 1;
481     var vh = s.manager.valid_hints;
482     if (vh.length > 0) {
483         cur = (cur + count) % vh.length;
484         if (cur < 0)
485             cur += vh.length;
486         s.manager.select_hint(cur + 1);
487     }
488     s.update_minibuffer(window);
490 interactive("hints-next", null, function (I) {
491     hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), I.p);
494 interactive("hints-previous", null, function (I) {
495     hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), -I.p);
498 function hints_exit(window, s)
500     if (s.auto_exit_timer_ID) {
501         window.clearTimeout(s.auto_exit_timer_ID);
502         s.auto_exit_timer_ID = null;
503     }
504     var cur = s.manager.current_hint_number;
505     var elem = null;
506     if (cur > 0 && cur <= s.manager.valid_hints.length)
507         elem = s.manager.valid_hints[cur - 1].elem;
508     else if (cur == 0)
509         elem = window.buffers.current.top_frame;
510     if (elem) {
511         var c = s.continuation;
512         delete s.continuation;
513         window.minibuffer.pop_state();
514         if (c)
515             c(elem);
516     }
519 interactive("hints-exit", null, function (I) {
520     hints_exit(I.window, I.minibuffer.check_state(hints_minibuffer_state));
524 /* FIXME: figure out why this needs to have a bunch of duplication */
525 define_variable(
526     "hints_xpath_expressions",
527     {
528         images: {def: "//img | //xhtml:img"},
529         frames: {def: "//iframe | //frame | //xhtml:iframe | //xhtml:frame"},
530         links: {def:
531                 "//*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @class='lk' or @class='s'] | " +
532                 "//input[not(@type='hidden')] | //a | //area | //iframe | //textarea | //button | //select | " +
533                 "//xhtml:*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @class='lk' or @class='s'] | " +
534                 "//xhtml:input[not(@type='hidden')] | //xhtml:a | //xhtml:area | //xhtml:iframe | //xhtml:textarea | " +
535                 "//xhtml:button | //xhtml:select"},
536         mathml: {def: "//m:math"}
537     },
538     "XPath expressions for each object class.");
540 minibuffer_auto_complete_preferences["media"] = true;
542 define_keywords("$buffer");
543 minibuffer.prototype.read_hinted_element = function () {
544     keywords(arguments);
545     var buf = arguments.$buffer;
546     var s = new hints_minibuffer_state((yield CONTINUATION), buf, forward_keywords(arguments));
547     this.push_state(s);
548     var result = yield SUSPEND;
549     yield co_return(result);