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 @@ +
+ + +
+
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 (