diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 316809e2..a51e5456 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 7dc2cc7b..5183009f 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 d7afad70..4ae0c08f 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { CharMeasure } from './ui/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, stringArrayToRow(['😁', ' '])); + 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 6521a209..1de14e70 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,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager /** * The current selection mode. */ - private _activeSelectionMode: SelectionMode; + protected _activeSelectionMode: SelectionMode; /** * A setInterval timer that is active while the mouse is down whose callback @@ -182,30 +183,43 @@ 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 { + if (this._activeSelectionMode === SelectionMode.COLUMN) { + // Ignore zero width selections + if (start[0] === end[0]) { + return ''; + } + + 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); + } } } @@ -254,7 +268,11 @@ 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 + }); } /** @@ -363,7 +381,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param event The mouse event. */ public shouldForceSelection(event: MouseEvent): boolean { - return Browser.isMac ? event.altKey : event.shiftKey; + if (Browser.isMac) { + return event.altKey && this._terminal.options.macOptionClickForcesSelection; + } + + return event.shiftKey; } /** @@ -454,7 +476,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager private _onSingleClick(event: MouseEvent): void { this._model.selectionStartLength = 0; this._model.isSelectAllActive = false; - this._activeSelectionMode = SelectionMode.NORMAL; + this._activeSelectionMode = this.shouldColumnSelect(event) ? SelectionMode.COLUMN : SelectionMode.NORMAL; // Initialize the new selection this._model.selectionStart = this._getMouseBufferCoords(event); @@ -507,6 +529,14 @@ export class SelectionManager extends EventEmitter implements ISelectionManager } } + /** + * Returns whether the selection manager should operate in column select mode + * @param event the mouse or keyboard event + */ + public shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean { + return event.altKey && !(Browser.isMac && this._terminal.options.macOptionClickForcesSelection); + } + /** * 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 971c0fc9..dbb2cd1e 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -98,6 +98,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = { screenReaderMode: false, debug: false, macOptionIsMeta: false, + macOptionClickForcesSelection: false, cancelEvents: false, disableStdin: false, useFlowControl: false, @@ -586,6 +587,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (!wasMondifierKeyOnlyEvent(ev)) { this.focus(); } + + self._keyUp(ev); }, true)); this.register(addDisposableDomListener(this.textarea, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true)); @@ -696,7 +699,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.selectionManager = new SelectionManager(this, this.charMeasure); this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e))); - this.register(this.selectionManager.addDisposableListener('refresh', data => this.renderer.onSelectionChanged(data.start, data.end))); + this.register(this.selectionManager.addDisposableListener('refresh', data => this.renderer.onSelectionChanged(data.start, data.end, data.columnSelectMode))); this.register(this.selectionManager.addDisposableListener('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 @@ -1105,6 +1108,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 && this.selectionManager.shouldColumnSelect(ev)) { + this.element.classList.add('xterm-cursor-crosshair'); + } else { + this.element.classList.remove('xterm-cursor-crosshair'); + } + } + /** * Display the cursor element */ @@ -1422,6 +1436,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 @@ -1493,6 +1509,10 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } + protected _keyUp(ev: KeyboardEvent): void { + this.updateCursorStyle(ev); + } + /** * 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 aecd5c79..6a382104 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -143,8 +143,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 e7054c13..56cfa2fc 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -35,7 +35,7 @@ export interface IRenderer extends IEventEmitter, IDisposable { 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; @@ -99,7 +99,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 989e147d..026381ff 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..8e129f50 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -139,6 +139,11 @@ cursor: pointer; } +.xterm.xterm-cursor-crosshair { + /* Column selection mode */ + cursor: crosshair; +} + .xterm .xterm-accessibility, .xterm .xterm-message { position: absolute; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index bda984b7..2bb70815 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -124,6 +124,15 @@ declare module 'xterm' { */ macOptionIsMeta?: boolean; + /** + * Whether holding a modifier key will force normal selection behavior, + * regardless of whether the terminal is in mouse events mode. This will + * also prevent mouse events from being emitted by the terminal. For example, + * this allows you to use xterm.js' regular selection inside tmux with + * mouse mode enabled. + */ + macOptionClickForcesSelection?: boolean; + /** * (EXPERIMENTAL) The type of renderer to use, this allows using the * fallback DOM renderer when canvas is too slow for the environment. The