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