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