diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 82aa0224..085f3d99 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -269,4 +269,81 @@ describe('Buffer', () => { 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'); + }); + }); }); diff --git a/src/Buffer.ts b/src/Buffer.ts index 5ac2a4f2..3b48e8a6 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -227,7 +227,7 @@ export class Buffer implements IBuffer { if (startCol >= i) { startIndex--; } - if (endCol >= i) { + if (endCol > i) { endIndex--; } } else { diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index 8e89ea3f..70d3fc1b 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { CharMeasure } from './utils/CharMeasure'; -import { SelectionManager } from './SelectionManager'; +import { SelectionManager, SelectionMode } from './SelectionManager'; import { SelectionModel } from './SelectionModel'; import { BufferSet } from './BufferSet'; import { LineData, CharData, ITerminal, IBuffer } from './Types'; @@ -25,6 +25,8 @@ class TestSelectionManager extends SelectionManager { public get model(): SelectionModel { return this._model; } + public set selectionMode(mode: SelectionMode) { this._activeSelectionMode = mode; } + public selectLineAt(line: number): void { this._selectLineAt(line); } public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); } @@ -378,4 +380,61 @@ describe('SelectionManager', () => { 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'); + }); + }); }); + diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 1b056e83..4f86c6de 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -54,10 +54,11 @@ interface IWordPosition { /** * A selection mode, this drives how the selection behaves on mouse move. */ -const enum SelectionMode { +export const enum SelectionMode { NORMAL, WORD, - LINE + LINE, + COLUMN } /** @@ -80,7 +81,12 @@ export class SelectionManager extends EventEmitter implements ISelectionManager /** * 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 @@ -114,6 +120,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager this._model = new SelectionModel(_terminal); this._activeSelectionMode = SelectionMode.NORMAL; + this._columnSelectRequiredModifiers = this._initColumnSelectModifierKeys(); } private get _buffer(): IBuffer { @@ -177,30 +184,41 @@ export class SelectionManager extends EventEmitter implements ISelectionManager return ''; } - // Get first row - const startRowEndCol = start[1] === end[1] ? end[0] : null; const result: string[] = []; - result.push(this._buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol)); - // Get middle rows - for (let i = start[1] + 1; i <= end[1] - 1; i++) { - const bufferLine = this._buffer.lines.get(i); - const lineText = this._buffer.translateBufferLineToString(i, true); - if ((bufferLine).isWrapped) { - result[result.length - 1] += lineText; - } else { - result.push(lineText); + if (this._activeSelectionMode === SelectionMode.COLUMN) { + // Ignore zero width selections + if (start[0] !== end[0]) { + for (let i = start[1]; i <= end[1]; i++) { + const lineText = this._buffer.translateBufferLineToString(i, true, start[0], end[0]); + 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 - 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 ((bufferLine).isWrapped) { - result[result.length - 1] += lineText; - } else { - result.push(lineText); + // Get middle rows + for (let i = start[1] + 1; i <= end[1] - 1; i++) { + const bufferLine = this._buffer.lines.get(i); + const lineText = this._buffer.translateBufferLineToString(i, true); + if ((bufferLine).isWrapped) { + result[result.length - 1] += lineText; + } else { + 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 ((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 { 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); } else { if (event.detail === 1) { - this._onSingleClick(event); + if (this.isColumnSelectMode(event)) { + this._onColumnSelectSingleClick(event); + } else { + this._onSingleClick(event); + } } else if (event.detail === 2) { this._onDoubleClick(event); } 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 (!(event)[this._columnSelectRequiredModifiers[i]]) { + return false; + } + } + return true; + } + /** * Handles the mousemove event when the mouse button is down, recording the * end of the selection and refreshing the selection. diff --git a/src/Terminal.ts b/src/Terminal.ts index 9f581461..b754b6f6 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -590,6 +590,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (!wasMondifierKeyOnlyEvent(ev)) { this.focus(); } + + self._keyUp(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.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 => { // 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 @@ -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 */ @@ -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); + this.updateCursorStyle(event); + // if (result.key === C0.DC3) { // XOFF // this._writeStopped = true; // } 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. * Key Resources: diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index b2a40290..2e9de389 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -49,7 +49,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { public onFocus(terminal: ITerminal): void {} public onCursorMove(terminal: ITerminal): 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 { this._refreshCharAtlas(terminal, colorSet); diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index c41dece5..a3743289 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -141,8 +141,8 @@ export class Renderer extends EventEmitter implements IRenderer { this._runOperation(l => l.onFocus(this._terminal)); } - public onSelectionChanged(start: [number, number], end: [number, number]): void { - this._runOperation(l => l.onSelectionChanged(this._terminal, start, end)); + public onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean = false): void { + this._runOperation(l => l.onSelectionChanged(this._terminal, start, end, columnSelectMode)); } public onCursorMove(): void { diff --git a/src/renderer/SelectionRenderLayer.ts b/src/renderer/SelectionRenderLayer.ts index e2212246..a9542cd7 100644 --- a/src/renderer/SelectionRenderLayer.ts +++ b/src/renderer/SelectionRenderLayer.ts @@ -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 if (this._state.start === start || this._state.end === end) { return; @@ -62,21 +62,30 @@ export class SelectionRenderLayer extends BaseRenderLayer { 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.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); + if (columnSelectMode) { + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + const width = end[0] - startCol; + const height = viewportCappedEndRow - viewportCappedStartRow + 1; + this.fillCells(startCol, viewportCappedStartRow, width, height); - // 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); + } else { + // Draw first row + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + const startRowEndCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : terminal.cols; + 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 diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts index bafc9ad4..6d9b293d 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -34,7 +34,7 @@ export interface IRenderer extends IEventEmitter { onCharSizeChanged(): void; onBlur(): void; onFocus(): void; - onSelectionChanged(start: [number, number], end: [number, number]): void; + onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean): void; onCursorMove(): void; onOptionsChanged(): void; clear(): void; @@ -98,7 +98,7 @@ export interface IRenderLayer { /** * 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. diff --git a/src/renderer/dom/DomRenderer.ts b/src/renderer/dom/DomRenderer.ts index 8c335dca..5f3b1c2a 100644 --- a/src/renderer/dom/DomRenderer.ts +++ b/src/renderer/dom/DomRenderer.ts @@ -217,7 +217,7 @@ export class DomRenderer extends EventEmitter implements IRenderer { 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 while (this._selectionContainer.children.length) { this._selectionContainer.removeChild(this._selectionContainer.children[0]); @@ -241,18 +241,25 @@ export class DomRenderer extends EventEmitter implements IRenderer { // Create the selections const documentFragment = document.createDocumentFragment(); - // Draw first row const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; - const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols; - documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, 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)); + + if (columnSelectMode) { + documentFragment.appendChild( + this._createSelectionElement(viewportCappedStartRow, startCol, end[0], viewportCappedEndRow - viewportStartRow + 1) + ); + } else { + // Draw first row + const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols; + documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, 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); } diff --git a/src/xterm.css b/src/xterm.css index 6e7d2f96..b435a63f 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -139,6 +139,11 @@ cursor: pointer; } +.xterm.xterm-cursor-crosshair { + /* Block selection mode */ + cursor: crosshair; +} + .xterm .xterm-accessibility, .xterm .xterm-message { position: absolute;