This repository has been archived by the owner on Jun 28, 2022. It is now read-only.
/
inputter.js
664 lines (569 loc) · 20.5 KB
/
inputter.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
/*
* Copyright 2009-2011 Mozilla Foundation and contributors
* Licensed under the New BSD license. See LICENSE.txt or:
* http://opensource.org/licenses/BSD-3-Clause
*/
define(function(require, exports, module) {
var cliView = exports;
var KeyEvent = require('gcli/util').event.KeyEvent;
var dom = require('gcli/util').dom;
var Status = require('gcli/types').Status;
var History = require('gcli/history').History;
var inputterCss = require('text!gcli/ui/inputter.css');
/**
* A wrapper to take care of the functions concerning an input element
*/
function Inputter(options) {
this.requisition = options.requisition;
// Suss out where the input element is
this.element = options.inputElement || 'gcli-input';
if (typeof this.element === 'string') {
this.document = options.document || document;
var name = this.element;
this.element = this.document.getElementById(name);
if (!this.element) {
throw new Error('No element with id=' + name + '.');
}
}
else {
// Assume we've been passed in the correct node
this.document = this.element.ownerDocument;
}
if (inputterCss != null) {
this.style = dom.importCss(inputterCss, this.document);
}
this.element.spellcheck = false;
// Used to distinguish focus from TAB in CLI. See onKeyUp()
this.lastTabDownAt = 0;
// Used to effect caret changes. See _processCaretChange()
this._caretChange = null;
// Ensure that TAB/UP/DOWN isn't handled by the browser
this.onKeyDown = this.onKeyDown.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.element.addEventListener('keydown', this.onKeyDown, false);
this.element.addEventListener('keyup', this.onKeyUp, false);
this.completer = options.completer || new Completer(options);
this.completer.decorate(this);
// Use the provided history object, or instantiate our own
this.history = options.history || new History(options);
this._scrollingThroughHistory = false;
// Cursor position affects hint severity
this.onMouseUp = function(ev) {
this.completer.update(this.getInputState());
}.bind(this);
this.element.addEventListener('mouseup', this.onMouseUp, false);
this.focusManager = options.focusManager;
if (this.focusManager) {
this.focusManager.addMonitoredElement(this.element, 'input');
}
this.requisition.inputChange.add(this.onInputChange, this);
this.update();
}
/**
* Avoid memory leaks
*/
Inputter.prototype.destroy = function() {
this.requisition.inputChange.remove(this.onInputChange, this);
if (this.focusManager) {
this.focusManager.removeMonitoredElement(this.element, 'input');
}
this.element.removeEventListener('keydown', this.onKeyDown, false);
this.element.removeEventListener('keyup', this.onKeyUp, false);
delete this.onKeyDown;
delete this.onKeyUp;
this.history.destroy();
this.completer.destroy();
if (this.style) {
this.style.parentNode.removeChild(this.style);
delete this.style;
}
delete this.document;
delete this.element;
};
/**
* Utility to add an element into the DOM after the input element
*/
Inputter.prototype.appendAfter = function(element) {
this.element.parentNode.insertBefore(element, this.element.nextSibling);
};
/**
* Handler for the Requisition.inputChange event
*/
Inputter.prototype.onInputChange = function() {
if (this._caretChange == null) {
// We weren't expecting a change so this was requested by the hint system
// we should move the cursor to the end of the 'changed section', and the
// best we can do for that right now is the end of the current argument.
this._caretChange = Caret.TO_ARG_END;
}
this._setInputInternal(this.requisition.toString());
};
/**
* Internal function to set the input field to a value.
* This function checks to see if the current value is the same as the new
* value, and makes no changes if they are the same (except for caret/completer
* updating - see below). If changes are to be made, they are done in a timeout
* to avoid XUL bug 676520.
* This function assumes that the data model is up to date with the new value.
* It does attempts to leave the caret position in the same position in the
* input string unless this._caretChange === Caret.TO_ARG_END. This is required
* for completion events.
* It does not change the completer decoration unless this._updatePending is
* set. This is required for completion events.
*/
Inputter.prototype._setInputInternal = function(str, update) {
if (!this.document) {
return; // This can happen post-destroy()
}
if (this.element.value && this.element.value === str) {
this._processCaretChange(this.getInputState(), false);
return;
}
// Updating in a timeout fixes a XUL issue (bug 676520) where textbox gives
// incorrect values for its content
this.document.defaultView.setTimeout(function() {
if (!this.document) {
return; // This can happen post-destroy()
}
// Bug 678520 - We could do better caret handling by recording the caret
// position in terms of offset into an assignment, and then replacing into
// a similar place
var input = this.getInputState();
input.typed = str;
this._processCaretChange(input);
this.element.value = str;
if (update) {
this.update();
}
}.bind(this), 0);
};
/**
* Various ways in which we need to manipulate the caret/selection position.
* A value of null means we're not expecting a change
*/
var Caret = {
/**
* We are expecting changes, but we don't need to move the cursor
*/
NO_CHANGE: 0,
/**
* We want the entire input area to be selected
*/
SELECT_ALL: 1,
/**
* The whole input has changed - push the cursor to the end
*/
TO_END: 2,
/**
* A part of the input has changed - push the cursor to the end of the
* changed section
*/
TO_ARG_END: 3
};
/**
* If this._caretChange === Caret.TO_ARG_END, we alter the input object to move
* the selection start to the end of the current argument.
* @param input An object shaped like { typed:'', cursor: { start:0, end:0 }}
* @param forceUpdate Do we call this.completer.update even when the cursor has
* not changed (useful when input.typed has changed)
*/
Inputter.prototype._processCaretChange = function(input, forceUpdate) {
var start, end;
switch (this._caretChange) {
case Caret.SELECT_ALL:
start = 0;
end = input.typed.length;
break;
case Caret.TO_END:
start = input.typed.length;
end = input.typed.length;
break;
case Caret.TO_ARG_END:
// There could be a fancy way to do this involving assignment/arg math
// but it doesn't seem easy, so we cheat a move the cursor to just before
// the next space, or the end of the input
start = input.cursor.start;
do {
start++;
}
while (start < input.typed.length && input.typed[start - 1] !== ' ');
end = start;
break;
case null:
case Caret.NO_CHANGE:
start = input.cursor.start;
end = input.cursor.end;
break;
}
start = (start > input.typed.length) ? input.typed.length : start;
end = (end > input.typed.length) ? input.typed.length : end;
var newInput = { typed: input.typed, cursor: { start: start, end: end }};
if (start !== input.cursor.start || end !== input.cursor.end || forceUpdate) {
this.completer.update(newInput);
}
this.element.selectionStart = newInput.cursor.start;
this.element.selectionEnd = newInput.cursor.end;
this._caretChange = null;
return newInput;
};
/**
* Set the input field to a value.
* This function updates the data model and the completer decoration. It sets
* the caret to the end of the input. It does not make any similarity checks
* so calling this function with it's current value resets the cursor position.
* It does not execute the input or affect the history.
* This function should not be called internally, by Inputter and never as a
* result of a keyboard event on this.element or bug 676520 could be triggered.
*/
Inputter.prototype.setInput = function(str) {
this.element.value = str;
this.update();
};
/**
* Focus the input element
*/
Inputter.prototype.focus = function() {
this.element.focus();
};
/**
* Ensure certain keys (arrows, tab, etc) that we would like to handle
* are not handled by the browser
*/
Inputter.prototype.onKeyDown = function(ev) {
if (ev.keyCode === KeyEvent.DOM_VK_UP || ev.keyCode === KeyEvent.DOM_VK_DOWN) {
ev.preventDefault();
}
if (ev.keyCode === KeyEvent.DOM_VK_TAB) {
this.lastTabDownAt = 0;
if (!ev.shiftKey) {
ev.preventDefault();
// Record the timestamp of this TAB down so onKeyUp can distinguish
// focus from TAB in the CLI.
this.lastTabDownAt = ev.timeStamp;
}
if (ev.metaKey || ev.altKey || ev.crtlKey) {
if (this.document.commandDispatcher) {
this.document.commandDispatcher.advanceFocus();
}
else {
this.element.blur();
}
}
}
};
/**
* The main keyboard processing loop
*/
Inputter.prototype.onKeyUp = function(ev) {
// RETURN does a special exec/highlight thing
if (ev.keyCode === KeyEvent.DOM_VK_RETURN) {
var worst = this.requisition.getStatus();
// Deny RETURN unless the command might work
if (worst === Status.VALID) {
this._scrollingThroughHistory = false;
this.history.add(this.element.value);
this.requisition.exec();
}
// See bug 664135 - On pressing return with an invalid input, GCLI
// should select the incorrect part of the input for an easy fix
return;
}
if (ev.keyCode === KeyEvent.DOM_VK_TAB && !ev.shiftKey) {
// If the TAB keypress took the cursor from another field to this one,
// then they get the keydown/keypress, and we get the keyup. In this
// case we don't want to do any completion.
// If the time of the keydown/keypress of TAB was close (i.e. within
// 1 second) to the time of the keyup then we assume that we got them
// both, and do the completion.
if (this.lastTabDownAt + 1000 > ev.timeStamp) {
// It's possible for TAB to not change the input, in which case the
// onInputChange event will not fire, and the caret move will not be
// processed. So we check that this is done first
this._caretChange = Caret.TO_ARG_END;
this._processCaretChange(this.getInputState(), true);
this.getCurrentAssignment().complete();
}
this.lastTabDownAt = 0;
this._scrollingThroughHistory = false;
return;
}
if (ev.keyCode === KeyEvent.DOM_VK_UP) {
if (this.element.value === '' || this._scrollingThroughHistory) {
this._scrollingThroughHistory = true;
this._setInputInternal(this.history.backward(), true);
}
else {
this.getCurrentAssignment().increment();
}
return;
}
if (ev.keyCode === KeyEvent.DOM_VK_DOWN) {
if (this.element.value === '' || this._scrollingThroughHistory) {
this._scrollingThroughHistory = true;
this._setInputInternal(this.history.forward(), true);
}
else {
this.getCurrentAssignment().decrement();
}
return;
}
this._scrollingThroughHistory = false;
this._caretChange = Caret.NO_CHANGE;
this.update();
};
/**
* Accessor for the assignment at the cursor.
* i.e Requisition.getAssignmentAt(cursorPos);
*/
Inputter.prototype.getCurrentAssignment = function() {
var start = this.element.selectionStart;
return this.requisition.getAssignmentAt(start);
};
/**
* Actually parse the input and make sure we're all up to date
*/
Inputter.prototype.update = function() {
var input = this.getInputState();
this.requisition.update(input);
this.completer.update(input);
};
/**
* Pull together an input object, which may include XUL hacks
*/
Inputter.prototype.getInputState = function() {
var input = {
typed: this.element.value,
cursor: {
start: this.element.selectionStart,
end: this.element.selectionEnd
}
};
// Workaround for potential XUL bug 676520 where textbox gives incorrect
// values for its content
if (input.typed == null) {
input = { typed: '', cursor: { start: 0, end: 0 } };
console.log('fixing input.typed=""', input);
}
return input;
};
cliView.Inputter = Inputter;
/**
* Completer is an 'input-like' element that sits an input element annotating
* it with visual goodness.
* @param {object} options An object that contains various options which
* customizes how the completer functions.
* Properties on the options object:
* - document (required) DOM document to be used in creating elements
* - requisition (required) A GCLI Requisition object whose state is monitored
* - completeElement (optional) An element to use
* - completionPrompt (optional) The prompt - defaults to '\u00bb'
* (double greater-than, a.k.a right guillemet). The prompt is used directly
* in a TextNode, so HTML entities are not allowed.
*/
function Completer(options) {
this.document = options.document || document;
this.requisition = options.requisition;
this.elementCreated = false;
this.element = options.completeElement || 'gcli-row-complete';
if (typeof this.element === 'string') {
var name = this.element;
this.element = this.document.getElementById(name);
if (!this.element) {
this.elementCreated = true;
this.element = dom.createElement(this.document, 'div');
this.element.className = 'gcli-in-complete gcli-in-valid';
this.element.setAttribute('tabindex', '-1');
this.element.setAttribute('aria-live', 'polite');
}
}
this.completionPrompt = typeof options.completionPrompt === 'string'
? options.completionPrompt
: '\u00bb';
if (options.inputBackgroundElement) {
this.backgroundElement = options.inputBackgroundElement;
}
else {
this.backgroundElement = this.element;
}
}
/**
* Avoid memory leaks
*/
Completer.prototype.destroy = function() {
delete this.document;
delete this.element;
delete this.backgroundElement;
if (this.elementCreated) {
this.document.defaultView.removeEventListener('resize', this.resizer, false);
}
delete this.inputter;
};
/**
* A list of the styles that decorate() should copy to make the completion
* element look like the input element. backgroundColor is a spiritual part of
* this list, but see comment in decorate().
*/
Completer.copyStyles = [ 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle' ];
/**
* Make ourselves visually similar to the input element, and make the input
* element transparent so our background shines through
*/
Completer.prototype.decorate = function(inputter) {
this.inputter = inputter;
var input = inputter.element;
// If we were told which element to use, then assume it is already
// correctly positioned. Otherwise insert it alongside the input element
if (this.elementCreated) {
this.inputter.appendAfter(this.element);
var styles = this.document.defaultView.getComputedStyle(input, null);
Completer.copyStyles.forEach(function(style) {
this.element.style[style] = styles[style];
}, this);
// The completer text is by default invisible so we make it the same color
// as the input background.
this.element.style.color = input.style.backgroundColor;
// If there is a separate backgroundElement, then we make the element
// transparent, otherwise it inherits the color of the input node
// It's not clear why backgroundColor doesn't work when used from
// computedStyle, but it doesn't. Patches welcome!
this.element.style.backgroundColor = (this.backgroundElement != this.element) ?
'transparent' :
input.style.backgroundColor;
input.style.backgroundColor = 'transparent';
// Make room for the prompt
input.style.paddingLeft = '20px';
this.resizer = this.resizer.bind(this);
this.document.defaultView.addEventListener('resize', this.resizer, false);
this.resizer();
}
};
/**
* Ensure that the completion element is the same size and the inputter element
*/
Completer.prototype.resizer = function() {
var rect = this.inputter.element.getBoundingClientRect();
// -4 to line up with 1px of padding and border, top and bottom
var height = rect.bottom - rect.top - 4;
this.element.style.top = rect.top + 'px';
this.element.style.height = height + 'px';
this.element.style.lineHeight = height + 'px';
this.element.style.left = rect.left + 'px';
this.element.style.width = (rect.right - rect.left) + 'px';
};
/**
* Is the completion given, a "strict" completion of the user inputted value?
* A completion is considered "strict" only if it the user inputted value is an
* exact prefix of the completion (ignoring leading whitespace)
*/
function isStrictCompletion(inputValue, completion) {
// Strip any leading whitespace from the user inputted value because the
// completion will never have leading whitespace.
inputValue = inputValue.replace(/^\s*/, '');
// Strict: "ec" -> "echo"
// Non-Strict: "ls *" -> "ls foo bar baz"
return completion.indexOf(inputValue) === 0;
}
/**
* Bring the completion element up to date with what the requisition says
*/
Completer.prototype.update = function(input) {
var current = this.requisition.getAssignmentAt(input.cursor.start);
var predictions = current.getPredictions();
dom.clearElement(this.element);
// All this DOM manipulation is equivalent to the HTML below.
// It's not a template because it's very simple except appendMarkupStatus()
// which is complex due to a need to merge spans.
// Bug 707131 questions if we couldn't simplify this to use a template.
//
// <span class="gcli-prompt">${completionPrompt}</span>
// ${appendMarkupStatus()}
// ${prefix}
// <span class="gcli-in-ontab">${contents}</span>
// <span class="gcli-in-closebrace" if="${unclosedJs}">}<span>
var document = this.element.ownerDocument;
var prompt = document.createElement('span');
prompt.classList.add('gcli-prompt');
prompt.appendChild(document.createTextNode(this.completionPrompt + ' '));
this.element.appendChild(prompt);
if (input.typed.length > 0) {
var scores = this.requisition.getInputStatusMarkup(input.cursor.start);
this.appendMarkupStatus(this.element, scores, input);
}
if (input.typed.length > 0 && predictions.length > 0) {
var tab = predictions[0].name;
var existing = current.getArg().text;
var contents;
var prefix = null;
if (isStrictCompletion(existing, tab) &&
input.cursor.start === input.typed.length) {
// Display the suffix of the prediction as the completion
var numLeadingSpaces = existing.match(/^(\s*)/)[0].length;
contents = tab.slice(existing.length - numLeadingSpaces);
} else {
// Display the '-> prediction' at the end of the completer element
prefix = ' \u00a0'; // aka
contents = '\u21E5 ' + tab; // aka → the right arrow
}
if (prefix != null) {
this.element.appendChild(document.createTextNode(prefix));
}
var suffix = document.createElement('span');
suffix.classList.add('gcli-in-ontab');
suffix.appendChild(document.createTextNode(contents));
this.element.appendChild(suffix);
}
// Add a grey '}' to the end of the command line when we've opened
// with a { but haven't closed it
var command = this.requisition.commandAssignment.getValue();
var unclosedJs = command && command.name === '{' &&
this.requisition.getAssignment(0).getArg().suffix.indexOf('}') === -1;
if (unclosedJs) {
var close = document.createElement('span');
close.classList.add('gcli-in-closebrace');
close.appendChild(document.createTextNode('}'));
this.element.appendChild(close);
}
};
/**
* Mark-up an array of Status values with spans
*/
Completer.prototype.appendMarkupStatus = function(element, scores, input) {
if (scores.length === 0) {
return;
}
var document = element.ownerDocument;
var i = 0;
var lastStatus = -1;
var span;
var contents = '';
while (true) {
if (lastStatus !== scores[i]) {
var state = scores[i];
if (!state) {
console.error('No state at i=' + i + '. scores.len=' + scores.length);
state = Status.VALID;
}
span = document.createElement('span');
span.classList.add('gcli-in-' + state.toString().toLowerCase());
lastStatus = scores[i];
}
var char = input.typed[i];
if (char === ' ') {
char = '\u00a0';
}
contents += char;
i++;
if (i === input.typed.length) {
span.appendChild(document.createTextNode(contents));
this.element.appendChild(span);
break;
}
if (lastStatus !== scores[i]) {
span.appendChild(document.createTextNode(contents));
this.element.appendChild(span);
contents = '';
}
}
};
cliView.Completer = Completer;
});