diff --git a/src/xterm.css b/src/xterm.css index 07b82dd5..e46f02d0 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -43,7 +43,8 @@ position: relative; } -.terminal.focus { +.terminal.focus, +.terminal:focus { outline: none; } @@ -103,6 +104,29 @@ display: block; } +.terminal .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; +} + +.terminal .xterm-rows { + position: absolute; + left: 0; + top: 0; +} + +.terminal .xterm-scroll-area { + visibility: hidden; +} + +.terminal .xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + left: -9999em; +} + /* * Determine default colors for xterm.js */ @@ -2169,11 +2193,3 @@ .terminal .xterm-bg-color-255 { background-color: #eeeeee; } - -/** - * All terminal rows should have explicitly declared height, - * in order to allow child elements to adjust. - */ -.terminal .xterm-rows > div { - line-height: normal; -} diff --git a/src/xterm.js b/src/xterm.js index 01233494..e26288c5 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -219,7 +219,7 @@ } return true; - } + }; /** * Finalizes the composition, resuming regular input actions. This is called when a composition @@ -275,7 +275,7 @@ } }, 0); } - } + }; /** * Apply any changes made to the textarea after the current event chain is allowed to complete. @@ -296,7 +296,7 @@ } } }, 0); - } + }; /** * Positions the composition view on top of the cursor and the textarea just below it (so the @@ -322,8 +322,124 @@ CompositionHelper.prototype.clearTextareaPosition = function() { this.textarea.style.left = ''; this.textarea.style.top = ''; + }; + + /** + * Represents the viewport of a terminal, the visible area within the larger buffer of output. + * Logic for the virtual scroll bar is included in this object. + * @param {Terminal} terminal The Terminal object. + * @param {HTMLElement} viewportElement The DOM element acting as the viewport + * @param {HTMLElement} charMeasureElement A DOM element used to measure the character size of + * the terminal. + */ + function Viewport(terminal, viewportElement, scrollArea, charMeasureElement) { + this.terminal = terminal; + this.viewportElement = viewportElement; + this.scrollArea = scrollArea; + this.charMeasureElement = charMeasureElement; + this.currentRowHeight = 0; + this.lastRecordedBufferLength = 0; + this.lastRecordedViewportHeight = 0; + + this.terminal.on('scroll', this.syncScrollArea.bind(this)); + this.terminal.on('resize', this.syncScrollArea.bind(this)); + this.viewportElement.addEventListener('scroll', this.onScroll.bind(this)); + + this.syncScrollArea(); } + /** + * Refreshes row height, setting line-height, viewport height and scroll area height if + * necessary. + * @param {number|undefined} charSize A character size measurement bounding rect object, if it + * doesn't exist it will be created. + */ + Viewport.prototype.refresh = function(charSize) { + var size = charSize || this.charMeasureElement.getBoundingClientRect(); + if (size.height > 0) { + if (size.height !== this.currentRowHeight) { + this.currentRowHeight = size.height; + this.viewportElement.style.lineHeight = size.height + 'px'; + this.terminal.rowContainer.style.lineHeight = size.height + 'px'; + } + if (this.lastRecordedViewportHeight !== this.terminal.rows) { + this.lastRecordedViewportHeight = this.terminal.rows; + this.viewportElement.style.height = size.height * this.terminal.rows + 'px'; + } + this.scrollArea.style.height = (size.height * this.lastRecordedBufferLength) + 'px'; + } + }; + + /** + * Updates dimensions and synchronizes the scroll area if necessary. + */ + Viewport.prototype.syncScrollArea = function() { + if (this.isApplicationMode) { + this.lastRecordedBufferLength = this.terminal.rows; + this.refresh(); + return; + } + + if (this.lastRecordedBufferLength !== this.terminal.lines.length) { + this.lastRecordedBufferLength = this.terminal.lines.length; + this.refresh(); + } else { + // If size has changed, refresh viewport + var size = this.charMeasureElement.getBoundingClientRect(); + if (size.height !== this.currentRowHeight) { + this.refresh(size); + } + } + var scrollTop = this.terminal.ydisp * this.currentRowHeight; + if (this.viewportElement.scrollTop !== scrollTop) { + this.viewportElement.scrollTop = scrollTop; + } + }; + + /** + * Sets the application mode of the viewport. + * @param {boolean} isApplicationMode Sets whether the terminal is in application mode. true + * for application mode (DECKPAM) and false for normal mode (DECKPNM). + */ + Viewport.prototype.setApplicationMode = function(isApplicationMode) { + this.isApplicationMode = isApplicationMode; + this.syncScrollArea(); + }; + + /** + * Handles scroll events on the viewport, calculating the new viewport and requesting the + * terminal to scroll to it. + * @param {Event} ev The scroll event. + */ + Viewport.prototype.onScroll = function(ev) { + var newRow = Math.round(this.viewportElement.scrollTop / this.currentRowHeight); + var diff = newRow - this.terminal.ydisp; + this.terminal.scrollDisp(diff, true); + }; + + /** + * Handles mouse wheel events by adjusting the viewport's scrollTop and delegating the actual + * scrolling to `onScroll`, this event needs to be attached manually by the consumer of + * `Viewport`. + * @param {WheelEvent} ev The mouse wheel event. + */ + Viewport.prototype.onWheel = function(ev) { + if (ev.deltaY === 0) { + // Do nothing if it's not a vertical scroll event + return; + } + // Fallback to WheelEvent.DOM_DELTA_PIXEL + var multiplier = 1; + if (ev.deltaMode === WheelEvent.DOM_DELTA_LINE) { + multiplier = this.currentRowHeight; + } else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + multiplier = this.currentRowHeight * this.terminal.rows; + } + this.viewportElement.scrollTop += ev.deltaY * multiplier; + // Prevent the page from scrolling when the terminal scrolls + ev.preventDefault(); + }; + /** * States */ @@ -787,7 +903,6 @@ return row; }; - /** * Opens the terminal within an element. * @@ -841,8 +956,17 @@ this.element.classList.add('terminal'); this.element.classList.add('xterm'); this.element.classList.add('xterm-theme-' + this.theme); + + this.element.style.height this.element.setAttribute('tabindex', 0); + this.viewportElement = document.createElement('div'); + this.viewportElement.classList.add('xterm-viewport'); + this.element.appendChild(this.viewportElement); + this.viewportScrollArea = document.createElement('div'); + this.viewportScrollArea.classList.add('xterm-scroll-area'); + this.viewportElement.appendChild(this.viewportScrollArea); + /* * Create the container that will hold the lines of the terminal and then * produce the lines the lines. @@ -879,11 +1003,17 @@ this.compositionHelper = new CompositionHelper(this.textarea, this.compositionView, this); this.helperContainer.appendChild(this.compositionView); + this.charMeasureElement = document.createElement('div'); + this.charMeasureElement.classList.add('xterm-char-measure-element'); + this.charMeasureElement.innerHTML = 'W'; + this.helperContainer.appendChild(this.charMeasureElement); + for (; i < this.rows; i++) { this.insertRow(); } this.parent.appendChild(this.element); + this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasureElement); // Draw the screen. this.refresh(0, this.rows - 1); @@ -949,11 +1079,10 @@ */ Terminal.prototype.bindMouse = function() { var el = this.element, self = this, pressed = 32; - var wheelEvent = ('onmousewheel' in this.context) ? 'mousewheel' : 'DOMMouseScroll'; - // mouseup, mousedown, mousewheel + // mouseup, mousedown, wheel // left click: ^[[M 3<^[[M#3< - // mousewheel up: ^[[M`3> + // wheel up: ^[[M`3> function sendButton(ev) { var button , pos; @@ -976,7 +1105,7 @@ // button, just in case. pressed = 32; break; - case wheelEvent: + case 'wheel': // nothing. don't // interfere with // `pressed`. @@ -1137,7 +1266,7 @@ ? 64 : 65; break; - case 'mousewheel': + case 'wheel': button = ev.wheelDeltaY > 0 ? 64 : 65; @@ -1207,9 +1336,7 @@ return { x: x, y: y, - type: (ev.overrideType || ev.type) === wheelEvent - ? 'mousewheel' - : (ev.overrideType || ev.type) + type: 'wheel' }; } @@ -1250,7 +1377,7 @@ // on(self.document, 'mousemove', sendMove); //} - on(el, wheelEvent, function(ev) { + on(el, 'wheel', function(ev) { if (!self.mouseEvents) return; if (self.x10Mouse || self.vt300Mouse @@ -1259,16 +1386,12 @@ return self.cancel(ev); }); - // allow mousewheel scrolling in + // allow wheel scrolling in // the shell for example - on(el, wheelEvent, function(ev) { + on(el, 'wheel', function(ev) { if (self.mouseEvents) return; if (self.applicationKeypad) return; - if (ev.type === 'DOMMouseScroll') { - self.scrollDisp(ev.detail < 0 ? -1 : 1); - } else { - self.scrollDisp(ev.wheelDeltaY > 0 ? -1 : 1); - } + self.viewport.onWheel(ev); return self.cancel(ev); }); }; @@ -1574,13 +1697,18 @@ // this.maxRange(); this.updateRange(this.scrollTop); this.updateRange(this.scrollBottom); + + this.emit('scroll', this.ydisp); }; /** * Scroll the display of the terminal * @param {number} disp The number of lines to scroll down (negatives scroll up). + * @param {boolean} suppressScrollEvent Don't emit the scroll event as scrollDisp. This is used + * to avoid unwanted events being handled by the veiwport when the event was triggered from the + * viewport originally. */ - Terminal.prototype.scrollDisp = function(disp) { + Terminal.prototype.scrollDisp = function(disp, suppressScrollEvent) { this.ydisp += disp; if (this.ydisp > this.ybase) { @@ -1589,6 +1717,10 @@ this.ydisp = 0; } + if (!suppressScrollEvent) { + this.emit('scroll', this.ydisp); + } + this.refresh(0, this.rows - 1); }; @@ -1604,6 +1736,7 @@ if (this.ybase !== this.ydisp) { this.ydisp = this.ybase; + this.emit('scroll', this.ydisp); this.maxRange(); } @@ -1930,17 +2063,19 @@ this.tabSet(); break; - // ESC = Application Keypad (DECPAM). + // ESC = Application Keypad (DECKPAM). case '=': this.log('Serial port requested application keypad.'); this.applicationKeypad = true; + this.viewport.setApplicationMode(true); this.state = normal; break; - // ESC > Normal Keypad (DECPNM). + // ESC > Normal Keypad (DECKPNM). case '>': this.log('Switching back to normal keypad.'); this.applicationKeypad = false; + this.viewport.setApplicationMode(false); this.state = normal; break; @@ -4184,6 +4319,7 @@ case 66: this.log('Serial port requested application keypad.'); this.applicationKeypad = true; + this.viewport.setApplicationMode(true); break; case 9: // X10 Mouse // no release, no motion, no wheel, no modifiers. @@ -4382,6 +4518,7 @@ break; case 66: this.log('Switching back to normal keypad.'); + this.viewport.setApplicationMode(false); this.applicationKeypad = false; break; case 9: // X10 Mouse @@ -5251,6 +5388,7 @@ Terminal.EventEmitter = EventEmitter; Terminal.CompositionHelper = CompositionHelper; + Terminal.Viewport = Viewport; Terminal.inherits = inherits; /** diff --git a/test/composition-helper-test.js b/test/composition-helper-test.js index 7bc4ee48..ceb2b4ea 100644 --- a/test/composition-helper-test.js +++ b/test/composition-helper-test.js @@ -19,14 +19,14 @@ describe('CompositionHelper', function () { top: 0 }, textContent: '' - } + }; textarea = { value: '', style: { left: 0, top: 0 } - } + }; terminal = { element: { querySelector: function () { @@ -36,7 +36,7 @@ describe('CompositionHelper', function () { handler: function (text) { handledText += text; } - } + }; handledText = ''; compositionHelper = new Terminal.CompositionHelper(textarea, compositionView, terminal); }); diff --git a/test/viewport-test.js b/test/viewport-test.js new file mode 100644 index 00000000..186ef982 --- /dev/null +++ b/test/viewport-test.js @@ -0,0 +1,94 @@ +var assert = require('chai').assert; +var Terminal = require('../src/xterm'); + +describe('Viewport', function () { + var terminal; + var viewportElement; + var charMeasureElement; + var viewport; + + var CHARACTER_HEIGHT = 10; + + beforeEach(function () { + terminal = { + lines: [], + rows: 0, + ydisp: 0, + on: function () {}, + rowContainer: { + style: { + lineHeight: 0 + } + } + }; + viewportElement = { + addEventListener: function () {}, + style: { + height: 0, + lineHeight: 0 + } + }; + scrollAreaElement = { + style: { + height: 0 + } + }; + charMeasureElement = { + getBoundingClientRect: function () { + return { width: null, height: CHARACTER_HEIGHT }; + } + }; + viewport = new Terminal.Viewport(terminal, viewportElement, scrollAreaElement, charMeasureElement); + }); + + describe('Public API', function () { + it('should define Viewport.prototype.onWheel', function () { + assert.isDefined(Terminal.Viewport.prototype.onWheel); + }); + it('should define Viewport.prototype.setApplicationMode', function () { + assert.isDefined(Terminal.Viewport.prototype.setApplicationMode); + }); + }); + + describe('setApplicationMode', function () { + it('should restrict the scroll area to the viewport', function () { + terminal.lines.push(''); + terminal.lines.push(''); + terminal.rows = 1; + viewport.syncScrollArea(); + assert.equal(scrollAreaElement.style.height, 2 * CHARACTER_HEIGHT + 'px'); + viewport.setApplicationMode(true); + assert.equal(scrollAreaElement.style.height, CHARACTER_HEIGHT + 'px'); + viewport.setApplicationMode(false); + assert.equal(scrollAreaElement.style.height, 2 * CHARACTER_HEIGHT + 'px'); + }); + }); + + describe('refresh', function () { + it('should set the line-height of the terminal', function () { + assert.equal(viewportElement.style.lineHeight, CHARACTER_HEIGHT + 'px'); + assert.equal(terminal.rowContainer.style.lineHeight, CHARACTER_HEIGHT + 'px'); + charMeasureElement.getBoundingClientRect = function () { + return { width: null, height: 1 }; + }; + viewport.refresh(); + assert.equal(viewportElement.style.lineHeight, '1px'); + assert.equal(terminal.rowContainer.style.lineHeight, '1px'); + }); + }); + + describe('syncScrollArea', function () { + it('should sync the scroll area', function () { + terminal.lines.push(''); + terminal.rows = 1; + assert.equal(scrollAreaElement.style.height, 0 * CHARACTER_HEIGHT + 'px'); + viewport.syncScrollArea(); + assert.equal(viewportElement.style.height, 1 * CHARACTER_HEIGHT + 'px'); + assert.equal(scrollAreaElement.style.height, 1 * CHARACTER_HEIGHT + 'px'); + terminal.lines.push(''); + viewport.syncScrollArea(); + assert.equal(viewportElement.style.height, 1 * CHARACTER_HEIGHT + 'px'); + assert.equal(scrollAreaElement.style.height, 2 * CHARACTER_HEIGHT + 'px'); + }); + }); +});