]> git.donarmstrong.com Git - dactyl.git/blob - common/content/commandline.js
f01cbb0b33b9f31a84b8d7af8bfd8ca3c5438037
[dactyl.git] / common / content / commandline.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
11 var CommandWidgets = Class("CommandWidgets", {
12     depends: ["statusline"],
13
14     init: function init() {
15         let s = "dactyl-statusline-field-";
16
17         overlay.overlayWindow(window, {
18             objects: {
19                 eventTarget: commandline
20             },
21             append: [
22                 ["vbox", { id: config.ids.commandContainer, xmlns: "xul" },
23                     ["vbox", { class: "dactyl-container", hidden: "false", collapsed: "true" },
24                         ["iframe", { class: "dactyl-completions", id: "dactyl-completions-dactyl-commandline",
25                                      src: "dactyl://content/buffer.xhtml", contextmenu: "dactyl-contextmenu",
26                                      flex: "1", hidden: "false", collapsed: "false",
27                                      highlight: "Events", events: "mowEvents" }]],
28
29                     ["stack", { orient: "horizontal", align: "stretch", class: "dactyl-container",
30                                 id: "dactyl-container", highlight: "CmdLine CmdCmdLine" },
31                         ["textbox", { class: "plain", id: "dactyl-strut",   flex: "1", crop: "end", collapsed: "true" }],
32                         ["textbox", { class: "plain", id: "dactyl-mode",    flex: "1", crop: "end" }],
33                         ["hbox", { id: "dactyl-message-box" },
34                             ["label", { class: "plain", id: "dactyl-message-pre", flex: "0", readonly: "true", highlight: "WarningMsg" }],
35                             ["textbox", { class: "plain", id: "dactyl-message", flex: "1", readonly: "true" }]],
36
37                         ["hbox", { id: "dactyl-commandline", hidden: "false", class: "dactyl-container", highlight: "Normal CmdNormal", collapsed: "true" },
38                             ["label", {   id: "dactyl-commandline-prompt",  class: "dactyl-commandline-prompt  plain", flex: "0", crop: "end", value: "", collapsed: "true" }],
39                             ["textbox", { id: "dactyl-commandline-command", class: "dactyl-commandline-command plain", flex: "1", type: "input", timeout: "100",
40                                           highlight: "Events" }]]],
41
42                     ["vbox", { class: "dactyl-container", hidden: "false", collapsed: "false", highlight: "CmdLine" },
43                         ["textbox", { id: "dactyl-multiline-input", class: "plain", flex: "1", rows: "1", hidden: "false", collapsed: "true",
44                                       multiline: "true", highlight: "Normal Events", events: "multilineInputEvents" }]]],
45
46                 ["stack", { id: "dactyl-statusline-stack", xmlns: "xul" },
47                     ["hbox", { id: s + "commandline", hidden: "false", class: "dactyl-container", highlight: "Normal StatusNormal", collapsed: "true" },
48                         ["label", { id: s + "commandline-prompt",    class: "dactyl-commandline-prompt  plain", flex: "0", crop: "end", value: "", collapsed: "true" }],
49                         ["textbox", { id: s + "commandline-command", class: "dactyl-commandline-command plain", flex: "1", type: "text", timeout: "100",
50                                       highlight: "Events",  }]]]],
51
52             before: [
53                 ["toolbar", { id: statusline.statusBar.id, xmlns: "xul" },
54                     ["vbox", { id: "dactyl-completions-" + s + "commandline-container", class: "dactyl-container", hidden: "false", collapsed: "true" },
55                         ["iframe", { class: "dactyl-completions", id: "dactyl-completions-" + s + "commandline", src: "dactyl://content/buffer.xhtml",
56                                      contextmenu: "dactyl-contextmenu", flex: "1", hidden: "false", collapsed: "false", highlight: "Events",
57                                      events: "mowEvents" }]]]],
58         });
59
60         this.elements = {};
61
62         this.addElement({
63             name: "container",
64             noValue: true
65         });
66
67         this.addElement({
68             name: "commandline",
69             getGroup: function () options.get("guioptions").has("C") ? this.commandbar : this.statusbar,
70             getValue: function () this.command
71         });
72
73         this.addElement({
74             name: "strut",
75             defaultGroup: "Normal",
76             getGroup: function () this.commandbar,
77             getValue: function () options.get("guioptions").has("c")
78         });
79
80         this.addElement({
81             name: "command",
82             test: function test(stack, prev) stack.pop && !isinstance(prev.main, modes.COMMAND_LINE),
83             id: "commandline-command",
84             get: function command_get(elem) {
85                 // The long path is because of complications with the
86                 // completion preview.
87                 try {
88                     return elem.inputField.editor.rootElement.firstChild.textContent;
89                 }
90                 catch (e) {
91                     return elem.value;
92                 }
93             },
94             getElement: CommandWidgets.getEditor,
95             getGroup: function (value) this.activeGroup.commandline,
96             onChange: function command_onChange(elem, value) {
97                 if (elem.inputField != dactyl.focusedElement)
98                     try {
99                         elem.selectionStart = elem.value.length;
100                         elem.selectionEnd = elem.value.length;
101                     }
102                     catch (e) {}
103
104                 if (!elem.collapsed)
105                     dactyl.focus(elem);
106             },
107             onVisibility: function command_onVisibility(elem, visible) {
108                 if (visible)
109                     dactyl.focus(elem);
110             }
111         });
112
113         this.addElement({
114             name: "prompt",
115             id: "commandline-prompt",
116             defaultGroup: "CmdPrompt",
117             getGroup: function () this.activeGroup.commandline
118         });
119
120         this.addElement({
121             name: "message",
122             defaultGroup: "Normal",
123             getElement: CommandWidgets.getEditor,
124             getGroup: function (value) {
125                 if (this.command && !options.get("guioptions").has("M"))
126                     return this.statusbar;
127
128                 let statusElem = this.statusbar.message;
129                 // Currently doesn't work as expected with <hbox> parent.
130                 if (false && value && !value[2] && statusElem.editor && statusElem.editor.rootElement.scrollWidth > statusElem.scrollWidth)
131                     return this.commandbar;
132                 return this.activeGroup.mode;
133             }
134         });
135
136         this.addElement({
137             name: "message-pre",
138             defaultGroup: "WarningMsg",
139             getGroup: function () this.activeGroup.message
140         });
141
142         this.addElement({
143             name: "message-box",
144             defaultGroup: "Normal",
145             getGroup: function () this.activeGroup.message,
146             getValue: function () this.message
147         });
148
149         this.addElement({
150             name: "mode",
151             defaultGroup: "ModeMsg",
152             getGroup: function (value) {
153                 if (!options.get("guioptions").has("M"))
154                     if (this.commandbar.container.clientHeight == 0 ||
155                             value && !this.commandbar.commandline.collapsed)
156                         return this.statusbar;
157                 return this.commandbar;
158             }
159         });
160         this.updateVisibility();
161
162         this.initialized = true;
163     },
164     addElement: function addElement(obj) {
165         const self = this;
166         this.elements[obj.name] = obj;
167
168         function get(prefix, map, id) (obj.getElement || util.identity)(map[id] || document.getElementById(prefix + id));
169
170         this.active.__defineGetter__(obj.name, function () self.activeGroup[obj.name][obj.name]);
171         this.activeGroup.__defineGetter__(obj.name, function () self.getGroup(obj.name));
172
173         memoize(this.statusbar, obj.name, function () get("dactyl-statusline-field-", statusline.widgets, (obj.id || obj.name)));
174         memoize(this.commandbar, obj.name, function () get("dactyl-", {}, (obj.id || obj.name)));
175
176         if (!(obj.noValue || obj.getValue)) {
177             Object.defineProperty(this, obj.name, Modes.boundProperty({
178                 test: obj.test,
179
180                 get: function get_widgetValue() {
181                     let elem = self.getGroup(obj.name, obj.value)[obj.name];
182                     if (obj.value != null)
183                         return [obj.value[0],
184                                 obj.get ? obj.get.call(this, elem) : elem.value]
185                                 .concat(obj.value.slice(2));
186                     return null;
187                 },
188
189                 set: function set_widgetValue(val) {
190                     if (val != null && !isArray(val))
191                         val = [obj.defaultGroup || "", val];
192                     obj.value = val;
193
194                     [this.commandbar, this.statusbar].forEach(function (nodeSet) {
195                         let elem = nodeSet[obj.name];
196                         if (val == null)
197                             elem.value = "";
198                         else {
199                             highlight.highlightNode(elem,
200                                 (val[0] != null ? val[0] : obj.defaultGroup)
201                                     .split(/\s/).filter(util.identity)
202                                     .map(function (g) g + " " + nodeSet.group + g)
203                                     .join(" "));
204                             elem.value = val[1];
205                             if (obj.onChange)
206                                 obj.onChange.call(this, elem, val);
207                         }
208                     }, this);
209
210                     this.updateVisibility();
211                     return val;
212                 }
213             }).init(obj.name));
214         }
215         else if (obj.defaultGroup) {
216             [this.commandbar, this.statusbar].forEach(function (nodeSet) {
217                 let elem = nodeSet[obj.name];
218                 if (elem)
219                     highlight.highlightNode(elem, obj.defaultGroup.split(/\s/)
220                                                      .map(function (g) g + " " + nodeSet.group + g).join(" "));
221             });
222         }
223     },
224
225     getGroup: function getgroup(name, value) {
226         if (!statusline.visible)
227             return this.commandbar;
228         return this.elements[name].getGroup.call(this, arguments.length > 1 ? value : this[name]);
229     },
230
231     updateVisibility: function updateVisibility() {
232         let changed = 0;
233         for (let elem in values(this.elements))
234             if (elem.getGroup) {
235                 let value = elem.getValue ? elem.getValue.call(this)
236                           : elem.noValue || this[elem.name];
237
238                 let activeGroup = this.getGroup(elem.name, value);
239                 for (let group in values([this.commandbar, this.statusbar])) {
240                     let meth, node = group[elem.name];
241                     let visible = (value && group === activeGroup);
242                     if (node && !node.collapsed == !visible) {
243                         changed++;
244                         node.collapsed = !visible;
245                         if (elem.onVisibility)
246                             elem.onVisibility.call(this, node, visible);
247                     }
248                 }
249             }
250
251         // Hack. Collapse hidden elements in the stack.
252         // Might possibly be better to use a deck and programmatically
253         // choose which element to select.
254         function check(node) {
255             if (DOM(node).style.display === "-moz-stack") {
256                 let nodes = Array.filter(node.children, function (n) !n.collapsed && n.boxObject.height);
257                 nodes.forEach(function (node, i) { node.style.opacity = (i == nodes.length - 1) ? "" : "0" });
258             }
259             Array.forEach(node.children, check);
260         }
261         [this.commandbar.container, this.statusbar.container].forEach(check);
262
263         // Work around a redrawing bug.
264         if (changed && config.haveGecko("16", "20")) {
265             util.delay(function () {
266                 // Urgh.
267                 statusline.statusBar.style.paddingRight = "1px";
268                 DOM(statusline.statusBar).rect; // Force reflow.
269                 statusline.statusBar.style.paddingRight = "";
270             }, 0);
271         }
272
273         if (this.initialized && loaded.mow && mow.visible)
274             mow.resize(false);
275     },
276
277     active: Class.Memoize(Object),
278     activeGroup: Class.Memoize(Object),
279     commandbar: Class.Memoize(function () ({ group: "Cmd" })),
280     statusbar: Class.Memoize(function ()  ({ group: "Status" })),
281
282     _ready: function _ready(elem) {
283         return elem.contentDocument.documentURI === elem.getAttribute("src") &&
284                ["viewable", "complete"].indexOf(elem.contentDocument.readyState) >= 0;
285     },
286
287     _whenReady: function _whenReady(id, init) {
288         let elem = document.getElementById(id);
289         while (!this._ready(elem))
290             yield 10;
291
292         if (init)
293             init.call(this, elem);
294         yield elem;
295     },
296
297     completionContainer: Class.Memoize(function () this.completionList.parentNode),
298
299     contextMenu: Class.Memoize(function () {
300         ["copy", "copylink", "selectall"].forEach(function (tail) {
301             // some host apps use "hostPrefixContext-copy" ids
302             let css   = "menuitem[id$='ontext-" + tail + "']:not([id^=dactyl-])";
303             let style = DOM(css, document).style;
304             DOM("#dactyl-context-" + tail, document).css({
305                 listStyleImage: style.listStyleImage,
306                 MozImageRegion: style.MozImageRegion
307             });
308         });
309         return document.getElementById("dactyl-contextmenu");
310     }),
311
312     multilineOutput: Class.Memoize(function () this._whenReady("dactyl-multiline-output", function (elem) {
313         highlight.highlightNode(elem.contentDocument.body, "MOW");
314     }), true),
315
316     multilineInput: Class.Memoize(function () document.getElementById("dactyl-multiline-input")),
317
318     mowContainer: Class.Memoize(function () document.getElementById("dactyl-multiline-output-container"))
319 }, {
320     getEditor: function getEditor(elem) {
321         elem.inputField.QueryInterface(Ci.nsIDOMNSEditableElement);
322         return elem;
323     }
324 });
325
326 var CommandMode = Class("CommandMode", {
327     init: function CM_init() {
328         this.keepCommand = userContext.hidden_option_command_afterimage;
329     },
330
331     get autocomplete() options["autocomplete"].length,
332
333     get command() this.widgets.command[1],
334     set command(val) this.widgets.command = val,
335
336     get prompt() this._open ? this.widgets.prompt : this._prompt,
337     set prompt(val) {
338         if (this._open)
339             this.widgets.prompt = val;
340         else
341             this._prompt = val;
342     },
343
344     open: function CM_open(command) {
345         dactyl.assert(isinstance(this.mode, modes.COMMAND_LINE),
346                       /*L*/"Not opening command line in non-command-line mode.",
347                       false);
348
349         this.messageCount = commandline.messageCount;
350         modes.push(this.mode, this.extendedMode, this.closure);
351
352         this.widgets.active.commandline.collapsed = false;
353         this.widgets.prompt = this.prompt;
354         this.widgets.command = command || "";
355
356         this._open = true;
357
358         this.input = this.widgets.active.command.inputField;
359         if (this.historyKey)
360             this.history = CommandLine.History(this.input, this.historyKey, this);
361
362         if (this.complete)
363             this.completions = CommandLine.Completions(this.input, this);
364
365         if (this.completions && command && commandline.commandSession === this)
366             this.completions.autocompleteTimer.flush(true);
367     },
368
369     get active() this === commandline.commandSession,
370
371     get holdFocus() this.widgets.active.command.inputField,
372
373     get mappingSelf() this,
374
375     get widgets() commandline.widgets,
376
377     enter: function CM_enter(stack) {
378         commandline.commandSession = this;
379         if (stack.pop && commandline.command) {
380             this.onChange(commandline.command);
381             if (this.completions && stack.pop)
382                 this.completions.complete(true, false);
383         }
384     },
385
386     leave: function CM_leave(stack) {
387         if (!stack.push) {
388             commandline.commandSession = null;
389             this.input.dactylKeyPress = undefined;
390
391             let waiting = this.accepted && this.completions && this.completions.waiting;
392             if (waiting)
393                 this.completions.onComplete = bind("onSubmit", this);
394
395             if (this.completions)
396                 this.completions.cleanup();
397
398             if (this.history)
399                 this.history.save();
400
401             commandline.hideCompletions();
402
403             modes.delay(function () {
404                 if (!this.keepCommand || commandline.silent || commandline.quiet)
405                     commandline.hide();
406                 if (!waiting)
407                     this[this.accepted ? "onSubmit" : "onCancel"](commandline.command);
408                 if (commandline.messageCount === this.messageCount)
409                     commandline.clearMessage();
410             }, this);
411         }
412     },
413
414     events: {
415         input: function CM_onInput(event) {
416             if (this.completions) {
417                 this.resetCompletions();
418
419                 this.completions.autocompleteTimer.tell(false);
420             }
421             this.onChange(commandline.command);
422         },
423         keyup: function CM_onKeyUp(event) {
424             let key = DOM.Event.stringify(event);
425             if (/-?Tab>$/.test(key) && this.completions)
426                 this.completions.tabTimer.flush();
427         }
428     },
429
430     keepCommand: false,
431
432     onKeyPress: function CM_onKeyPress(events) {
433         if (this.completions)
434             this.completions.previewClear();
435
436         return true; /* Pass event */
437     },
438
439     onCancel: function (value) {},
440
441     onChange: function (value) {},
442
443     onHistory: function (value) {},
444
445     onSubmit: function (value) {},
446
447     resetCompletions: function CM_resetCompletions() {
448         if (this.completions)
449             this.completions.clear();
450         if (this.history)
451             this.history.reset();
452     },
453 });
454
455 var CommandExMode = Class("CommandExMode", CommandMode, {
456
457     get mode() modes.EX,
458
459     historyKey: "command",
460
461     prompt: ["Normal", ":"],
462
463     complete: function CEM_complete(context) {
464         try {
465             context.fork("ex", 0, completion, "ex");
466         }
467         catch (e) {
468             context.message = _("error.error", e);
469         }
470     },
471
472     onSubmit: function CEM_onSubmit(command) {
473         contexts.withContext({ file: /*L*/"[Command Line]", line: 1 },
474                              function _onSubmit() {
475             io.withSavedValues(["readHeredoc"], function _onSubmit() {
476                 this.readHeredoc = commandline.readHeredoc;
477                 commands.repeat = command;
478                 dactyl.execute(command);
479             });
480         });
481     }
482 });
483
484 var CommandPromptMode = Class("CommandPromptMode", CommandMode, {
485     init: function init(prompt, params) {
486         this.prompt = isArray(prompt) ? prompt : ["Question", prompt];
487         update(this, params);
488         init.supercall(this);
489     },
490
491     complete: function CPM_complete(context) {
492         if (this.completer)
493             context.forkapply("prompt", 0, this, "completer", Array.slice(arguments, 1));
494     },
495
496     get mode() modes.PROMPT
497 });
498
499 /**
500  * This class is used for prompting of user input and echoing of messages.
501  *
502  * It consists of a prompt and command field be sure to only create objects of
503  * this class when the chrome is ready.
504  */
505 var CommandLine = Module("commandline", {
506     init: function init() {
507         const self = this;
508
509         this._callbacks = {};
510
511         memoize(this, "_store", function () storage.newMap("command-history", { store: true, privateData: true }));
512
513         for (let name in values(["command", "search"]))
514             if (storage.exists("history-" + name)) {
515                 let ary = storage.newArray("history-" + name, { store: true, privateData: true });
516
517                 this._store.set(name, [v for ([k, v] in ary)]);
518                 ary.delete();
519                 this._store.changed();
520             }
521
522         this._messageHistory = { //{{{
523             _messages: [],
524             get messages() {
525                 let max = options["messages"];
526
527                 // resize if 'messages' has changed
528                 if (this._messages.length > max)
529                     this._messages = this._messages.splice(this._messages.length - max);
530
531                 return this._messages;
532             },
533
534             get length() this._messages.length,
535
536             clear: function clear() {
537                 this._messages = [];
538             },
539
540             filter: function filter(fn, self) {
541                 this._messages = this._messages.filter(fn, self);
542             },
543
544             add: function add(message) {
545                 if (!message)
546                     return;
547
548                 if (this._messages.length >= options["messages"])
549                     this._messages.shift();
550
551                 this._messages.push(update({
552                     timestamp: Date.now()
553                 }, message));
554             }
555         }; //}}}
556     },
557
558     signals: {
559         "browser.locationChange": function (webProgress, request, uri) {
560             this.clear();
561         }
562     },
563
564     /**
565      * Determines whether the command line should be visible.
566      *
567      * @returns {boolean}
568      */
569     get commandVisible() !!this.commandSession,
570
571     /**
572      * Ensure that the multiline input widget is the correct size.
573      */
574     _autosizeMultilineInputWidget: function _autosizeMultilineInputWidget() {
575         let lines = this.widgets.multilineInput.value.split("\n").length - 1;
576
577         this.widgets.multilineInput.setAttribute("rows", Math.max(lines, 1));
578     },
579
580     HL_NORMAL:     "Normal",
581     HL_ERRORMSG:   "ErrorMsg",
582     HL_MODEMSG:    "ModeMsg",
583     HL_MOREMSG:    "MoreMsg",
584     HL_QUESTION:   "Question",
585     HL_INFOMSG:    "InfoMsg",
586     HL_WARNINGMSG: "WarningMsg",
587     HL_LINENR:     "LineNr",
588
589     FORCE_MULTILINE    : 1 << 0,
590     FORCE_SINGLELINE   : 1 << 1,
591     DISALLOW_MULTILINE : 1 << 2, // If an echo() should try to use the single line
592                                  // but output nothing when the MOW is open; when also
593                                  // FORCE_MULTILINE is given, FORCE_MULTILINE takes precedence
594     APPEND_TO_MESSAGES : 1 << 3, // Add the string to the message history.
595     ACTIVE_WINDOW      : 1 << 4, // Only echo in active window.
596
597     get completionContext() this._completions.context,
598
599     _silent: false,
600     get silent() this._silent,
601     set silent(val) {
602         this._silent = val;
603         this.quiet = this.quiet;
604     },
605
606     _quite: false,
607     get quiet() this._quiet,
608     set quiet(val) {
609         this._quiet = val;
610         ["commandbar", "statusbar"].forEach(function (nodeSet) {
611             Array.forEach(this.widgets[nodeSet].commandline.children, function (node) {
612                 node.style.opacity = this._quiet || this._silent ? "0" : "";
613             }, this);
614         }, this);
615     },
616
617     widgets: Class.Memoize(function () CommandWidgets()),
618
619     runSilently: function runSilently(func, self) {
620         this.withSavedValues(["silent"], function () {
621             this.silent = true;
622             func.call(self);
623         });
624     },
625
626     get completionList() {
627         let node = this.widgets.active.commandline;
628         if (this.commandSession && this.commandSession.completionList)
629             node = document.getElementById(this.commandSession.completionList);
630
631         if (!node.completionList) {
632             let elem = document.getElementById("dactyl-completions-" + node.id);
633             util.waitFor(bind(this.widgets._ready, null, elem));
634
635             node.completionList = ItemList(elem);
636             node.completionList.isAboveMow = node.id ==
637                 this.widgets.statusbar.commandline.id
638         }
639         return node.completionList;
640     },
641
642     hideCompletions: function hideCompletions() {
643         for (let nodeSet in values([this.widgets.statusbar, this.widgets.commandbar]))
644             if (nodeSet.commandline.completionList)
645                 nodeSet.commandline.completionList.visible = false;
646     },
647
648     _lastClearable: Modes.boundProperty(),
649     messages: Modes.boundProperty(),
650
651     multilineInputVisible: Modes.boundProperty({
652         set: function set_miwVisible(value) { this.widgets.multilineInput.collapsed = !value; }
653     }),
654
655     get command() {
656         if (this.commandVisible && this.widgets.command)
657             return commands.lastCommand = this.widgets.command[1];
658         return commands.lastCommand;
659     },
660     set command(val) {
661         if (this.commandVisible && (modes.extended & modes.EX))
662             return this.widgets.command = val;
663         return commands.lastCommand = val;
664     },
665
666     clear: function clear(scroll) {
667         if (!scroll || Date.now() - this._lastEchoTime > 5000)
668             this.clearMessage();
669         this._lastEchoTime = 0;
670         this.hiddenMessages = 0;
671
672         if (!this.commandSession) {
673             this.widgets.command = null;
674             this.hideCompletions();
675         }
676
677         if (modes.main == modes.OUTPUT_MULTILINE && !mow.isScrollable(1))
678             modes.pop();
679
680         if (!modes.have(modes.OUTPUT_MULTILINE))
681             mow.visible = false;
682     },
683
684     clearMessage: function clearMessage() {
685         if (this.widgets.message && this.widgets.message[1] === this._lastClearable) {
686             this.widgets.message = null;
687             this.hiddenMessages = 0;
688         }
689     },
690
691     /**
692      * Displays the multi-line output of a command, preceded by the last
693      * executed ex command string.
694      *
695      * @param {XML} xml The output as an E4X XML object.
696      */
697     commandOutput: function commandOutput(xml) {
698         if (!this.command)
699             this.echo(xml, this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
700         else
701             this.echo([["div", { xmlns: "html" }, ":" + this.command], "\n", xml],
702                       this.HIGHLIGHT_NORMAL, this.FORCE_MULTILINE);
703         this.command = null;
704     },
705
706     /**
707      * Hides the command line, and shows any status messages that
708      * are under it.
709      */
710     hide: function hide() {
711         this.widgets.command = null;
712     },
713
714     /**
715      * Display a message in the command-line area.
716      *
717      * @param {string} str
718      * @param {string} highlightGroup
719      * @param {boolean} forceSingle If provided, don't let over-long
720      *     messages move to the MOW.
721      */
722     _echoLine: function echoLine(str, highlightGroup, forceSingle, silent) {
723         this.widgets.message = str ? [highlightGroup, str, forceSingle] : null;
724
725         dactyl.triggerObserver("echoLine", str, highlightGroup, null, forceSingle);
726
727         if (!this.commandVisible)
728             this.hide();
729
730         let field = this.widgets.active.message.inputField;
731         if (field.value && !forceSingle && field.editor.rootElement.scrollWidth > field.scrollWidth) {
732             this.widgets.message = null;
733             mow.echo(["span", { highlight: "Message" }, str], highlightGroup, true);
734         }
735     },
736
737     _hiddenMessages: 0,
738     get hiddenMessages() this._hiddenMessages,
739     set hiddenMessages(val) {
740         this._hiddenMessages = val;
741         if (val)
742             this.widgets["message-pre"] = _("commandline.moreMessages", val) + " ";
743         else
744             this.widgets["message-pre"] = null
745     },
746
747     _lastEcho: null,
748
749     /**
750      * Output the given string onto the command line. With no flags, the
751      * message will be shown in the status line if it's short enough to
752      * fit, and contains no new lines, and isn't XML. Otherwise, it will be
753      * shown in the MOW.
754      *
755      * @param {string} str
756      * @param {string} highlightGroup The Highlight group for the
757      *     message.
758      * @default "Normal"
759      * @param {number} flags Changes the behavior as follows:
760      *   commandline.APPEND_TO_MESSAGES - Causes message to be added to the
761      *          messages history, and shown by :messages.
762      *   commandline.FORCE_SINGLELINE   - Forbids the command from being
763      *          pushed to the MOW if it's too long or of there are already
764      *          status messages being shown.
765      *   commandline.DISALLOW_MULTILINE - Cancels the operation if the MOW
766      *          is already visible.
767      *   commandline.FORCE_MULTILINE    - Forces the message to appear in
768      *          the MOW.
769      */
770     messageCount: 0,
771     echo: function echo(data, highlightGroup, flags) {
772         // dactyl.echo uses different order of flags as it omits the highlight group, change commandline.echo argument order? --mst
773         if (this._silent || !this.widgets)
774             return;
775
776         this.messageCount++;
777
778         highlightGroup = highlightGroup || this.HL_NORMAL;
779
780         let self = this;
781         function appendToMessages(data) {
782             let message = isObject(data) && !DOM.isJSONXML(data) ? data : { message: data };
783
784             // Make sure the memoized message property is an instance property.
785             message.message;
786             self._messageHistory.add(update({ highlight: highlightGroup }, message));
787             return message.message;
788         }
789
790         if (flags & this.APPEND_TO_MESSAGES)
791             data = appendToMessages(data);
792
793         if ((flags & this.ACTIVE_WINDOW) && window != overlay.activeWindow)
794             return;
795
796         if ((flags & this.DISALLOW_MULTILINE) && !this.widgets.mowContainer.collapsed)
797             return;
798
799         let forceSingle = flags & (this.FORCE_SINGLELINE | this.DISALLOW_MULTILINE);
800         let action = this._echoLine;
801
802         if ((flags & this.FORCE_MULTILINE) || (/\n/.test(data) || !isinstance(data, [_, "String"])) && !(flags & this.FORCE_SINGLELINE))
803             action = mow.closure.echo;
804
805         let single = function () action == self._echoLine;
806
807         if (forceSingle) {
808             this._lastEcho = null;
809             this.hiddenMessages = 0;
810         }
811         else {
812             // So complicated...
813             if (single() && !this.widgets.mowContainer.collapsed) {
814                 highlightGroup += " Message";
815                 action = mow.closure.echo;
816             }
817             else if (!single() && this.widgets.mowContainer.collapsed) {
818                 if (this._lastEcho && this.widgets.message && this.widgets.message[1] == this._lastEcho.msg) {
819                     if (!(this._lastEcho.flags & this.APPEND_TO_MESSAGES))
820                         appendToMessages(this._lastEcho.data);
821
822                     mow.echo(
823                         ["span", { highlight: "Message" },
824                             ["span", { highlight: "WarningMsg" },
825                                 _("commandline.moreMessages", this.hiddenMessages + 1) + " "],
826                             this._lastEcho.msg],
827                         this.widgets.message[0], true);
828
829                     this.hiddenMessages = 0;
830                 }
831             }
832             else if (this._lastEcho && this.widgets.message && this.widgets.message[1] == this._lastEcho.msg) {
833                 if (!(this._lastEcho.flags & this.APPEND_TO_MESSAGES))
834                     appendToMessages(this._lastEcho.data);
835                 if (single() && !(flags & this.APPEND_TO_MESSAGES))
836                     appendToMessages(data);
837
838                 flags |= this.APPEND_TO_MESSAGES;
839                 this.hiddenMessages++;
840             }
841             this._lastEcho = single() && { flags: flags, msg: data, data: arguments[0] };
842         }
843
844         this._lastClearable = action === this._echoLine && String(data);
845         this._lastEchoTime = (flags & this.FORCE_SINGLELINE) && Date.now();
846
847         if (action)
848             action.call(this, data, highlightGroup, single);
849     },
850     _lastEchoTime: 0,
851
852     /**
853      * Prompt the user. Sets modes.main to COMMAND_LINE, which the user may
854      * pop at any time to close the prompt.
855      *
856      * @param {string} prompt The input prompt to use.
857      * @param {function(string)} callback
858      * @param {Object} extra
859      * @... {function} onChange - A function to be called with the current
860      *     input every time it changes.
861      * @... {function(CompletionContext)} completer - A completion function
862      *     for the user's input.
863      * @... {string} promptHighlight - The HighlightGroup used for the
864      *     prompt. @default "Question"
865      * @... {string} default - The initial value that will be returned
866      *     if the user presses <CR> straightaway. @default ""
867      */
868     input: function _input(prompt, callback, extra) {
869         extra = extra || {};
870
871         CommandPromptMode(prompt, update({ onSubmit: callback }, extra)).open();
872     },
873
874     readHeredoc: function readHeredoc(end) {
875         let args;
876         commandline.inputMultiline(end, function (res) { args = res; });
877         util.waitFor(function () args !== undefined);
878         return args;
879     },
880
881     /**
882      * Get a multi-line input from a user, up to but not including the line
883      * which matches the given regular expression. Then execute the
884      * callback with that string as a parameter.
885      *
886      * @param {string} end
887      * @param {function(string)} callback
888      */
889     // FIXME: Buggy, especially when pasting.
890     inputMultiline: function inputMultiline(end, callback) {
891         let cmd = this.command;
892         let self = {
893             end: "\n" + end + "\n",
894             callback: callback
895         };
896
897         modes.push(modes.INPUT_MULTILINE, null, {
898             holdFocus: true,
899             leave: function leave() {
900                 if (!self.done)
901                     self.callback(null);
902             },
903             mappingSelf: self
904         });
905
906         if (cmd != false)
907             this._echoLine(cmd, this.HL_NORMAL);
908
909         // save the arguments, they are needed in the event handler onKeyPress
910
911         this.multilineInputVisible = true;
912         this.widgets.multilineInput.value = "";
913         this._autosizeMultilineInputWidget();
914
915         this.timeout(function () { dactyl.focus(this.widgets.multilineInput); }, 10);
916     },
917
918     get commandMode() this.commandSession && isinstance(modes.main, modes.COMMAND_LINE),
919
920     events: update(
921         iter(CommandMode.prototype.events).map(
922             function ([event, handler]) [
923                 event, function (event) {
924                     if (this.commandMode)
925                         handler.call(this.commandSession, event);
926                 }
927             ]).toObject(),
928         {
929             focus: function onFocus(event) {
930                 if (!this.commandSession
931                         && event.originalTarget === this.widgets.active.command.inputField) {
932                     event.target.blur();
933                     dactyl.beep();
934                 }
935             }
936         }
937     ),
938
939     get mowEvents() mow.events,
940
941     /**
942      * Multiline input events, they will come straight from
943      * #dactyl-multiline-input in the XUL.
944      *
945      * @param {Event} event
946      */
947     multilineInputEvents: {
948         blur: function onBlur(event) {
949             if (modes.main == modes.INPUT_MULTILINE)
950                 this.timeout(function () {
951                     dactyl.focus(this.widgets.multilineInput.inputField);
952                 });
953         },
954         input: function onInput(event) {
955             this._autosizeMultilineInputWidget();
956         }
957     },
958
959     updateOutputHeight: deprecated("mow.resize", function updateOutputHeight(open, extra) mow.resize(open, extra)),
960
961     withOutputToString: function withOutputToString(fn, self) {
962         dactyl.registerObserver("echoLine", observe, true);
963         dactyl.registerObserver("echoMultiline", observe, true);
964
965         let output = [];
966         function observe(str, highlight, dom) {
967             output.push(dom && !isString(str) ? dom : str);
968         }
969
970         this.savingOutput = true;
971         dactyl.trapErrors.apply(dactyl, [fn, self].concat(Array.slice(arguments, 2)));
972         this.savingOutput = false;
973         return output.map(function (elem) elem instanceof Node ? DOM.stringify(elem) : elem)
974                      .join("\n");
975     }
976 }, {
977     /**
978      * A class for managing the history of an input field.
979      *
980      * @param {HTMLInputElement} inputField
981      * @param {string} mode The mode for which we need history.
982      */
983     History: Class("History", {
984         init: function init(inputField, mode, session) {
985             this.mode = mode;
986             this.input = inputField;
987             this.reset();
988             this.session = session;
989         },
990         get store() commandline._store.get(this.mode, []),
991         set store(ary) { commandline._store.set(this.mode, ary); },
992         /**
993          * Reset the history index to the first entry.
994          */
995         reset: function reset() {
996             this.index = null;
997         },
998         /**
999          * Save the last entry to the permanent store. All duplicate entries
1000          * are removed and the list is truncated, if necessary.
1001          */
1002         save: function save() {
1003             if (events.feedingKeys)
1004                 return;
1005
1006             let str = this.input.value;
1007             if (/^\s*$/.test(str))
1008                 return;
1009
1010             let privateData = this.checkPrivate(str);
1011             if (privateData == "never-save")
1012                 return;
1013
1014             this.store = this.store.filter(function (line) (line.value || line) != str);
1015             dactyl.trapErrors(function () {
1016                 this.store.push({ value: str, timestamp: Date.now()*1000, privateData: privateData });
1017             }, this);
1018             this.store = this.store.slice(Math.max(0, this.store.length - options["history"]));
1019         },
1020         /**
1021          * @property {function} Returns whether a data item should be
1022          * considered private.
1023          */
1024         checkPrivate: function checkPrivate(str) {
1025             // Not really the ideal place for this check.
1026             if (this.mode == "command")
1027                 return commands.hasPrivateData(str);
1028             return false;
1029         },
1030         /**
1031          * Replace the current input field value.
1032          *
1033          * @param {string} val The new value.
1034          */
1035         replace: function replace(val) {
1036             editor.withSavedValues(["skipSave"], function () {
1037                 editor.skipSave = true;
1038
1039                 this.input.dactylKeyPress = undefined;
1040                 if (this.completions)
1041                     this.completions.previewClear();
1042                 this.input.value = val;
1043                 this.session.onHistory(val);
1044             }, this);
1045         },
1046
1047         /**
1048          * Move forward or backward in history.
1049          *
1050          * @param {boolean} backward Direction to move.
1051          * @param {boolean} matchCurrent Search for matches starting
1052          *      with the current input value.
1053          */
1054         select: function select(backward, matchCurrent) {
1055             // always reset the tab completion if we use up/down keys
1056             if (this.session.completions)
1057                 this.session.completions.reset();
1058
1059             let diff = backward ? -1 : 1;
1060
1061             if (this.index == null) {
1062                 this.original = this.input.value;
1063                 this.index = this.store.length;
1064             }
1065
1066             // search the history for the first item matching the current
1067             // command-line string
1068             while (true) {
1069                 this.index += diff;
1070                 if (this.index < 0 || this.index > this.store.length) {
1071                     this.index = Math.constrain(this.index, 0, this.store.length);
1072                     dactyl.beep();
1073                     // I don't know why this kludge is needed. It
1074                     // prevents the caret from moving to the end of
1075                     // the input field.
1076                     if (this.input.value == "") {
1077                         this.input.value = " ";
1078                         this.input.value = "";
1079                     }
1080                     break;
1081                 }
1082
1083                 let hist = this.store[this.index];
1084                 // user pressed DOWN when there is no newer history item
1085                 if (!hist)
1086                     hist = this.original;
1087                 else
1088                     hist = (hist.value || hist);
1089
1090                 if (!matchCurrent || hist.substr(0, this.original.length) == this.original) {
1091                     this.replace(hist);
1092                     break;
1093                 }
1094             }
1095         }
1096     }),
1097
1098     /**
1099      * A class for tab completions on an input field.
1100      *
1101      * @param {Object} input
1102      */
1103     Completions: Class("Completions", {
1104         UP: {},
1105         DOWN: {},
1106         CTXT_UP: {},
1107         CTXT_DOWN: {},
1108         PAGE_UP: {},
1109         PAGE_DOWN: {},
1110         RESET: null,
1111
1112         init: function init(input, session) {
1113             let self = this;
1114
1115             this.context = CompletionContext(input.QueryInterface(Ci.nsIDOMNSEditableElement).editor);
1116             this.context.onUpdate = function onUpdate() { self.asyncUpdate(this); };
1117
1118             this.editor = input.editor;
1119             this.input = input;
1120             this.session = session;
1121
1122             this.wildmode = options.get("wildmode");
1123             this.wildtypes = this.wildmode.value;
1124
1125             this.itemList = commandline.completionList;
1126             this.itemList.open(this.context);
1127
1128             dactyl.registerObserver("events.doneFeeding", this.closure.onDoneFeeding, true);
1129
1130             this.autocompleteTimer = Timer(200, 500, function autocompleteTell(tabPressed) {
1131                 if (events.feedingKeys && !tabPressed)
1132                     this.ignoredCount++;
1133                 else if (this.session.autocomplete) {
1134                     this.itemList.visible = true;
1135                     this.complete(true, false);
1136                 }
1137             }, this);
1138
1139             this.tabTimer = Timer(0, 0, function tabTell(event) {
1140                 let tabCount = this.tabCount;
1141                 this.tabCount = 0;
1142                 this.tab(tabCount, event.altKey && options["altwildmode"]);
1143             }, this);
1144         },
1145
1146         tabCount: 0,
1147
1148         ignoredCount: 0,
1149
1150         /**
1151          * @private
1152          */
1153         onDoneFeeding: function onDoneFeeding() {
1154             if (this.ignoredCount)
1155                 this.autocompleteTimer.flush(true);
1156             this.ignoredCount = 0;
1157         },
1158
1159         /**
1160          * @private
1161          */
1162         onTab: function onTab(event) {
1163             this.tabCount += event.shiftKey ? -1 : 1;
1164             this.tabTimer.tell(event);
1165         },
1166
1167         get activeContexts() this.context.contextList
1168                                  .filter(function (c) c.items.length || c.incomplete),
1169
1170         /**
1171          * Returns the current completion string relative to the
1172          * offset of the currently selected context.
1173          */
1174         get completion() {
1175             let offset = this.selected ? this.selected[0].offset : this.start;
1176             return commandline.command.slice(offset, this.caret);
1177         },
1178
1179         /**
1180          * Updates the input field from *offset* to {@link #caret}
1181          * with the value *value*. Afterward, the caret is moved
1182          * just after the end of the updated text.
1183          *
1184          * @param {number} offset The offset in the original input
1185          *      string at which to insert *value*.
1186          * @param {string} value The value to insert.
1187          */
1188         setCompletion: function setCompletion(offset, value) {
1189             editor.withSavedValues(["skipSave"], function () {
1190                 editor.skipSave = true;
1191                 this.previewClear();
1192
1193                 if (value == null)
1194                     var [input, caret] = [this.originalValue, this.originalCaret];
1195                 else {
1196                     input = this.getCompletion(offset, value);
1197                     caret = offset + value.length;
1198                 }
1199
1200                 // Change the completion text.
1201                 // The second line is a hack to deal with some substring
1202                 // preview corner cases.
1203                 commandline.widgets.active.command.value = input;
1204                 this.editor.selection.focusNode.textContent = input;
1205
1206                 this.caret = caret;
1207                 this._caret = this.caret;
1208
1209                 this.input.dactylKeyPress = undefined;
1210             }, this);
1211         },
1212
1213         /**
1214          * For a given offset and completion string, returns the
1215          * full input value after selecting that item.
1216          *
1217          * @param {number} offset The offset at which to insert the
1218          *      completion.
1219          * @param {string} value The value to insert.
1220          * @returns {string};
1221          */
1222         getCompletion: function getCompletion(offset, value) {
1223             return this.originalValue.substr(0, offset)
1224                  + value
1225                  + this.originalValue.substr(this.originalCaret);
1226         },
1227
1228         get selected() this.itemList.selected,
1229         set selected(tuple) {
1230             if (!array.equals(tuple || [],
1231                               this.itemList.selected || []))
1232                 this.itemList.select(tuple);
1233
1234             if (!tuple)
1235                 this.setCompletion(null);
1236             else {
1237                 let [ctxt, idx] = tuple;
1238                 this.setCompletion(ctxt.offset, ctxt.items[idx].result);
1239             }
1240         },
1241
1242         get caret() this.editor.selection.getRangeAt(0).startOffset,
1243         set caret(offset) {
1244             this.editor.selection.collapse(this.editor.rootElement.firstChild, offset);
1245         },
1246
1247         get start() this.context.allItems.start,
1248
1249         get items() this.context.allItems.items,
1250
1251         get substring() this.context.longestAllSubstring,
1252
1253         get wildtype() this.wildtypes[this.wildIndex] || "",
1254
1255         /**
1256          * Cleanup resources used by this completion session. This
1257          * instance should not be used again once this method is
1258          * called.
1259          */
1260         cleanup: function cleanup() {
1261             dactyl.unregisterObserver("events.doneFeeding", this.closure.onDoneFeeding);
1262             this.previewClear();
1263
1264             this.tabTimer.reset();
1265             this.autocompleteTimer.reset();
1266             if (!this.onComplete)
1267                 this.context.cancelAll();
1268
1269             this.itemList.visible = false;
1270             this.input.dactylKeyPress = undefined;
1271             this.hasQuit = true;
1272         },
1273
1274         /**
1275          * Run the completer.
1276          *
1277          * @param {boolean} show Passed to {@link #reset}.
1278          * @param {boolean} tabPressed Should be set to true if, and
1279          *      only if, this function is being called in response
1280          *      to a <Tab> press.
1281          */
1282         complete: function complete(show, tabPressed) {
1283             this.session.ignoredCount = 0;
1284
1285             this.waiting = null;
1286             this.context.reset();
1287             this.context.tabPressed = tabPressed;
1288
1289             this.session.complete(this.context);
1290             if (!this.session.active)
1291                 return;
1292
1293             this.reset(show, tabPressed);
1294             this.wildIndex = 0;
1295             this._caret = this.caret;
1296         },
1297
1298         /**
1299          * Clear any preview string and cancel any pending
1300          * asynchronous context. Called when there is further input
1301          * to be processed.
1302          */
1303         clear: function clear() {
1304             this.context.cancelAll();
1305             this.wildIndex = -1;
1306             this.previewClear();
1307         },
1308
1309         /**
1310          * Saves the current input state. To be called before an
1311          * item is selected in a new set of completion responses.
1312          * @private
1313          */
1314         saveInput: function saveInput() {
1315             this.originalValue = this.context.value;
1316             this.originalCaret = this.caret;
1317         },
1318
1319         /**
1320          * Resets the completion state.
1321          *
1322          * @param {boolean} show If true and options allow the
1323          *      completion list to be shown, show it.
1324          */
1325         reset: function reset(show) {
1326             this.waiting = null;
1327             this.wildIndex = -1;
1328
1329             this.saveInput();
1330
1331             if (show) {
1332                 this.itemList.update();
1333                 this.context.updateAsync = true;
1334                 if (this.haveType("list"))
1335                     this.itemList.visible = true;
1336                 this.wildIndex = 0;
1337             }
1338
1339             this.preview();
1340         },
1341
1342         /**
1343          * Calls when an asynchronous completion context has new
1344          * results to return.
1345          *
1346          * @param {CompletionContext} context The changed context.
1347          * @private
1348          */
1349         asyncUpdate: function asyncUpdate(context) {
1350             if (this.hasQuit) {
1351                 let item = this.getItem(this.waiting);
1352                 if (item && this.waiting && this.onComplete) {
1353                     util.trapErrors("onComplete", this,
1354                                     this.getCompletion(this.waiting[0].offset,
1355                                                        item.result));
1356                     this.waiting = null;
1357                     this.context.cancelAll();
1358                 }
1359                 return;
1360             }
1361
1362             let value = this.editor.selection.focusNode.textContent;
1363             this.saveInput();
1364
1365             if (this.itemList.visible)
1366                 this.itemList.updateContext(context);
1367
1368             if (this.waiting && this.waiting[0] == context)
1369                 this.select(this.waiting);
1370             else if (!this.waiting) {
1371                 let cursor = this.selected;
1372                 if (cursor && cursor[0] == context) {
1373                     let item = this.getItem(cursor);
1374                     if (!item || this.completion != item.result)
1375                         this.itemList.select(null);
1376                 }
1377
1378                 this.preview();
1379             }
1380         },
1381
1382         /**
1383          * Returns true if the currently selected 'wildmode' index
1384          * has the given completion type.
1385          */
1386         haveType: function haveType(type)
1387             this.wildmode.checkHas(this.wildtype, type == "first" ? "" : type),
1388
1389         /**
1390          * Returns the completion item for the given selection
1391          * tuple.
1392          *
1393          * @param {[CompletionContext,number]} tuple The spec of the
1394          *      item to return.
1395          *      @default {@link #selected}
1396          * @returns {object}
1397          */
1398         getItem: function getItem(tuple) {
1399             tuple = tuple || this.selected;
1400             return tuple && tuple[0] && tuple[0].items[tuple[1]];
1401         },
1402
1403         /**
1404          * Returns a tuple representing the next item, at the given
1405          * *offset*, from *tuple*.
1406          *
1407          * @param {[CompletionContext,number]} tuple The offset from
1408          *      which to search.
1409          *      @default {@link #selected}
1410          * @param {number} offset The positive or negative offset to
1411          *      find.
1412          *      @default 1
1413          * @param {boolean} noWrap If true, and the search would
1414          *      wrap, return null.
1415          */
1416         nextItem: function nextItem(tuple, offset, noWrap) {
1417             if (tuple === undefined)
1418                 tuple = this.selected;
1419
1420             return this.itemList.getRelativeItem(offset || 1, tuple, noWrap);
1421         },
1422
1423         /**
1424          * The last previewed substring.
1425          * @private
1426          */
1427         lastSubstring: "",
1428
1429         /**
1430          * Displays a preview of the text provided by the next <Tab>
1431          * press if the current input is an anchored substring of
1432          * that result.
1433          */
1434         preview: function preview() {
1435             this.previewClear();
1436             if (this.wildIndex < 0 || this.caret < this.input.value.length
1437                     || !this.activeContexts.length || this.waiting)
1438                 return;
1439
1440             let substring = "";
1441             switch (this.wildtype.replace(/.*:/, "")) {
1442             case "":
1443                 var cursor = this.nextItem(null);
1444                 break;
1445             case "longest":
1446                 if (this.items.length > 1) {
1447                     substring = this.substring;
1448                     break;
1449                 }
1450                 // Fallthrough
1451             case "full":
1452                 cursor = this.nextItem();
1453                 break;
1454             }
1455             if (cursor)
1456                 substring = this.getItem(cursor).result;
1457
1458             // Don't show 1-character substrings unless we've just hit backspace
1459             if (substring.length < 2 && this.lastSubstring.indexOf(substring))
1460                 return;
1461
1462             this.lastSubstring = substring;
1463
1464             let value = this.completion;
1465             if (util.compareIgnoreCase(value, substring.substr(0, value.length)))
1466                 return;
1467
1468             substring = substring.substr(value.length);
1469             this.removeSubstring = substring;
1470
1471             let node = DOM.fromJSON(["span", { highlight: "Preview" }, substring],
1472                                     document);
1473
1474             this.withSavedValues(["caret"], function () {
1475                 this.editor.insertNode(node, this.editor.rootElement, 1);
1476             });
1477         },
1478
1479         /**
1480          * Clears the currently displayed next-<Tab> preview string.
1481          */
1482         previewClear: function previewClear() {
1483             let node = this.editor.rootElement.firstChild;
1484             if (node && node.nextSibling) {
1485                 try {
1486                     DOM(node.nextSibling).remove();
1487                 }
1488                 catch (e) {
1489                     node.nextSibling.textContent = "";
1490                 }
1491             }
1492             else if (this.removeSubstring) {
1493                 let str = this.removeSubstring;
1494                 let cmd = commandline.widgets.active.command.value;
1495                 if (cmd.substr(cmd.length - str.length) == str)
1496                     commandline.widgets.active.command.value = cmd.substr(0, cmd.length - str.length);
1497             }
1498             delete this.removeSubstring;
1499         },
1500
1501         /**
1502          * Selects a completion based on the value of *idx*.
1503          *
1504          * @param {[CompletionContext,number]|const object} The
1505          *      (context,index) tuple of the item to select, or an
1506          *      offset constant from this object.
1507          * @param {number} count When given an offset constant,
1508          *      select *count* units.
1509          *      @default 1
1510          * @param {boolean} fromTab If true, this function was
1511          *      called by {@link #tab}.
1512          *      @private
1513          */
1514         select: function select(idx, count, fromTab) {
1515             count = count || 1;
1516
1517             switch (idx) {
1518             case this.UP:
1519             case this.DOWN:
1520                 idx = this.nextItem(this.waiting || this.selected,
1521                                     idx == this.UP ? -count : count,
1522                                     true);
1523                 break;
1524
1525             case this.CTXT_UP:
1526             case this.CTXT_DOWN:
1527                 let groups = this.itemList.activeGroups;
1528                 let i = Math.max(0, groups.indexOf(this.itemList.selectedGroup));
1529
1530                 i += idx == this.CTXT_DOWN ? 1 : -1;
1531                 i %= groups.length;
1532                 if (i < 0)
1533                     i += groups.length;
1534
1535                 var position = 0;
1536                 idx = [groups[i].context, 0];
1537                 break;
1538
1539             case this.PAGE_UP:
1540             case this.PAGE_DOWN:
1541                 idx = this.itemList.getRelativePage(idx == this.PAGE_DOWN ? 1 : -1);
1542                 break;
1543
1544             case this.RESET:
1545                 idx = null;
1546                 break;
1547
1548             default:
1549                 break;
1550             }
1551
1552             if (!fromTab)
1553                 this.wildIndex = this.wildtypes.length - 1;
1554
1555             if (idx && idx[1] >= idx[0].items.length) {
1556                 if (!idx[0].incomplete)
1557                     this.waiting = null;
1558                 else {
1559                     this.waiting = idx;
1560                     statusline.progress = _("completion.waitingForResults");
1561                 }
1562                 return;
1563             }
1564
1565             this.waiting = null;
1566
1567             this.itemList.select(idx, null, position);
1568             this.selected = idx;
1569
1570             this.preview();
1571
1572             if (this.selected == null)
1573                 statusline.progress = "";
1574             else
1575                 statusline.progress = _("completion.matchIndex",
1576                                         this.itemList.getOffset(idx),
1577                                         this.itemList.itemCount);
1578         },
1579
1580         /**
1581          * Selects a completion result based on the 'wildmode'
1582          * option, or the value of the *wildmode* parameter.
1583          *
1584          * @param {number} offset The positive or negative number of
1585          *      tab presses to process.
1586          * @param {[string]} wildmode A 'wildmode' value to
1587          *      substitute for the value of the 'wildmode' option.
1588          *      @optional
1589          */
1590         tab: function tab(offset, wildmode) {
1591             this.autocompleteTimer.flush();
1592             this.ignoredCount = 0;
1593
1594             if (this._caret != this.caret)
1595                 this.reset();
1596             this._caret = this.caret;
1597
1598             // Check if we need to run the completer.
1599             if (this.context.waitingForTab || this.wildIndex == -1)
1600                 this.complete(true, true);
1601
1602             this.wildtypes = wildmode || options["wildmode"];
1603             let count = Math.abs(offset);
1604             let steps = Math.constrain(this.wildtypes.length - this.wildIndex,
1605                                        1, count);
1606             count = Math.max(1, count - steps);
1607
1608             while (steps--) {
1609                 this.wildIndex = Math.min(this.wildIndex, this.wildtypes.length - 1);
1610                 switch (this.wildtype.replace(/.*:/, "")) {
1611                 case "":
1612                     this.select(this.nextItem(null));
1613                     break;
1614                 case "longest":
1615                     if (this.itemList.itemCount > 1) {
1616                         if (this.substring && this.substring.length > this.completion.length)
1617                             this.setCompletion(this.start, this.substring);
1618                         break;
1619                     }
1620                     // Fallthrough
1621                 case "full":
1622                     let c = steps ? 1 : count;
1623                     this.select(offset < 0 ? this.UP : this.DOWN, c, true);
1624                     break;
1625                 }
1626
1627                 if (this.haveType("list"))
1628                     this.itemList.visible = true;
1629
1630                 this.wildIndex++;
1631             }
1632
1633             if (this.items.length == 0 && !this.waiting)
1634                 dactyl.beep();
1635         }
1636     }),
1637
1638     /**
1639      * Evaluate a JavaScript expression and return a string suitable
1640      * to be echoed.
1641      *
1642      * @param {string} arg
1643      * @param {boolean} useColor When true, the result is a
1644      *     highlighted XML object.
1645      */
1646     echoArgumentToString: function (arg, useColor) {
1647         if (!arg)
1648             return "";
1649
1650         arg = dactyl.userEval(arg);
1651         if (isObject(arg))
1652             arg = util.objectToString(arg, useColor);
1653         else if (callable(arg))
1654             arg = String.replace(arg, "/* use strict */ \n", "/* use strict */ ");
1655         else if (!isString(arg) && useColor)
1656             arg = template.highlight(arg);
1657         return arg;
1658     }
1659 }, {
1660     commands: function initCommands() {
1661         [
1662             {
1663                 name: "ec[ho]",
1664                 description: "Echo the expression",
1665                 action: dactyl.echo
1666             },
1667             {
1668                 name: "echoe[rr]",
1669                 description: "Echo the expression as an error message",
1670                 action: dactyl.echoerr
1671             },
1672             {
1673                 name: "echom[sg]",
1674                 description: "Echo the expression as an informational message",
1675                 action: dactyl.echomsg
1676             }
1677         ].forEach(function (command) {
1678             commands.add([command.name],
1679                 command.description,
1680                 function (args) {
1681                     command.action(CommandLine.echoArgumentToString(args[0] || "", true));
1682                 }, {
1683                     completer: function (context) completion.javascript(context),
1684                     literal: 0
1685                 });
1686         });
1687
1688         commands.add(["mes[sages]"],
1689             "Display previously shown messages",
1690             function () {
1691                 // TODO: are all messages single line? Some display an aggregation
1692                 //       of single line messages at least. E.g. :source
1693                 if (commandline._messageHistory.length == 1) {
1694                     let message = commandline._messageHistory.messages[0];
1695                     commandline.echo(message.message, message.highlight, commandline.FORCE_SINGLELINE);
1696                 }
1697                 else if (commandline._messageHistory.length > 1) {
1698                     commandline.commandOutput(
1699                         template.map(commandline._messageHistory.messages, function (message)
1700                            ["div", { highlight: message.highlight + " Message" },
1701                                message.message]));
1702                 }
1703             },
1704             { argCount: "0" });
1705
1706         commands.add(["messc[lear]"],
1707             "Clear the message history",
1708             function () { commandline._messageHistory.clear(); },
1709             { argCount: "0" });
1710
1711         commands.add(["sil[ent]"],
1712             "Run a command silently",
1713             function (args) {
1714                 commandline.runSilently(function () commands.execute(args[0] || "", null, true));
1715             }, {
1716                 completer: function (context) completion.ex(context),
1717                 literal: 0,
1718                 subCommand: 0
1719             });
1720     },
1721     modes: function initModes() {
1722         initModes.require("editor");
1723
1724         modes.addMode("COMMAND_LINE", {
1725             char: "c",
1726             description: "Active when the command line is focused",
1727             insert: true,
1728             ownsFocus: true,
1729             get mappingSelf() commandline.commandSession
1730         });
1731         // this._extended modes, can include multiple modes, and even main modes
1732         modes.addMode("EX", {
1733             description: "Ex command mode, active when the command line is open for Ex commands",
1734             bases: [modes.COMMAND_LINE]
1735         });
1736         modes.addMode("PROMPT", {
1737             description: "Active when a prompt is open in the command line",
1738             bases: [modes.COMMAND_LINE]
1739         });
1740
1741         modes.addMode("INPUT_MULTILINE", {
1742             description: "Active when the command line's multiline input buffer is open",
1743             bases: [modes.INSERT]
1744         });
1745     },
1746     mappings: function initMappings() {
1747
1748         mappings.add([modes.COMMAND],
1749             [":"], "Enter Command Line mode",
1750             function () { CommandExMode().open(""); });
1751
1752         mappings.add([modes.INPUT_MULTILINE],
1753             ["<Return>", "<C-j>", "<C-m>"], "Begin a new line",
1754             function ({ self }) {
1755                 let text = "\n" + commandline.widgets.multilineInput
1756                                              .value.substr(0, commandline.widgets.multilineInput.selectionStart)
1757                          + "\n";
1758
1759                 let index = text.indexOf(self.end);
1760                 if (index >= 0) {
1761                     self.done = true;
1762                     text = text.substring(1, index);
1763                     modes.pop();
1764
1765                     return function () self.callback.call(commandline, text);
1766                 }
1767                 return Events.PASS;
1768             });
1769
1770         let bind = function bind()
1771             mappings.add.apply(mappings, [[modes.COMMAND_LINE]].concat(Array.slice(arguments)))
1772
1773         bind(["<Esc>", "<C-[>"], "Stop waiting for completions or exit Command Line mode",
1774              function ({ self }) {
1775                  if (self.completions && self.completions.waiting)
1776                      self.completions.waiting = null;
1777                  else
1778                      return Events.PASS;
1779              });
1780
1781         // Any "non-keyword" character triggers abbreviation expansion
1782         // TODO: Add "<CR>" and "<Tab>" to this list
1783         //       At the moment, adding "<Tab>" breaks tab completion. Adding
1784         //       "<CR>" has no effect.
1785         // TODO: Make non-keyword recognition smarter so that there need not
1786         //       be two lists of the same characters (one here and a regexp in
1787         //       mappings.js)
1788         bind(["<Space>", '"', "'"], "Expand command line abbreviation",
1789              function ({ self }) {
1790                  self.resetCompletions();
1791                  editor.expandAbbreviation(modes.COMMAND_LINE);
1792                  return Events.PASS;
1793              });
1794
1795         bind(["<Return>", "<C-j>", "<C-m>"], "Accept the current input",
1796              function ({ self }) {
1797                  if (self.completions)
1798                      self.completions.tabTimer.flush();
1799
1800                  let command = commandline.command;
1801
1802                  self.accepted = true;
1803                  return function () { modes.pop(); };
1804              });
1805
1806         [
1807             [["<Up>", "<A-p>", "<cmd-prev-match>"],   "previous matching", true,  true],
1808             [["<S-Up>", "<C-p>", "<cmd-prev>"],       "previous",          true,  false],
1809             [["<Down>", "<A-n>", "<cmd-next-match>"], "next matching",     false, true],
1810             [["<S-Down>", "<C-n>", "<cmd-next>"],     "next",              false, false]
1811         ].forEach(function ([keys, desc, up, search]) {
1812             bind(keys, "Recall the " + desc + " command line from the history list",
1813                  function ({ self }) {
1814                      dactyl.assert(self.history);
1815                      self.history.select(up, search);
1816                  });
1817         });
1818
1819         bind(["<A-Tab>", "<Tab>", "<A-compl-next>", "<compl-next>"],
1820              "Select the next matching completion item",
1821              function ({ keypressEvents, self }) {
1822                  dactyl.assert(self.completions);
1823                  self.completions.onTab(keypressEvents[0]);
1824              });
1825
1826         bind(["<A-S-Tab>", "<S-Tab>", "<A-compl-prev>", "<compl-prev>"],
1827              "Select the previous matching completion item",
1828              function ({ keypressEvents, self }) {
1829                  dactyl.assert(self.completions);
1830                  self.completions.onTab(keypressEvents[0]);
1831              });
1832
1833         bind(["<C-Tab>", "<A-f>", "<compl-next-group>"],
1834              "Select the next matching completion group",
1835              function ({ keypressEvents, self }) {
1836                  dactyl.assert(self.completions);
1837                  self.completions.tabTimer.flush();
1838                  self.completions.select(self.completions.CTXT_DOWN);
1839              });
1840
1841         bind(["<C-S-Tab>", "<A-S-f>", "<compl-prev-group>"],
1842              "Select the previous matching completion group",
1843              function ({ keypressEvents, self }) {
1844                  dactyl.assert(self.completions);
1845                  self.completions.tabTimer.flush();
1846                  self.completions.select(self.completions.CTXT_UP);
1847              });
1848
1849         bind(["<C-f>", "<PageDown>", "<compl-next-page>"],
1850              "Select the next page of completions",
1851              function ({ keypressEvents, self }) {
1852                  dactyl.assert(self.completions);
1853                  self.completions.tabTimer.flush();
1854                  self.completions.select(self.completions.PAGE_DOWN);
1855              });
1856
1857         bind(["<C-b>", "<PageUp>", "<compl-prev-page>"],
1858              "Select the previous page of completions",
1859              function ({ keypressEvents, self }) {
1860                  dactyl.assert(self.completions);
1861                  self.completions.tabTimer.flush();
1862                  self.completions.select(self.completions.PAGE_UP);
1863              });
1864
1865         bind(["<BS>", "<C-h>"], "Delete the previous character",
1866              function () {
1867                  if (!commandline.command)
1868                      modes.pop();
1869                  else
1870                      return Events.PASS;
1871              });
1872
1873         bind(["<C-]>", "<C-5>"], "Expand command line abbreviation",
1874              function () { editor.expandAbbreviation(modes.COMMAND_LINE); });
1875     },
1876     options: function initOptions() {
1877         options.add(["history", "hi"],
1878             "Number of Ex commands and search patterns to store in the command-line history",
1879             "number", 500,
1880             { validator: function (value) value >= 0 });
1881
1882         options.add(["maxitems"],
1883             "Maximum number of completion items to display at once",
1884             "number", 20,
1885             { validator: function (value) value >= 1 });
1886
1887         options.add(["messages", "msgs"],
1888             "Number of messages to store in the :messages history",
1889             "number", 100,
1890             { validator: function (value) value >= 0 });
1891     },
1892     sanitizer: function initSanitizer() {
1893         sanitizer.addItem("commandline", {
1894             description: "Command-line and search history",
1895             persistent: true,
1896             action: function (timespan, host) {
1897                 let store = commandline._store;
1898                 for (let [k, v] in store) {
1899                     if (k == "command")
1900                         store.set(k, v.filter(function (item)
1901                             !(timespan.contains(item.timestamp) && (!host || commands.hasDomain(item.value, host)))));
1902                     else if (!host)
1903                         store.set(k, v.filter(function (item) !timespan.contains(item.timestamp)));
1904                 }
1905             }
1906         });
1907         // Delete history-like items from the commandline and messages on history purge
1908         sanitizer.addItem("history", {
1909             action: function (timespan, host) {
1910                 commandline._store.set("command",
1911                     commandline._store.get("command", []).filter(function (item)
1912                         !(timespan.contains(item.timestamp) && (host ? commands.hasDomain(item.value, host)
1913                                                                      : item.privateData))));
1914
1915                 commandline._messageHistory.filter(function (item) !timespan.contains(item.timestamp * 1000) ||
1916                     !item.domains && !item.privateData ||
1917                     host && (!item.domains || !item.domains.some(function (d) util.isSubdomain(d, host))));
1918             }
1919         });
1920         sanitizer.addItem("messages", {
1921             description: "Saved :messages",
1922             action: function (timespan, host) {
1923                 commandline._messageHistory.filter(function (item) !timespan.contains(item.timestamp * 1000) ||
1924                     host && (!item.domains || !item.domains.some(function (d) util.isSubdomain(d, host))));
1925             }
1926         });
1927     }
1928 });
1929
1930 /**
1931  * The list which is used for the completion box.
1932  *
1933  * @param {string} id The id of the iframe which will display the list. It
1934  *     must be in its own container element, whose height it will update as
1935  *     necessary.
1936  */
1937
1938 var ItemList = Class("ItemList", {
1939     CONTEXT_LINES: 2,
1940
1941     init: function init(frame) {
1942         this.frame = frame;
1943
1944         this.doc = frame.contentDocument;
1945         this.win = frame.contentWindow;
1946         this.body = this.doc.body;
1947         this.container = frame.parentNode;
1948
1949         highlight.highlightNode(this.doc.body, "Comp");
1950
1951         this._onResize = Timer(20, 400, function _onResize(event) {
1952             if (this.visible)
1953                 this.onResize(event);
1954         }, this);
1955         this._resize = Timer(20, 400, function _resize(flags) {
1956             if (this.visible)
1957                 this.resize(flags);
1958         }, this);
1959
1960         DOM(this.win).resize(this._onResize.closure.tell);
1961     },
1962
1963     get rootXML()
1964         ["div", { highlight: "Normal", style: "white-space: nowrap", key: "root" },
1965             ["div", { key: "wrapper" },
1966                 ["div", { highlight: "Completions", key: "noCompletions" },
1967                     ["span", { highlight: "Title" },
1968                         _("completion.noCompletions")]],
1969                 ["div", { key: "completions" }]],
1970
1971             ["div", { highlight: "Completions" },
1972                 template.map(util.range(0, options["maxitems"] * 2), function (i)
1973                     ["div", { highlight: "CompItem NonText" },
1974                         "~"])]],
1975
1976     get itemCount() this.context.contextList
1977                         .reduce(function (acc, ctxt) acc + ctxt.items.length, 0),
1978
1979     get visible() !this.container.collapsed,
1980     set visible(val) this.container.collapsed = !val,
1981
1982     get activeGroups() this.context.contextList
1983                            .filter(function (c) c.items.length || c.message || c.incomplete)
1984                            .map(this.getGroup, this),
1985
1986     get selected() let (g = this.selectedGroup) g && g.selectedIdx != null
1987         ? [g.context, g.selectedIdx] : null,
1988
1989     getRelativeItem: function getRelativeItem(offset, tuple, noWrap) {
1990         let groups = this.activeGroups;
1991         if (!groups.length)
1992             return null;
1993
1994         let group = this.selectedGroup || groups[0];
1995         let start = group.selectedIdx || 0;
1996         if (tuple === null) { // Kludge.
1997             if (offset > 0)
1998                 tuple = [this.activeGroups[0], -1];
1999             else {
2000                 let group = this.activeGroups.slice(-1)[0];
2001                 tuple = [group, group.itemCount];
2002             }
2003         }
2004         if (tuple)
2005             [group, start] = tuple;
2006
2007         group = this.getGroup(group);
2008
2009         start = (group.offsets.start + start + offset);
2010         if (!noWrap)
2011             start %= this.itemCount || 1;
2012         if (start < 0 && (!noWrap || arguments[1] === null))
2013             start += this.itemCount;
2014
2015         if (noWrap && offset > 0) {
2016             // Check if we've passed any incomplete contexts
2017
2018             let i = groups.indexOf(group);
2019             util.assert(i >= 0, undefined, false);
2020             for (; i < groups.length; i++) {
2021                 let end = groups[i].offsets.start + groups[i].itemCount;
2022                 if (start >= end && groups[i].context.incomplete)
2023                     return [groups[i].context, start - groups[i].offsets.start];
2024
2025                 if (start >= end);
2026                     break;
2027             }
2028         }
2029
2030         if (start < 0 || start >= this.itemCount)
2031             return null;
2032
2033         group = array.nth(groups, function (g) let (i = start - g.offsets.start) i >= 0 && i < g.itemCount, 0)
2034         return [group.context, start - group.offsets.start];
2035     },
2036
2037     getRelativePage: function getRelativePage(offset, tuple, noWrap) {
2038         offset *= this.maxItems;
2039         // Try once with wrapping disabled.
2040         let res = this.getRelativeItem(offset, tuple, true);
2041
2042         if (!res) {
2043             // Wrapped.
2044             let sign = offset / Math.abs(offset);
2045
2046             let off = this.getOffset(tuple === null ? null : tuple || this.selected);
2047             if (off == null)
2048                 // Unselected. Defer to getRelativeItem.
2049                 res = this.getRelativeItem(offset, null, noWrap);
2050             else if (~[0, this.itemCount - 1].indexOf(off))
2051                 // At start or end. Jump to other end.
2052                 res = this.getRelativeItem(sign, null, noWrap);
2053             else
2054                 // Wrapped. Go to beginning or end.
2055                 res = this.getRelativeItem(-sign, null);
2056         }
2057         return res;
2058     },
2059
2060     /**
2061      * Initializes the ItemList for use with a new root completion
2062      * context.
2063      *
2064      * @param {CompletionContext} context The new root context.
2065      */
2066     open: function open(context) {
2067         this.context = context;
2068         this.nodes = {};
2069         this.container.height = 0;
2070         this.minHeight = 0;
2071         this.maxItems  = options["maxitems"];
2072
2073         DOM(this.rootXML, this.doc, this.nodes)
2074             .appendTo(DOM(this.body).empty());
2075
2076         this.update();
2077     },
2078
2079     /**
2080      * Updates the absolute result indices of all groups after
2081      * results have changed.
2082      * @private
2083      */
2084     updateOffsets: function updateOffsets() {
2085         let total = this.itemCount;
2086         let count = 0;
2087         for (let group in values(this.activeGroups)) {
2088             group.offsets = { start: count, end: total - count - group.itemCount };
2089             count += group.itemCount;
2090         }
2091     },
2092
2093     /**
2094      * Updates the set and state of active groups for a new set of
2095      * completion results.
2096      */
2097     update: function update() {
2098         DOM(this.nodes.completions).empty();
2099
2100         let container = DOM(this.nodes.completions);
2101         let groups = this.activeGroups;
2102         for (let group in values(groups)) {
2103             group.reset();
2104             container.append(group.nodes.root);
2105         }
2106
2107         this.updateOffsets();
2108
2109         DOM(this.nodes.noCompletions).toggle(!groups.length);
2110
2111         this.startPos = null;
2112         this.select(groups[0] && groups[0].context, null);
2113
2114         this._resize.tell();
2115     },
2116
2117     /**
2118      * Updates the group for *context* after an asynchronous update
2119      * push.
2120      *
2121      * @param {CompletionContext} context The context which has
2122      *      changed.
2123      */
2124     updateContext: function updateContext(context) {
2125         let group = this.getGroup(context);
2126         this.updateOffsets();
2127
2128         if (~this.activeGroups.indexOf(group))
2129             group.update();
2130         else {
2131             DOM(group.nodes.root).remove();
2132             if (this.selectedGroup == group)
2133                 this.selectedGroup = null;
2134         }
2135
2136         let g = this.selectedGroup;
2137         this.select(g, g && g.selectedIdx);
2138     },
2139
2140     /**
2141      * Updates the DOM to reflect the current state of all groups.
2142      * @private
2143      */
2144     draw: function draw() {
2145         for (let group in values(this.activeGroups))
2146             group.draw();
2147
2148         // We need to collect all of the rescrolling functions in
2149         // one go, as the height calculation that they need to do
2150         // would force a reflow after each DOM modification.
2151         this.activeGroups.filter(function (g) !g.collapsed)
2152             .map(function (g) g.rescrollFunc)
2153             .forEach(call);
2154
2155         if (!this.selected)
2156             this.win.scrollTo(0, 0);
2157
2158         this._resize.tell(ItemList.RESIZE_BRIEF);
2159     },
2160
2161     onResize: function onResize() {
2162         if (this.selectedGroup)
2163             this.selectedGroup.rescrollFunc();
2164     },
2165
2166     minHeight: 0,
2167
2168     /**
2169      * Resizes the list after an update.
2170      * @private
2171      */
2172     resize: function resize(flags) {
2173         let { completions, root } = this.nodes;
2174
2175         if (!this.visible)
2176             root.style.minWidth = document.getElementById("dactyl-commandline").scrollWidth + "px";
2177
2178         let { minHeight } = this;
2179         if (mow.visible && this.isAboveMow) // Kludge.
2180             minHeight -= mow.wantedHeight;
2181
2182         let needed = this.win.scrollY + DOM(completions).rect.bottom;
2183         this.minHeight = Math.max(minHeight, needed);
2184
2185         if (!this.visible)
2186             root.style.minWidth = "";
2187
2188         let height = this.visible ? parseFloat(this.container.height) : 0;
2189         if (this.minHeight <= minHeight || !mow.visible)
2190             this.container.height = Math.min(this.minHeight,
2191                                              height + config.outputHeight - mow.spaceNeeded);
2192         else {
2193             // FIXME: Belongs elsewhere.
2194             mow.resize(false, Math.max(0, this.minHeight - this.container.height));
2195
2196             this.container.height = this.minHeight - mow.spaceNeeded;
2197             mow.resize(false);
2198             this.timeout(function () {
2199                 this.container.height -= mow.spaceNeeded;
2200             });
2201         }
2202     },
2203
2204     /**
2205      * Selects the item at the given *group* and *index*.
2206      *
2207      * @param {CompletionContext|[CompletionContext,number]} *group* The
2208      *      completion context to select, or a tuple specifying the
2209      *      context and item index.
2210      * @param {number} index The item index in *group* to select.
2211      * @param {number} position If non-null, try to position the
2212      *      selected item at the *position*th row from the top of
2213      *      the screen. Note that at least {@link #CONTEXT_LINES}
2214      *      lines will be visible above and below the selected item
2215      *      unless there aren't enough results to make this possible.
2216      *      @optional
2217      */
2218     select: function select(group, index, position) {
2219         if (isArray(group))
2220             [group, index] = group;
2221
2222         group = this.getGroup(group);
2223
2224         if (this.selectedGroup && (!group || group != this.selectedGroup))
2225             this.selectedGroup.selectedIdx = null;
2226
2227         this.selectedGroup = group;
2228
2229         if (group)
2230             group.selectedIdx = index;
2231
2232         let groups = this.activeGroups;
2233
2234         if (position != null || !this.startPos && groups.length)
2235             this.startPos = [group || groups[0], position || 0];
2236
2237         if (groups.length) {
2238             group = group || groups[0];
2239             let idx = groups.indexOf(group);
2240
2241             let start  = this.startPos[0].getOffset(this.startPos[1]);
2242             if (group) {
2243                 let idx = group.selectedIdx || 0;
2244                 let off = group.getOffset(idx);
2245
2246                 start = Math.constrain(start,
2247                                        off + Math.min(this.CONTEXT_LINES,
2248                                                       group.itemCount - idx + group.offsets.end)
2249                                            - this.maxItems + 1,
2250                                        off - Math.min(this.CONTEXT_LINES,
2251                                                       idx + group.offsets.start));
2252             }
2253
2254             let count = this.maxItems;
2255             for (let group in values(groups)) {
2256                 let off = Math.max(0, start - group.offsets.start);
2257
2258                 group.count = Math.constrain(group.itemCount - off, 0, count);
2259                 count -= group.count;
2260
2261                 group.collapsed = group.offsets.start >= start + this.maxItems
2262                                || group.offsets.start + group.itemCount < start;
2263
2264                 group.range = ItemList.Range(off, off + group.count);
2265
2266                 if (!startPos)
2267                     var startPos = [group, group.range.start];
2268             }
2269             this.startPos = startPos;
2270         }
2271         this.draw();
2272     },
2273
2274     /**
2275      * Returns an ItemList group for the given completion context,
2276      * creating one if necessary.
2277      *
2278      * @param {CompletionContext} context
2279      * @returns {ItemList.Group}
2280      */
2281     getGroup: function getGroup(context)
2282         context instanceof ItemList.Group ? context
2283                                           : context && context.getCache("itemlist-group",
2284                                                                         bind("Group", ItemList, this, context)),
2285
2286     getOffset: function getOffset(tuple) tuple && this.getGroup(tuple[0]).getOffset(tuple[1])
2287 }, {
2288     RESIZE_BRIEF: 1 << 0,
2289
2290     WAITING_MESSAGE: _("completion.generating"),
2291
2292     Group: Class("ItemList.Group", {
2293         init: function init(parent, context) {
2294             this.parent  = parent;
2295             this.context = context;
2296             this.offsets = {};
2297             this.range   = ItemList.Range(0, 0);
2298         },
2299
2300         get rootXML()
2301             ["div", { key: "root", highlight: "CompGroup" },
2302                 ["div", { highlight: "Completions" },
2303                     this.context.createRow(this.context.title || [], "CompTitle")],
2304                 ["div", { highlight: "CompTitleSep" }],
2305                 ["div", { key: "contents" },
2306                     ["div", { key: "up", highlight: "CompLess" }],
2307                     ["div", { key: "message", highlight: "CompMsg" },
2308                         this.context.message || []],
2309                     ["div", { key: "itemsContainer", class: "completion-items-container" },
2310                         ["div", { key: "items", highlight: "Completions" }]],
2311                     ["div", { key: "waiting", highlight: "CompMsg" },
2312                         ItemList.WAITING_MESSAGE],
2313                     ["div", { key: "down", highlight: "CompMore" }]]],
2314
2315         get doc() this.parent.doc,
2316         get win() this.parent.win,
2317         get maxItems() this.parent.maxItems,
2318
2319         get itemCount() this.context.items.length,
2320
2321         /**
2322          * Returns a function which will update the scroll offsets
2323          * and heights of various DOM members.
2324          * @private
2325          */
2326         get rescrollFunc() {
2327             let container = this.nodes.itemsContainer;
2328             let pos    = DOM(container).rect.top;
2329             let start  = DOM(this.getRow(this.range.start)).rect.top;
2330             let height = DOM(this.getRow(this.range.end - 1)).rect.bottom - start || 0;
2331             let scroll = start + container.scrollTop - pos;
2332
2333             let win = this.win;
2334             let row = this.selectedRow;
2335             if (row && this.parent.minHeight) {
2336                 let { rect } = DOM(this.selectedRow);
2337                 var scrollY = this.win.scrollY + rect.bottom - this.win.innerHeight;
2338             }
2339
2340             return function () {
2341                 container.style.height = height + "px";
2342                 container.scrollTop = scroll;
2343                 if (scrollY != null)
2344                     win.scrollTo(0, Math.max(scrollY, 0));
2345             }
2346         },
2347
2348         /**
2349          * Reset this group for use with a new set of results.
2350          */
2351         reset: function reset() {
2352             this.nodes = {};
2353             this.generatedRange = ItemList.Range(0, 0);
2354
2355             DOM.fromJSON(this.rootXML, this.doc, this.nodes);
2356         },
2357
2358         /**
2359          * Update this group after an asynchronous results push.
2360          */
2361         update: function update() {
2362             this.generatedRange = ItemList.Range(0, 0);
2363             DOM(this.nodes.items).empty();
2364
2365             if (this.context.message)
2366                 DOM(this.nodes.message).empty()
2367                     .append(DOM.fromJSON(this.context.message, this.doc));
2368
2369             if (this.selectedIdx > this.itemCount)
2370                 this.selectedIdx = null;
2371         },
2372
2373         /**
2374          * Updates the DOM to reflect the current state of this
2375          * group.
2376          * @private
2377          */
2378         draw: function draw() {
2379             DOM(this.nodes.contents).toggle(!this.collapsed);
2380             if (this.collapsed)
2381                 return;
2382
2383             DOM(this.nodes.message).toggle(this.context.message && this.range.start == 0);
2384             DOM(this.nodes.waiting).toggle(this.context.incomplete && this.range.end <= this.itemCount);
2385             DOM(this.nodes.up).toggle(this.range.start > 0);
2386             DOM(this.nodes.down).toggle(this.range.end < this.itemCount);
2387
2388             if (!this.generatedRange.contains(this.range)) {
2389                 if (this.generatedRange.end == 0)
2390                     var [start, end] = this.range;
2391                 else {
2392                     start = this.range.start - (this.range.start <= this.generatedRange.start
2393                                                     ? this.maxItems / 2 : 0);
2394                     end   = this.range.end   + (this.range.end > this.generatedRange.end
2395                                                     ? this.maxItems / 2 : 0);
2396                 }
2397
2398                 let range = ItemList.Range(Math.max(0, start - start % 2),
2399                                            Math.min(this.itemCount, end));
2400
2401                 let first;
2402                 for (let [i, row] in this.context.getRows(this.generatedRange.start,
2403                                                           this.generatedRange.end,
2404                                                           this.doc))
2405                     if (!range.contains(i))
2406                         DOM(row).remove();
2407                     else if (!first)
2408                         first = row;
2409
2410                 let container = DOM(this.nodes.items);
2411                 let before    = first ? DOM(first).closure.before
2412                                       : DOM(this.nodes.items).closure.append;
2413
2414                 for (let [i, row] in this.context.getRows(range.start, range.end,
2415                                                           this.doc)) {
2416                     if (i < this.generatedRange.start)
2417                         before(row);
2418                     else if (i >= this.generatedRange.end)
2419                         container.append(row);
2420                     if (i == this.selectedIdx)
2421                         this.selectedIdx = this.selectedIdx;
2422                 }
2423
2424                 this.generatedRange = range;
2425             }
2426         },
2427
2428         getRow: function getRow(idx) this.context.getRow(idx, this.doc),
2429
2430         getOffset: function getOffset(idx) this.offsets.start + (idx || 0),
2431
2432         get selectedRow() this.getRow(this._selectedIdx),
2433
2434         get selectedIdx() this._selectedIdx,
2435         set selectedIdx(idx) {
2436             if (this.selectedRow && this._selectedIdx != idx)
2437                 DOM(this.selectedRow).attr("selected", null);
2438
2439             this._selectedIdx = idx;
2440
2441             if (this.selectedRow)
2442                 DOM(this.selectedRow).attr("selected", true);
2443         }
2444     }),
2445
2446     Range: Class.Memoize(function () {
2447         let Range = Struct("ItemList.Range", "start", "end");
2448         update(Range.prototype, {
2449             contains: function contains(idx)
2450                 typeof idx == "number" ? idx >= this.start && idx < this.end
2451                                        : this.contains(idx.start) &&
2452                                          idx.end >= this.start && idx.end <= this.end
2453         });
2454         return Range;
2455     })
2456 });
2457
2458 // vim: set fdm=marker sw=4 ts=4 et: