hints.js: fixed bug in auto-exit timer handling
[conkeror.git] / modules / hints.js
blobcc06f51351c1883186e15380b408e7132c815e8c
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-gui/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     }
331 /* Show panel with currently selected URL below the minibuffer. */
332 function hints_url_panel(hints, window) {
333     var g = new dom_generator(window.document, XUL_NS);
335     var p = g.element("hbox", "class", "panel url", "flex", "0");
336     g.element("label", p, "value", "URL:", "class", "url-panel-label");
337     var url_value = g.element("label", p, "class", "url-panel-value");
338     window.minibuffer.insert_before(p);
340     p.update = function() {
341         url_value.value = "";
342         if (hints.manager && hints.manager.last_selected_hint) {
343             var spec = element_get_load_spec(
344                 hints.manager.last_selected_hint.elem);
345             if (spec) {
346                 var uri = load_spec_uri_string(spec);
347                 if (uri) url_value.value = uri;
348             }
349         }
350     };
352     p.destroy = function() {
353         this.parentNode.removeChild(this);
354     };
356     return p;
359 define_variable("hints_display_url_panel", false, "When selecting a hint, the URL can be displayed in a panel above the minibuffer.  This is useful for confirming that the correct link is selected and that the URL is not evil.  This option is most useful when hints_auto_exit_delay is long or disabled.");
362  * keyword arguments:
364  * $prompt
365  * $callback
366  * $abort_callback
367  */
368 define_keywords("$keymap", "$auto", "$hint_xpath_expression", "$multiple");
369 function hints_minibuffer_state(continuation, buffer)
371     keywords(arguments, $keymap = hint_keymap, $auto);
372     basic_minibuffer_state.call(this, $prompt = arguments.$prompt);
373     if (hints_display_url_panel)
374         this.url_panel = hints_url_panel(this, buffer.window);
375     this.original_prompt = arguments.$prompt;
376     this.continuation = continuation;
377     this.keymap = arguments.$keymap;
378     this.auto_exit = arguments.$auto ? true : false;
379     this.xpath_expr = arguments.$hint_xpath_expression;
380     this.auto_exit_timer_ID = null;
381     this.multiple = arguments.$multiple;
382     this.focused_element = buffer.focused_element;
383     this.focused_frame = buffer.focused_frame;
385 hints_minibuffer_state.prototype = {
386     __proto__: basic_minibuffer_state.prototype,
387     manager : null,
388     typed_string : "",
389     typed_number : "",
390     load : function (window) {
391         if (!this.manager) {
392             var buf = window.buffers.current;
393             this.manager = new hint_manager(buf.top_frame, this.xpath_expr,
394                                             this.focused_frame, this.focused_element);
395         }
396         this.manager.update_valid_hints();
397         this.window = window;
398         if (this.url_panel)
399             this.url_panel.update();
400     },
401     clear_auto_exit_timer : function () {
402         if (this.auto_exit_timer_ID != null) {
403             this.window.clearTimeout(this.auto_exit_timer_ID);
404             this.auto_exit_timer_ID = null;
405         }
406     },
407     unload : function (window) {
408         this.clear_auto_exit_timer();
409         this.manager.hide_hints();
410         delete this.window;
411     },
412     destroy : function () {
413         this.clear_auto_exit_timer();
414         this.manager.remove();
415         if (this.url_panel)
416             this.url_panel.destroy();
417     },
418     update_minibuffer : function (m) {
419         if (this.typed_number.length > 0)
420             m.prompt = this.original_prompt + " #" + this.typed_number;
421         else
422             m.prompt = this.original_prompt;
423         if (this.url_panel)
424             this.url_panel.update();
425     },
427     handle_auto_exit : function (m, ambiguous) {
428         let window = m.window;
429         var num = this.manager.current_hint_number;
430         if (!this.auto_exit)
431             return;
432         let s = this;
433         let delay = ambiguous ? hints_ambiguous_auto_exit_delay : hints_auto_exit_delay;
434         if (delay > 0)
435             this.auto_exit_timer_ID = window.setTimeout(function() { hints_exit(window, s); },
436                                                         delay);
437     },
439     handle_input : function (m) {
440         m._set_selection();
441         this.clear_auto_exit_timer();
442         this.typed_number = "";
443         this.typed_string = m._input_text;
444         this.manager.current_hint_string = this.typed_string;
445         this.manager.current_hint_number = -1;
446         this.manager.update_valid_hints();
447         if (this.manager.valid_hints.length == 1)
448             this.handle_auto_exit(m, false /* unambiguous */);
449         else if (this.manager.valid_hints.length > 1)
450         this.handle_auto_exit(m, true /* ambiguous */);
451         this.update_minibuffer(m);
452     }
455 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.");
457 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.");
459 interactive("hints-handle-number", null, function (I) {
460                 let s = I.minibuffer.check_state(hints_minibuffer_state);
461                 s.clear_auto_exit_timer();
462                 var ch = String.fromCharCode(I.event.charCode);
463                 var auto_exit_ambiguous = null; // null -> no auto exit; false -> not ambiguous; true -> ambiguous
464                 /* TODO: implement number escaping */
465                 // Number entered
466                 s.typed_number += ch;
468                 s.manager.select_hint(parseInt(s.typed_number));
469                 var num = s.manager.current_hint_number;
470                 if (num > 0 && num <= s.manager.valid_hints.length)
471                     auto_exit_ambiguous = num * 10 > s.manager.valid_hints.length ? false : true;
472                 else if (num == 0) {
473                     if (!s.multiple) {
474                         hints_exit(I.window, s);
475                         return;
476                     }
477                     auto_exit_ambiguous = false;
478                 }
479                 if (auto_exit_ambiguous !== null)
480                     s.handle_auto_exit(I.minibuffer, auto_exit_ambiguous);
481                 s.update_minibuffer(I.minibuffer);
482             });
484 function hints_backspace(window, s) {
485     let m = window.minibuffer;
486     s.clear_auto_exit_timer();
487     if (s.typed_number.length > 0) {
488         s.typed_number = s.typed_number.substring(0, s.typed_number.length - 1);
489         var num = s.typed_number.length > 0 ? parseInt(s.typed_number) : 1;
490         s.manager.select_hint(num);
491     } else if (s.typed_string.length > 0) {
492         s.typed_string = s.typed_string.substring(0, s.typed_string.length - 1);
493         m._input_text = s.typed_string;
494         m._set_selection();
495         s.manager.current_hint_string = s.typed_string;
496         s.manager.current_hint_number = -1;
497         s.manager.update_valid_hints();
498     }
499     s.update_minibuffer(m);
501 interactive("hints-backspace", null, function (I) {
502     hints_backspace(I.window, I.minibuffer.check_state(hints_minibuffer_state));
505 function hints_next(window, s, count) {
506     s.clear_auto_exit_timer();
507     s.typed_number = "";
508     var cur = s.manager.current_hint_number - 1;
509     var vh = s.manager.valid_hints;
510     if (vh.length > 0) {
511         cur = (cur + count) % vh.length;
512         if (cur < 0)
513             cur += vh.length;
514         s.manager.select_hint(cur + 1);
515     }
516     s.update_minibuffer(window);
518 interactive("hints-next", null, function (I) {
519     hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), I.p);
522 interactive("hints-previous", null, function (I) {
523     hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), -I.p);
526 function hints_exit(window, s)
528     var cur = s.manager.current_hint_number;
529     var elem = null;
530     if (cur > 0 && cur <= s.manager.valid_hints.length)
531         elem = s.manager.valid_hints[cur - 1].elem;
532     else if (cur == 0)
533         elem = window.buffers.current.top_frame;
534     if (elem !== null) {
535         var c = s.continuation;
536         delete s.continuation;
537         window.minibuffer.pop_state();
538         if (c)
539             c(elem);
540     }
543 interactive("hints-exit", null, function (I) {
544     hints_exit(I.window, I.minibuffer.check_state(hints_minibuffer_state));
548 define_keywords("$buffer");
549 minibuffer.prototype.read_hinted_element = function () {
550     keywords(arguments);
551     var buf = arguments.$buffer;
552     var s = new hints_minibuffer_state((yield CONTINUATION), buf, forward_keywords(arguments));
553     this.push_state(s);
554     var result = yield SUSPEND;
555     yield co_return(result);