diff --git a/demo/index.html b/demo/index.html index ce58e192..ee765c20 100644 --- a/demo/index.html +++ b/demo/index.html @@ -11,10 +11,18 @@ +

xterm.js: xterm, in the browser

+
+

Actions

+

+ + +

+

Options

diff --git a/demo/main.js b/demo/main.js index c8d03ae1..9098c2f0 100644 --- a/demo/main.js +++ b/demo/main.js @@ -7,6 +7,10 @@ var term, charHeight; var terminalContainer = document.getElementById('terminal-container'), + actionElements = { + findNext: document.querySelector('#find-next'), + findPrevious: document.querySelector('#find-previous') + }, optionElements = { cursorBlink: document.querySelector('#option-cursor-blink'), cursorStyle: document.querySelector('#option-cursor-style'), @@ -30,6 +34,19 @@ function setTerminalSize () { colsElement.addEventListener('change', setTerminalSize); rowsElement.addEventListener('change', setTerminalSize); +actionElements.findNext.addEventListener('keypress', function (e) { + if (e.key === "Enter") { + e.preventDefault(); + term.findNext(actionElements.findNext.value); + } +}); +actionElements.findPrevious.addEventListener('keypress', function (e) { + if (e.key === "Enter") { + e.preventDefault(); + term.findPrevious(actionElements.findPrevious.value); + } +}); + optionElements.cursorBlink.addEventListener('change', function () { term.setOption('cursorBlink', optionElements.cursorBlink.checked); }); diff --git a/gulpfile.js b/gulpfile.js index d9ab8714..9b0a6de7 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,3 +1,7 @@ +/** + * @license MIT + */ + const browserify = require('browserify'); const buffer = require('vinyl-buffer'); const coveralls = require('gulp-coveralls'); @@ -14,6 +18,7 @@ const ts = require('gulp-typescript'); let buildDir = process.env.BUILD_DIR || 'build'; let tsProject = ts.createProject('tsconfig.json'); +let tsProjectSearchAddon = ts.createProject('./src/addons/search/tsconfig.json'); let srcDir = tsProject.config.compilerOptions.rootDir; let outDir = tsProject.config.compilerOptions.outDir; @@ -30,13 +35,17 @@ gulp.task('tsc', function () { let tsResult = tsProject.src().pipe(sourcemaps.init()).pipe(tsProject()); let tsc = tsResult.js.pipe(sourcemaps.write('.', {includeContent: false, sourceRoot: ''})).pipe(gulp.dest(outDir)); + fs.emptyDirSync(`${outDir}/addons/search`); + let tsResultSearchAddon = tsProjectSearchAddon.src().pipe(sourcemaps.init()).pipe(tsProjectSearchAddon()); + let tscSearchAddon = tsResultSearchAddon.js.pipe(sourcemaps.write('.', {includeContent: false, sourceRoot: ''})).pipe(gulp.dest(`${outDir}/addons/search`)); + // Copy all addons from ${srcDir}/ to ${outDir}/ - let copyAddons = gulp.src(`${srcDir}/addons/**/*`).pipe(gulp.dest(`${outDir}/addons`)); + let copyAddons = gulp.src([`${srcDir}/addons/**/*`, `!${srcDir}/addons/search`, `!${srcDir}/addons/search/**`]).pipe(gulp.dest(`${outDir}/addons`)); // Copy stylesheets from ${srcDir}/ to ${outDir}/ let copyStylesheets = gulp.src(`${srcDir}/**/*.css`).pipe(gulp.dest(outDir)); - return merge(tsc, copyAddons, copyStylesheets); + return merge(tsc, tscSearchAddon, copyAddons, copyStylesheets); }); /** @@ -63,13 +72,38 @@ gulp.task('browserify', ['tsc'], function() { .pipe(sourcemaps.write('./')) .pipe(gulp.dest(buildDir)); - // Copy all add-ons from ${outDir}/ to buildDir - let copyAddons = gulp.src(`${outDir}/addons/**/*`).pipe(gulp.dest(`${buildDir}/addons`)); - // Copy stylesheets from ${outDir}/ to ${buildDir}/ let copyStylesheets = gulp.src(`${outDir}/**/*.css`).pipe(gulp.dest(buildDir)); - return merge(bundleStream, copyAddons, copyStylesheets); + return merge(bundleStream, copyStylesheets); +}); + +gulp.task('browserify-addons', ['tsc'], function() { + let searchOptions = { + basedir: `${buildDir}/addons/search`, + debug: true, + entries: [`${outDir}/addons/search/search.js`], + cache: {}, + packageCache: {} + }; + let searchBundle = browserify(searchOptions) + .bundle() + .pipe(source('./addons/search/search.js')) + .pipe(buffer()) + .pipe(sourcemaps.init({loadMaps: true, sourceRoot: ''})) + .pipe(sourcemaps.write('./')) + .pipe(gulp.dest(buildDir)); + + // Copy all add-ons from outDir to buildDir + let copyAddons = gulp.src([ + // Copy JS addons + `${outDir}/addons/**/*`, + // Exclude TS addons from copy as they are being built via browserify + `!${outDir}/addons/search`, + `!${outDir}/addons/search/**` + ]).pipe(gulp.dest(`${buildDir}/addons`)); + + return merge(searchBundle, copyAddons); }); gulp.task('instrument-test', function () { @@ -98,6 +132,12 @@ gulp.task('sorcery', ['browserify'], function () { chain.writeSync(); }); +gulp.task('sorcery-addons', ['browserify-addons'], function () { + var chain = sorcery.loadSync(`${buildDir}/addons/search/search.js`); + chain.apply(); + chain.writeSync(); +}); + /** * Submit coverage results to coveralls.io */ @@ -106,6 +146,6 @@ gulp.task('coveralls', function () { .pipe(coveralls()); }); -gulp.task('build', ['sorcery']); +gulp.task('build', ['sorcery', 'sorcery-addons']); gulp.task('test', ['mocha']); gulp.task('default', ['build']); diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 4b857674..4de2f285 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -21,6 +21,7 @@ export interface ITerminal { element: HTMLElement; rowContainer: HTMLElement; selectionContainer: HTMLElement; + selectionManager: ISelectionManager; charMeasure: ICharMeasure; textarea: HTMLTextAreaElement; ybase: number; @@ -51,6 +52,10 @@ export interface ITerminal { export interface ISelectionManager { selectionText: string; + selectionStart: [number, number]; + selectionEnd: [number, number]; + + setSelection(row: number, col: number, length: number); } export interface ICharMeasure { diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 4440ea4f..ac7fd017 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -9,6 +9,7 @@ import { CircularList } from './utils/CircularList'; import { EventEmitter } from './EventEmitter'; import { ITerminal } from './Interfaces'; import { SelectionModel } from './SelectionModel'; +import { translateBufferLineToString } from './utils/BufferLine'; /** * The number of pixels the mouse needs to be above or below the viewport in @@ -163,6 +164,9 @@ export class SelectionManager extends EventEmitter { this.clearSelection(); } + public get selectionStart(): [number, number] { return this._model.finalSelectionStart; } + public get selectionEnd(): [number, number] { return this._model.finalSelectionEnd; } + /** * Gets whether there is an active text selection. */ @@ -188,12 +192,12 @@ export class SelectionManager extends EventEmitter { // Get first row const startRowEndCol = start[1] === end[1] ? end[0] : null; let result: string[] = []; - result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol)); + result.push(translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol)); // Get middle rows for (let i = start[1] + 1; i <= end[1] - 1; i++) { const bufferLine = this._buffer.get(i); - const lineText = this._translateBufferLineToString(bufferLine, true); + const lineText = translateBufferLineToString(bufferLine, true); if (bufferLine.isWrapped) { result[result.length - 1] += lineText; } else { @@ -204,7 +208,7 @@ export class SelectionManager extends EventEmitter { // Get final row if (start[1] !== end[1]) { const bufferLine = this._buffer.get(end[1]); - const lineText = this._translateBufferLineToString(bufferLine, true, 0, end[0]); + const lineText = translateBufferLineToString(bufferLine, true, 0, end[0]); if (bufferLine.isWrapped) { result[result.length - 1] += lineText; } else { @@ -230,55 +234,6 @@ export class SelectionManager extends EventEmitter { this.refresh(); } - /** - * Translates a buffer line to a string, with optional start and end columns. - * Wide characters will count as two columns in the resulting string. This - * function is useful for getting the actual text underneath the raw selection - * position. - * @param line The line being translated. - * @param trimRight Whether to trim whitespace to the right. - * @param startCol The column to start at. - * @param endCol The column to end at. - */ - private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string { - // TODO: This function should live in a buffer or buffer line class - - // Get full line - let lineString = ''; - let widthAdjustedStartCol = startCol; - let widthAdjustedEndCol = endCol; - for (let i = 0; i < line.length; i++) { - const char = line[i]; - lineString += char[LINE_DATA_CHAR_INDEX]; - // Adjust start and end cols for wide characters if they affect their - // column indexes - if (char[LINE_DATA_WIDTH_INDEX] === 0) { - if (startCol >= i) { - widthAdjustedStartCol--; - } - if (endCol >= i) { - widthAdjustedEndCol--; - } - } - } - - // Calculate the final end col by trimming whitespace on the right of the - // line if needed. - let finalEndCol = widthAdjustedEndCol || line.length; - if (trimRight) { - const rightWhitespaceIndex = lineString.search(/\s+$/); - if (rightWhitespaceIndex !== -1) { - finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex); - } - // Return the empty string if only trimmed whitespace is selected - if (finalEndCol <= widthAdjustedStartCol) { - return ''; - } - } - - return lineString.substring(widthAdjustedStartCol, finalEndCol); - } - /** * Queues a refresh, redrawing the selection on the next opportunity. * @param isNewSelection Whether the selection should be registered as a new @@ -565,13 +520,21 @@ export class SelectionManager extends EventEmitter { return charIndex; } + public setSelection(col: number, row: number, length: number): void { + this._model.clearSelection(); + this._removeMouseDownListeners(); + this._model.selectionStart = [col, row]; + this._model.selectionStartLength = length; + this.refresh(); + } + /** * Gets positional information for the word at the coordinated specified. * @param coords The coordinates to get the word at. */ private _getWordAt(coords: [number, number]): IWordPosition { const bufferLine = this._buffer.get(coords[1]); - const line = this._translateBufferLineToString(bufferLine, false); + const line = translateBufferLineToString(bufferLine, false); // Get actual index, taking into consideration wide characters let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts new file mode 100644 index 00000000..f2851447 --- /dev/null +++ b/src/addons/search/SearchHelper.ts @@ -0,0 +1,140 @@ +/** + * @license MIT + */ + +// import { ITerminal } from '../../Interfaces'; +// import { translateBufferLineToString } from '../../utils/BufferLine'; + +interface ISearchResult { + term: string; + col: number; + row: number; +} + +/** + * A class that knows how to search the terminal and how to display the results. + */ +export class SearchHelper { + constructor(private _terminal: any, private _translateBufferLineToString: any) { + // TODO: Search for multiple instances on 1 line + // TODO: Don't use the actual selection, instead use a "find selection" so multiple instances can be highlighted + // TODO: Highlight other instances in the viewport + // TODO: Support regex, case sensitivity, etc. + } + + /** + * Find the next instance of the term, then scroll to and select it. If it + * doesn't exist, do nothing. + * @param term Tne search term. + * @return Whether a result was found. + */ + public findNext(term: string): boolean { + if (!term || term.length === 0) { + return false; + } + + let result: ISearchResult; + + let startRow = this._terminal.ydisp; + if (this._terminal.selectionManager.selectionEnd) { + // Start from the selection end if there is a selection + startRow = this._terminal.selectionManager.selectionEnd[1]; + } + + // Search from ydisp + 1 to end + for (let y = startRow + 1; y < this._terminal.ybase + this._terminal.rows; y++) { + result = this._findInLine(term, y); + if (result) { + break; + } + } + + // Search from the top to the current ydisp + if (!result) { + for (let y = 0; y < startRow; y++) { + result = this._findInLine(term, y); + if (result) { + break; + } + } + } + + // Set selection and scroll if a result was found + return this._selectResult(result); + } + + /** + * Find the previous instance of the term, then scroll to and select it. If it + * doesn't exist, do nothing. + * @param term Tne search term. + * @return Whether a result was found. + */ + public findPrevious(term: string): boolean { + if (!term || term.length === 0) { + return false; + } + + let result: ISearchResult; + + let startRow = this._terminal.ydisp; + if (this._terminal.selectionManager.selectionStart) { + // Start from the selection end if there is a selection + startRow = this._terminal.selectionManager.selectionStart[1]; + } + + // Search from ydisp + 1 to end + for (let y = startRow - 1; y >= 0; y--) { + result = this._findInLine(term, y); + if (result) { + break; + } + } + + // Search from the top to the current ydisp + if (!result) { + for (let y = this._terminal.ybase + this._terminal.rows - 1; y > startRow; y--) { + result = this._findInLine(term, y); + if (result) { + break; + } + } + } + + // Set selection and scroll if a result was found + return this._selectResult(result); + } + + /** + * Searches a line for a search term. + * @param term Tne search term. + * @param y The line to search. + * @return The search result if it was found. + */ + private _findInLine(term: string, y: number): ISearchResult { + const bufferLine = this._terminal.lines.get(y); + const lowerStringLine = this._translateBufferLineToString(bufferLine, true).toLowerCase(); + const lowerTerm = term.toLowerCase(); + const searchIndex = lowerStringLine.indexOf(lowerTerm); + if (searchIndex >= 0) { + return { + term, + col: searchIndex, + row: y + }; + } + } + + /** + * Selects and scrolls to a result. + * @param result The result to select. + * @return Whethera result was selected. + */ + private _selectResult(result: ISearchResult): boolean { + if (!result) { + return false; + } + this._terminal.selectionManager.setSelection(result.col, result.row, result.term.length); + this._terminal.scrollDisp(result.row - this._terminal.ydisp, false); + return true; + } +} diff --git a/src/addons/search/search.ts b/src/addons/search/search.ts new file mode 100644 index 00000000..5a227a8a --- /dev/null +++ b/src/addons/search/search.ts @@ -0,0 +1,57 @@ +/** + * @license MIT + */ + +import { SearchHelper } from './SearchHelper'; + +declare var exports: any; +declare var module: any; +declare var define: any; +declare var require: any; +declare var window: any; + +(function (addon) { + if ('Terminal' in window) { + /** + * Plain browser environment + */ + addon(window.Terminal); + } else if (typeof exports === 'object' && typeof module === 'object') { + /** + * CommonJS environment + */ + const xterm = '../../xterm'; + module.exports = addon(require(xterm)); + } else if (typeof define == 'function') { + /** + * Require.js is available + */ + define(['../../xterm'], addon); + } +})((Terminal: any) => { + /** + * Find the next instance of the term, then scroll to and select it. If it + * doesn't exist, do nothing. + * @param term Tne search term. + * @return Whether a result was found. + */ + Terminal.prototype.findNext = function(term: string): boolean { + if (!this._searchHelper) { + this.searchHelper = new SearchHelper(this, Terminal.translateBufferLineToString); + } + return (this.searchHelper).findNext(term); + }; + + /** + * Find the previous instance of the term, then scroll to and select it. If it + * doesn't exist, do nothing. + * @param term Tne search term. + * @return Whether a result was found. + */ + Terminal.prototype.findPrevious = function(term: string): boolean { + if (!this._searchHelper) { + this.searchHelper = new SearchHelper(this, Terminal.translateBufferLineToString); + } + return (this.searchHelper).findPrevious(term); + }; +}); diff --git a/src/addons/search/tsconfig.json b/src/addons/search/tsconfig.json new file mode 100644 index 00000000..7f72e47f --- /dev/null +++ b/src/addons/search/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "rootDir": ".", + "outDir": "../../../lib/addons/search/", + "sourceMap": true, + "removeComments": true + } +} diff --git a/src/utils/BufferLine.ts b/src/utils/BufferLine.ts new file mode 100644 index 00000000..3dc6525a --- /dev/null +++ b/src/utils/BufferLine.ts @@ -0,0 +1,55 @@ +/** + * @license MIT + */ + +// TODO: This module should be merged into a buffer or buffer line class + +const LINE_DATA_CHAR_INDEX = 1; +const LINE_DATA_WIDTH_INDEX = 2; + +/** + * Translates a buffer line to a string, with optional start and end columns. + * Wide characters will count as two columns in the resulting string. This + * function is useful for getting the actual text underneath the raw selection + * position. + * @param line The line being translated. + * @param trimRight Whether to trim whitespace to the right. + * @param startCol The column to start at. + * @param endCol The column to end at. + */ +export function translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string { + // Get full line + let lineString = ''; + let widthAdjustedStartCol = startCol; + let widthAdjustedEndCol = endCol; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + lineString += char[LINE_DATA_CHAR_INDEX]; + // Adjust start and end cols for wide characters if they affect their + // column indexes + if (char[LINE_DATA_WIDTH_INDEX] === 0) { + if (startCol >= i) { + widthAdjustedStartCol--; + } + if (endCol >= i) { + widthAdjustedEndCol--; + } + } + } + + // Calculate the final end col by trimming whitespace on the right of the + // line if needed. + let finalEndCol = widthAdjustedEndCol || line.length; + if (trimRight) { + const rightWhitespaceIndex = lineString.search(/\s+$/); + if (rightWhitespaceIndex !== -1) { + finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex); + } + // Return the empty string if only trimmed whitespace is selected + if (finalEndCol <= widthAdjustedStartCol) { + return ''; + } + } + + return lineString.substring(widthAdjustedStartCol, finalEndCol); +} diff --git a/src/xterm.js b/src/xterm.js index cf777477..44b5c797 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -26,6 +26,7 @@ import * as Browser from './utils/Browser'; import * as Mouse from './utils/Mouse'; import { CHARSETS } from './Charsets'; import { getRawByteCoords } from './utils/Mouse'; +import { translateBufferLineToString } from './utils/BufferLine'; /** * Terminal Emulation References: @@ -2453,6 +2454,7 @@ function keys(obj) { * Expose */ +Terminal.translateBufferLineToString = translateBufferLineToString; Terminal.EventEmitter = EventEmitter; Terminal.inherits = inherits;