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