Adds support for column selections if holding alt on mac and shift on windows/linux

This commit is contained in:
Viraj Sinha
2018-06-28 15:55:44 -07:00
parent 8c20a2e26b
commit 3202ddc96d
11 changed files with 291 additions and 58 deletions
+77
View File
@@ -269,4 +269,81 @@ describe('Buffer', () => {
assert.equal(buffer.markers.length, 0); assert.equal(buffer.markers.length, 0);
}); });
}); });
describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => {
buffer.lines.set(0, [
[ null, 'a', 1, 'a'.charCodeAt(0)],
[ null, 'b', 1, 'b'.charCodeAt(0)],
[ null, 'c', 1, 'c'.charCodeAt(0)],
[ null, 'd', 1, 'd'.charCodeAt(0)]
]);
const str = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str, 'ab');
});
it('should handle a cut-off double width character by including it', () => {
buffer.lines.set(0, [
[ null, '語', 2, 35486 ],
[ null, '', 0, null],
[ null, 'a', 1, 'a'.charCodeAt(0)]
]);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
assert.equal(str1, '語');
});
it('should handle a zero width character in the middle of the string by not including it', () => {
buffer.lines.set(0, [
[ null, '語', 2, '語'.charCodeAt(0) ],
[ null, '', 0, null],
[ null, 'a', 1, 'a'.charCodeAt(0)]
]);
const str0 = buffer.translateBufferLineToString(0, true, 0, 1);
assert.equal(str0, '語');
const str1 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str1, '語');
const str2 = buffer.translateBufferLineToString(0, true, 0, 3);
assert.equal(str2, '語a');
});
it('should handle single width emojis', () => {
buffer.lines.set(0, [
[ null, '😁', 1, '😁'.charCodeAt(0) ],
[ null, 'a', 1, 'a'.charCodeAt(0)]
]);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
assert.equal(str1, '😁');
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁a');
});
it('should handle double width emojis', () => {
buffer.lines.set(0, [
[ null, '😁', 2, '😁'.charCodeAt(0) ],
[ null, '', 0, null]
]);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
assert.equal(str1, '😁');
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁');
buffer.lines.set(0, [
[ null, '😁', 2, '😁'.charCodeAt(0) ],
[ null, '', 0, null],
[ null, 'a', 1, 'a'.charCodeAt(0)]
]);
const str3 = buffer.translateBufferLineToString(0, true, 0, 3);
assert.equal(str3, '😁a');
});
});
}); });
+1 -1
View File
@@ -227,7 +227,7 @@ export class Buffer implements IBuffer {
if (startCol >= i) { if (startCol >= i) {
startIndex--; startIndex--;
} }
if (endCol >= i) { if (endCol > i) {
endIndex--; endIndex--;
} }
} else { } else {
+60 -1
View File
@@ -5,7 +5,7 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { CharMeasure } from './utils/CharMeasure'; import { CharMeasure } from './utils/CharMeasure';
import { SelectionManager } from './SelectionManager'; import { SelectionManager, SelectionMode } from './SelectionManager';
import { SelectionModel } from './SelectionModel'; import { SelectionModel } from './SelectionModel';
import { BufferSet } from './BufferSet'; import { BufferSet } from './BufferSet';
import { LineData, CharData, ITerminal, IBuffer } from './Types'; import { LineData, CharData, ITerminal, IBuffer } from './Types';
@@ -25,6 +25,8 @@ class TestSelectionManager extends SelectionManager {
public get model(): SelectionModel { return this._model; } public get model(): SelectionModel { return this._model; }
public set selectionMode(mode: SelectionMode) { this._activeSelectionMode = mode; }
public selectLineAt(line: number): void { this._selectLineAt(line); } public selectLineAt(line: number): void { this._selectLineAt(line); }
public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); } public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); }
@@ -378,4 +380,61 @@ describe('SelectionManager', () => {
assert.equal(selectionManager.hasSelection, true); assert.equal(selectionManager.hasSelection, true);
}); });
}); });
describe('column selection', () => {
it('should select a column of text', () => {
buffer.lines.length = 3;
buffer.lines.set(0, stringToRow('abcdefghij'));
buffer.lines.set(1, stringToRow('klmnopqrst'));
buffer.lines.set(2, stringToRow('uvwxyz'));
selectionManager.selectionMode = SelectionMode.COLUMN;
selectionManager.model.selectionStart = [2, 0];
selectionManager.model.selectionEnd = [4, 2];
assert.equal(selectionManager.selectionText, 'cd\nmn\nwx');
});
it('should select a column of text without chopping up double width characters', () => {
buffer.lines.length = 3;
buffer.lines.set(0, stringToRow('a'));
buffer.lines.set(1, stringToRow('語'));
buffer.lines.set(2, stringToRow('b'));
selectionManager.selectionMode = SelectionMode.COLUMN;
selectionManager.model.selectionStart = [0, 0];
selectionManager.model.selectionEnd = [1, 2];
assert.equal(selectionManager.selectionText, 'a\n語\nb');
});
it('should select a column of text with single character emojis', () => {
buffer.lines.length = 3;
buffer.lines.set(0, stringToRow('a'));
buffer.lines.set(1, stringToRow('☃'));
buffer.lines.set(2, stringToRow('c'));
selectionManager.selectionMode = SelectionMode.COLUMN;
selectionManager.model.selectionStart = [0, 0];
selectionManager.model.selectionEnd = [1, 2];
assert.equal(selectionManager.selectionText, 'a\n☃\nc');
});
it('should select a column of text with double character emojis', () => {
// TODO the case this is testing works for me in the demo webapp,
// but doing it programmatically fails.
buffer.lines.length = 3;
buffer.lines.set(0, stringToRow('a'));
buffer.lines.set(1, stringToRow('😁'));
buffer.lines.set(2, stringToRow('c'));
selectionManager.selectionMode = SelectionMode.COLUMN;
selectionManager.model.selectionStart = [0, 0];
selectionManager.model.selectionEnd = [1, 2];
assert.equal(selectionManager.selectionText, 'a\n😁\nc');
});
});
}); });
+81 -25
View File
@@ -54,10 +54,11 @@ interface IWordPosition {
/** /**
* A selection mode, this drives how the selection behaves on mouse move. * A selection mode, this drives how the selection behaves on mouse move.
*/ */
const enum SelectionMode { export const enum SelectionMode {
NORMAL, NORMAL,
WORD, WORD,
LINE LINE,
COLUMN
} }
/** /**
@@ -80,7 +81,12 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
/** /**
* The current selection mode. * The current selection mode.
*/ */
private _activeSelectionMode: SelectionMode; protected _activeSelectionMode: SelectionMode;
/**
* The modifier keys required to trigger block select mode with left click + drag
*/
private _columnSelectRequiredModifiers: string[];
/** /**
* A setInterval timer that is active while the mouse is down whose callback * A setInterval timer that is active while the mouse is down whose callback
@@ -114,6 +120,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
this._model = new SelectionModel(_terminal); this._model = new SelectionModel(_terminal);
this._activeSelectionMode = SelectionMode.NORMAL; this._activeSelectionMode = SelectionMode.NORMAL;
this._columnSelectRequiredModifiers = this._initColumnSelectModifierKeys();
} }
private get _buffer(): IBuffer { private get _buffer(): IBuffer {
@@ -177,30 +184,41 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
return ''; return '';
} }
// Get first row
const startRowEndCol = start[1] === end[1] ? end[0] : null;
const result: string[] = []; const result: string[] = [];
result.push(this._buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol));
// Get middle rows if (this._activeSelectionMode === SelectionMode.COLUMN) {
for (let i = start[1] + 1; i <= end[1] - 1; i++) { // Ignore zero width selections
const bufferLine = this._buffer.lines.get(i); if (start[0] !== end[0]) {
const lineText = this._buffer.translateBufferLineToString(i, true); for (let i = start[1]; i <= end[1]; i++) {
if ((<any>bufferLine).isWrapped) { const lineText = this._buffer.translateBufferLineToString(i, true, start[0], end[0]);
result[result.length - 1] += lineText; result.push(lineText);
} else { }
result.push(lineText);
} }
} } else {
// Get first row
const startRowEndCol = start[1] === end[1] ? end[0] : null;
result.push(this._buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol));
// Get final row // Get middle rows
if (start[1] !== end[1]) { for (let i = start[1] + 1; i <= end[1] - 1; i++) {
const bufferLine = this._buffer.lines.get(end[1]); const bufferLine = this._buffer.lines.get(i);
const lineText = this._buffer.translateBufferLineToString(end[1], true, 0, end[0]); const lineText = this._buffer.translateBufferLineToString(i, true);
if ((<any>bufferLine).isWrapped) { if ((<any>bufferLine).isWrapped) {
result[result.length - 1] += lineText; result[result.length - 1] += lineText;
} else { } else {
result.push(lineText); result.push(lineText);
}
}
// Get final row
if (start[1] !== end[1]) {
const bufferLine = this._buffer.lines.get(end[1]);
const lineText = this._buffer.translateBufferLineToString(end[1], true, 0, end[0]);
if ((<any>bufferLine).isWrapped) {
result[result.length - 1] += lineText;
} else {
result.push(lineText);
}
} }
} }
@@ -249,7 +267,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
*/ */
private _refresh(): void { private _refresh(): void {
this._refreshAnimationFrame = null; this._refreshAnimationFrame = null;
this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd }); this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd, columnSelectMode: this._activeSelectionMode === SelectionMode.COLUMN });
} }
/** /**
@@ -398,7 +416,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
this._onIncrementalClick(event); this._onIncrementalClick(event);
} else { } else {
if (event.detail === 1) { if (event.detail === 1) {
this._onSingleClick(event); if (this.isColumnSelectMode(event)) {
this._onColumnSelectSingleClick(event);
} else {
this._onSingleClick(event);
}
} else if (event.detail === 2) { } else if (event.detail === 2) {
this._onDoubleClick(event); this._onDoubleClick(event);
} else if (event.detail === 3) { } else if (event.detail === 3) {
@@ -502,6 +524,40 @@ export class SelectionManager extends EventEmitter implements ISelectionManager
} }
} }
/**
* Configures the modifier key for enabling column selection mode
*/
private _initColumnSelectModifierKeys(): string[] {
if (this._terminal.browser.isMac) {
return ['altKey'];
}
// Linux and Windows
return ['shiftKey'];
}
/**
* Begin a block selection
*/
private _onColumnSelectSingleClick(event: MouseEvent): void {
this._onSingleClick(event); // Perform all the normal setup actions
this._activeSelectionMode = SelectionMode.COLUMN;
}
/**
* Checks if all required key modifiers are pressed in order to enable block
* select mode
* @param event the mouse click event
*/
public isColumnSelectMode(event: KeyboardEvent | MouseEvent): boolean {
for (let i = 0; i < this._columnSelectRequiredModifiers.length; i++) {
if (!(<any>event)[this._columnSelectRequiredModifiers[i]]) {
return false;
}
}
return true;
}
/** /**
* Handles the mousemove event when the mouse button is down, recording the * Handles the mousemove event when the mouse button is down, recording the
* end of the selection and refreshing the selection. * end of the selection and refreshing the selection.
+21 -1
View File
@@ -590,6 +590,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II
if (!wasMondifierKeyOnlyEvent(ev)) { if (!wasMondifierKeyOnlyEvent(ev)) {
this.focus(); this.focus();
} }
self._keyUp(ev);
}, true); }, true);
on(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true); on(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true);
@@ -696,7 +698,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II
this.selectionManager = new SelectionManager(this, this.charMeasure); this.selectionManager = new SelectionManager(this, this.charMeasure);
this.element.addEventListener('mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e)); this.element.addEventListener('mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e));
this.selectionManager.on('refresh', data => this.renderer.onSelectionChanged(data.start, data.end)); this.selectionManager.on('refresh', data => this.renderer.onSelectionChanged(data.start, data.end, data.columnSelectMode));
this.selectionManager.on('newselection', text => { this.selectionManager.on('newselection', text => {
// If there's a new selection, put it into the textarea, focus and select it // If there's a new selection, put it into the textarea, focus and select it
// in order to register it as a selection on the OS. This event is fired // in order to register it as a selection on the OS. This event is fired
@@ -1098,6 +1100,17 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II
} }
} }
/**
* Change the cursor style for different selection modes
*/
public updateCursorStyle(ev: KeyboardEvent): void {
if (this.selectionManager.isColumnSelectMode(ev)) {
this.element.classList.add('xterm-cursor-crosshair');
} else {
this.element.classList.remove('xterm-cursor-crosshair');
}
}
/** /**
* Display the cursor element * Display the cursor element
*/ */
@@ -1415,6 +1428,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II
const result = evaluateKeyboardEvent(event, this.applicationCursor, this.browser.isMac, this.options.macOptionIsMeta); const result = evaluateKeyboardEvent(event, this.applicationCursor, this.browser.isMac, this.options.macOptionIsMeta);
this.updateCursorStyle(event);
// if (result.key === C0.DC3) { // XOFF // if (result.key === C0.DC3) { // XOFF
// this._writeStopped = true; // this._writeStopped = true;
// } else if (result.key === C0.DC1) { // XON // } else if (result.key === C0.DC1) { // XON
@@ -1486,6 +1501,11 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II
} }
} }
protected _keyUp(ev: KeyboardEvent): boolean {
this.updateCursorStyle(ev);
return true;
}
/** /**
* Handle a keypress event. * Handle a keypress event.
* Key Resources: * Key Resources:
+1 -1
View File
@@ -49,7 +49,7 @@ export abstract class BaseRenderLayer implements IRenderLayer {
public onFocus(terminal: ITerminal): void {} public onFocus(terminal: ITerminal): void {}
public onCursorMove(terminal: ITerminal): void {} public onCursorMove(terminal: ITerminal): void {}
public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void {} public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void {}
public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void {} public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number], columnSelectMode: boolean = false): void {}
public onThemeChanged(terminal: ITerminal, colorSet: IColorSet): void { public onThemeChanged(terminal: ITerminal, colorSet: IColorSet): void {
this._refreshCharAtlas(terminal, colorSet); this._refreshCharAtlas(terminal, colorSet);
+2 -2
View File
@@ -141,8 +141,8 @@ export class Renderer extends EventEmitter implements IRenderer {
this._runOperation(l => l.onFocus(this._terminal)); this._runOperation(l => l.onFocus(this._terminal));
} }
public onSelectionChanged(start: [number, number], end: [number, number]): void { public onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean = false): void {
this._runOperation(l => l.onSelectionChanged(this._terminal, start, end)); this._runOperation(l => l.onSelectionChanged(this._terminal, start, end, columnSelectMode));
} }
public onCursorMove(): void { public onCursorMove(): void {
+22 -13
View File
@@ -37,7 +37,7 @@ export class SelectionRenderLayer extends BaseRenderLayer {
} }
} }
public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void { public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number], columnSelectMode: boolean): void {
// Selection has not changed // Selection has not changed
if (this._state.start === start || this._state.end === end) { if (this._state.start === start || this._state.end === end) {
return; return;
@@ -62,21 +62,30 @@ export class SelectionRenderLayer extends BaseRenderLayer {
return; return;
} }
// Draw first row
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
const startRowEndCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : terminal.cols;
this._ctx.fillStyle = this._colors.selection.css; this._ctx.fillStyle = this._colors.selection.css;
this.fillCells(startCol, viewportCappedStartRow, startRowEndCol - startCol, 1);
// Draw middle rows if (columnSelectMode) {
const middleRowsCount = Math.max(viewportCappedEndRow - viewportCappedStartRow - 1, 0); const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
this.fillCells(0, viewportCappedStartRow + 1, terminal.cols, middleRowsCount); const width = end[0] - startCol;
const height = viewportCappedEndRow - viewportCappedStartRow + 1;
this.fillCells(startCol, viewportCappedStartRow, width, height);
// Draw final row } else {
if (viewportCappedStartRow !== viewportCappedEndRow) { // Draw first row
// Only draw viewportEndRow if it's not the same as viewportStartRow const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : terminal.cols; const startRowEndCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : terminal.cols;
this.fillCells(0, viewportCappedEndRow, endCol, 1); this.fillCells(startCol, viewportCappedStartRow, startRowEndCol - startCol, 1);
// Draw middle rows
const middleRowsCount = Math.max(viewportCappedEndRow - viewportCappedStartRow - 1, 0);
this.fillCells(0, viewportCappedStartRow + 1, terminal.cols, middleRowsCount);
// Draw final row
if (viewportCappedStartRow !== viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewportStartRow
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : terminal.cols;
this.fillCells(0, viewportCappedEndRow, endCol, 1);
}
} }
// Save state for next render // Save state for next render
+2 -2
View File
@@ -34,7 +34,7 @@ export interface IRenderer extends IEventEmitter {
onCharSizeChanged(): void; onCharSizeChanged(): void;
onBlur(): void; onBlur(): void;
onFocus(): void; onFocus(): void;
onSelectionChanged(start: [number, number], end: [number, number]): void; onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean): void;
onCursorMove(): void; onCursorMove(): void;
onOptionsChanged(): void; onOptionsChanged(): void;
clear(): void; clear(): void;
@@ -98,7 +98,7 @@ export interface IRenderLayer {
/** /**
* Calls when the selection changes. * Calls when the selection changes.
*/ */
onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void; onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number], columnSelectMode: boolean): void;
/** /**
* Resize the render layer. * Resize the render layer.
+19 -12
View File
@@ -217,7 +217,7 @@ export class DomRenderer extends EventEmitter implements IRenderer {
this._rowContainer.classList.add(FOCUS_CLASS); this._rowContainer.classList.add(FOCUS_CLASS);
} }
public onSelectionChanged(start: [number, number], end: [number, number]): void { public onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean): void {
// Remove all selections // Remove all selections
while (this._selectionContainer.children.length) { while (this._selectionContainer.children.length) {
this._selectionContainer.removeChild(this._selectionContainer.children[0]); this._selectionContainer.removeChild(this._selectionContainer.children[0]);
@@ -241,18 +241,25 @@ export class DomRenderer extends EventEmitter implements IRenderer {
// Create the selections // Create the selections
const documentFragment = document.createDocumentFragment(); const documentFragment = document.createDocumentFragment();
// Draw first row
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol)); if (columnSelectMode) {
// Draw middle rows documentFragment.appendChild(
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1; this._createSelectionElement(viewportCappedStartRow, startCol, end[0], viewportCappedEndRow - viewportStartRow + 1)
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._terminal.cols, middleRowsCount)); );
// Draw final row } else {
if (viewportCappedStartRow !== viewportCappedEndRow) { // Draw first row
// Only draw viewportEndRow if it's not the same as viewporttartRow const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols;
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._terminal.cols; documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol)); // Draw middle rows
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._terminal.cols, middleRowsCount));
// Draw final row
if (viewportCappedStartRow !== viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewporttartRow
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._terminal.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol));
}
} }
this._selectionContainer.appendChild(documentFragment); this._selectionContainer.appendChild(documentFragment);
} }
+5
View File
@@ -139,6 +139,11 @@
cursor: pointer; cursor: pointer;
} }
.xterm.xterm-cursor-crosshair {
/* Block selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility, .xterm .xterm-accessibility,
.xterm .xterm-message { .xterm .xterm-message {
position: absolute; position: absolute;