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