From a8174398f593d7cc3360f203c140e724ec9e6a21 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Thu, 28 Sep 2017 17:23:45 -0400 Subject: [PATCH] Calculate mouse coordinates using correct char dimensions Previously the Mouse class was using raw CSS pixels, we need CSS pixels based on the amount of space the cells take up on the canvas See Microsoft/vscode#35114 --- src/Interfaces.ts | 6 +++ src/SelectionManager.ts | 6 +-- src/Terminal.ts | 10 ++-- src/input/MouseZoneManager.ts | 3 +- src/renderer/Interfaces.ts | 2 + src/renderer/Renderer.ts | 13 +++++- src/utils/Mouse.test.ts | 63 ------------------------- src/utils/Mouse.ts | 83 --------------------------------- src/utils/MouseHelper.test.ts | 70 ++++++++++++++++++++++++++++ src/utils/MouseHelper.ts | 86 +++++++++++++++++++++++++++++++++++ src/utils/TestUtils.test.ts | 3 +- 11 files changed, 188 insertions(+), 157 deletions(-) delete mode 100644 src/utils/Mouse.test.ts delete mode 100644 src/utils/Mouse.ts create mode 100644 src/utils/MouseHelper.test.ts create mode 100644 src/utils/MouseHelper.ts diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 513deede1..2c77e8d3f 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -47,6 +47,7 @@ export interface ITerminal extends ILinkifierAccessor, IBufferAccessor, IElement options: ITerminalOptions; buffers: IBufferSet; isFocused: boolean; + mouseHelper: IMouseHelper; /** * Emit the 'data' event and populate the given data. @@ -174,6 +175,11 @@ export interface IBufferSet { activateAltBuffer(): void; } +export interface IMouseHelper { + getCoords(event: {pageX: number, pageY: number}, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number, isSelection?: boolean): [number, number]; + getRawByteCoords(event: MouseEvent, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number): { x: number, y: number }; +} + export interface IViewport { syncScrollArea(): void; onWheel(ev: WheelEvent): void; diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 28ed15227..23fe729d2 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -3,7 +3,7 @@ * @license MIT */ -import * as Mouse from './utils/Mouse'; +import { MouseHelper } from './utils/MouseHelper'; import * as Browser from './utils/Browser'; import { CharMeasure } from './utils/CharMeasure'; import { CircularList } from './utils/CircularList'; @@ -273,7 +273,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param event The mouse event. */ private _getMouseBufferCoords(event: MouseEvent): [number, number] { - const coords = Mouse.getCoords(event, this._terminal.element, this._charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows, true); + const coords = this._terminal.mouseHelper.getCoords(event, this._terminal.element, this._charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows, true); if (!coords) { return null; } @@ -292,7 +292,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param event The mouse event. */ private _getMouseEventScrollAmount(event: MouseEvent): number { - let offset = Mouse.getCoordsRelativeToElement(event, this._terminal.element)[1]; + let offset = MouseHelper.getCoordsRelativeToElement(event, this._terminal.element)[1]; const terminalHeight = this._terminal.rows * Math.ceil(this._charMeasure.height * this._terminal.options.lineHeight); if (offset >= 0 && offset <= terminalHeight) { return 0; diff --git a/src/Terminal.ts b/src/Terminal.ts index 52a9142a5..8486f2650 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -36,9 +36,8 @@ import { Linkifier } from './Linkifier'; import { SelectionManager } from './SelectionManager'; import { CharMeasure } from './utils/CharMeasure'; import * as Browser from './utils/Browser'; -import * as Mouse from './utils/Mouse'; +import { MouseHelper } from './utils/MouseHelper'; import { CHARSETS } from './Charsets'; -import { getRawByteCoords } from './utils/Mouse'; import { CustomKeyEventHandler, Charset, LinkMatcherHandler, LinkMatcherValidationCallback, CharData, LineData } from './Types'; import { ITerminal, IBrowser, ITerminalOptions, IInputHandlingTerminal, ILinkMatcherOptions, IViewport, ICompositionHelper, ITheme, ILinkifier } from './Interfaces'; import { BellSound } from './utils/Sounds'; @@ -200,6 +199,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private compositionHelper: ICompositionHelper; public charMeasure: CharMeasure; private _mouseZoneManager: IMouseZoneManager; + public mouseHelper: MouseHelper; public cols: number; public rows: number; @@ -652,6 +652,8 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT }); this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); + this.mouseHelper = new MouseHelper(this.renderer); + // Measure the character size this.charMeasure.measure(this.options); @@ -722,7 +724,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT button = getButton(ev); // get mouse coordinates - pos = getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows); + pos = self.mouseHelper.getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows); if (!pos) return; sendEvent(button, pos); @@ -748,7 +750,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT // ^[[M 3<^[[M@4<^[[M@5<^[[M@6<^[[M@7<^[[M#7< function sendMove(ev: MouseEvent): void { let button = pressed; - let pos = getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows); + let pos = self.mouseHelper.getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows); if (!pos) return; // buttons marked as motions diff --git a/src/input/MouseZoneManager.ts b/src/input/MouseZoneManager.ts index c7feed2a3..f7a1ff61f 100644 --- a/src/input/MouseZoneManager.ts +++ b/src/input/MouseZoneManager.ts @@ -5,7 +5,6 @@ import { IMouseZoneManager, IMouseZone } from './Interfaces'; import { ITerminal } from '../Interfaces'; -import { getCoords } from '../utils/Mouse'; const HOVER_DURATION = 500; @@ -144,7 +143,7 @@ export class MouseZoneManager implements IMouseZoneManager { } private _findZoneEventAt(e: MouseEvent): IMouseZone { - const coords = getCoords(e, this._terminal.element, this._terminal.charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows); + const coords = this._terminal.mouseHelper.getCoords(e, this._terminal.element, this._terminal.charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows); for (let i = 0; i < this._zones.length; i++) { const zone = this._zones[i]; if (zone.y === coords[1] && zone.x1 <= coords[0] && zone.x2 > coords[0]) { diff --git a/src/renderer/Interfaces.ts b/src/renderer/Interfaces.ts index bb9e36be1..74400b8cc 100644 --- a/src/renderer/Interfaces.ts +++ b/src/renderer/Interfaces.ts @@ -92,4 +92,6 @@ export interface IRenderDimensions { scaledCanvasHeight: number; canvasWidth: number; canvasHeight: number; + actualCellWidth: number; + actualCellHeight: number; } diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 1bdd3b4a0..168d6dc80 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -45,7 +45,9 @@ export class Renderer extends EventEmitter implements IRenderer { scaledCanvasWidth: null, scaledCanvasHeight: null, canvasWidth: null, - canvasHeight: null + canvasHeight: null, + actualCellWidth: null, + actualCellHeight: null }; this._devicePixelRatio = window.devicePixelRatio; } @@ -112,6 +114,15 @@ export class Renderer extends EventEmitter implements IRenderer { this.dimensions.canvasHeight = Math.round(this.dimensions.scaledCanvasHeight / window.devicePixelRatio); this.dimensions.canvasWidth = Math.round(this.dimensions.scaledCanvasWidth / window.devicePixelRatio); + // Get the _actual_ dimensions of an individual cell. This needs to be + // derived from the canvasWidth/Height calculated above which takes into + // account window.devicePixelRatio. CharMeasure.width/height by itself is + // insufficient when the page is not at 100% zoom level as CharMeasure is + // measured in CSS pixels, but the actual char size on the canvas can + // differ. + this.dimensions.actualCellHeight = this.dimensions.canvasHeight / this._terminal.rows; + this.dimensions.actualCellWidth = this.dimensions.canvasWidth / this._terminal.cols; + // Resize all render layers this._renderLayers.forEach(l => l.resize(this._terminal, this.dimensions, didCharSizeChange)); diff --git a/src/utils/Mouse.test.ts b/src/utils/Mouse.test.ts deleted file mode 100644 index b96dd68ac..000000000 --- a/src/utils/Mouse.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import jsdom = require('jsdom'); -import { assert } from 'chai'; -import { getCoords } from './Mouse'; -import { MockCharMeasure } from './TestUtils.test'; - -const CHAR_WIDTH = 10; -const CHAR_HEIGHT = 20; - -describe('getCoords', () => { - let dom: jsdom.JSDOM; - let window: Window; - let document: Document; - - let charMeasure: MockCharMeasure; - - beforeEach(() => { - dom = new jsdom.JSDOM(''); - window = dom.window; - document = window.document; - charMeasure = new MockCharMeasure(); - charMeasure.width = CHAR_WIDTH; - charMeasure.height = CHAR_HEIGHT; - }); - - describe('when charMeasure is not initialized', () => { - it('should return null', () => { - charMeasure = new MockCharMeasure(); - assert.equal(getCoords({ pageX: 0, pageY: 0 }, document.createElement('div'), charMeasure, 1, 10, 10), null); - }); - }); - - describe('when pageX/pageY are not supported', () => { - it('should return null', () => { - assert.equal(getCoords({ pageX: undefined, pageY: undefined }, document.createElement('div'), charMeasure, 1, 10, 10), null); - }); - }); - - it('should return the cell that was clicked', () => { - let coords: [number, number]; - coords = getCoords({ pageX: CHAR_WIDTH / 2, pageY: CHAR_HEIGHT / 2 }, document.createElement('div'), charMeasure, 1, 10, 10); - assert.deepEqual(coords, [1, 1]); - coords = getCoords({ pageX: CHAR_WIDTH, pageY: CHAR_HEIGHT }, document.createElement('div'), charMeasure, 1, 10, 10); - assert.deepEqual(coords, [1, 1]); - coords = getCoords({ pageX: CHAR_WIDTH, pageY: CHAR_HEIGHT + 1 }, document.createElement('div'), charMeasure, 1, 10, 10); - assert.deepEqual(coords, [1, 2]); - coords = getCoords({ pageX: CHAR_WIDTH + 1, pageY: CHAR_HEIGHT }, document.createElement('div'), charMeasure, 1, 10, 10); - assert.deepEqual(coords, [2, 1]); - }); - - it('should ensure the coordinates are returned within the terminal bounds', () => { - let coords: [number, number]; - coords = getCoords({ pageX: -1, pageY: -1 }, document.createElement('div'), charMeasure, 1, 10, 10); - assert.deepEqual(coords, [1, 1]); - // Event are double the cols/rows - coords = getCoords({ pageX: CHAR_WIDTH * 20, pageY: CHAR_HEIGHT * 20 }, document.createElement('div'), charMeasure, 1, 10, 10); - assert.deepEqual(coords, [10, 10], 'coordinates should never come back as larger than the terminal'); - }); -}); diff --git a/src/utils/Mouse.ts b/src/utils/Mouse.ts deleted file mode 100644 index 66e7f190a..000000000 --- a/src/utils/Mouse.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { ICharMeasure } from '../Interfaces'; - -export function getCoordsRelativeToElement(event: {pageX: number, pageY: number}, element: HTMLElement): [number, number] { - // Ignore browsers that don't support MouseEvent.pageX - if (event.pageX == null) { - return null; - } - - let x = event.pageX; - let y = event.pageY; - - // Converts the coordinates from being relative to the document to being - // relative to the terminal. - while (element) { - x -= element.offsetLeft; - y -= element.offsetTop; - element = 'offsetParent' in element ? element.offsetParent : element.parentElement; - } - return [x, y]; -} - -/** - * Gets coordinates within the terminal for a particular mouse event. The result - * is returned as an array in the form [x, y] instead of an object as it's a - * little faster and this function is used in some low level code. - * @param event The mouse event. - * @param element The terminal's container element. - * @param charMeasure The char measure object used to determine character sizes. - * @param colCount The number of columns in the terminal. - * @param rowCount The number of rows n the terminal. - * @param isSelection Whether the request is for the selection or not. This will - * apply an offset to the x value such that the left half of the cell will - * select that cell and the right half will select the next cell. - */ -export function getCoords(event: {pageX: number, pageY: number}, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number, isSelection?: boolean): [number, number] { - // Coordinates cannot be measured if charMeasure has not been initialized - if (!charMeasure.width || !charMeasure.height) { - return null; - } - - const coords = getCoordsRelativeToElement(event, element); - if (!coords) { - return null; - } - - // Convert to cols/rows. - const flooredCharWidth = Math.floor(charMeasure.width); - coords[0] = Math.ceil((coords[0] + (isSelection ? flooredCharWidth / 2 : 0)) / flooredCharWidth); - coords[1] = Math.ceil(coords[1] / Math.ceil(charMeasure.height * lineHeight)); - - // Ensure coordinates are within the terminal viewport. - coords[0] = Math.min(Math.max(coords[0], 1), colCount); - coords[1] = Math.min(Math.max(coords[1], 1), rowCount); - - return coords; -} - -/** - * Gets coordinates within the terminal for a particular mouse event, wrapping - * them to the bounds of the terminal and adding 32 to both the x and y values - * as expected by xterm. - * @param event The mouse event. - * @param element The terminal's container element. - * @param charMeasure The char measure object used to determine character sizes. - * @param colCount The number of columns in the terminal. - * @param rowCount The number of rows in the terminal. - */ -export function getRawByteCoords(event: MouseEvent, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number): { x: number, y: number } { - const coords = getCoords(event, element, charMeasure, lineHeight, colCount, rowCount); - let x = coords[0]; - let y = coords[1]; - - // xterm sends raw bytes and starts at 32 (SP) for each. - x += 32; - y += 32; - - return { x, y }; -} diff --git a/src/utils/MouseHelper.test.ts b/src/utils/MouseHelper.test.ts new file mode 100644 index 000000000..ac3137f40 --- /dev/null +++ b/src/utils/MouseHelper.test.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { MouseHelper } from './MouseHelper'; +import { MockCharMeasure, MockRenderer } from './TestUtils.test'; + +const CHAR_WIDTH = 10; +const CHAR_HEIGHT = 20; + +describe('MouseHelper.getCoords', () => { + let dom: jsdom.JSDOM; + let window: Window; + let document: Document; + let mouseHelper: MouseHelper; + + let charMeasure: MockCharMeasure; + + beforeEach(() => { + dom = new jsdom.JSDOM(''); + window = dom.window; + document = window.document; + charMeasure = new MockCharMeasure(); + charMeasure.width = CHAR_WIDTH; + charMeasure.height = CHAR_HEIGHT; + const renderer = new MockRenderer(); + renderer.dimensions = { + actualCellWidth: CHAR_WIDTH, + actualCellHeight: CHAR_HEIGHT + }; + mouseHelper = new MouseHelper(renderer); + }); + + describe('when charMeasure is not initialized', () => { + it('should return null', () => { + charMeasure = new MockCharMeasure(); + assert.equal(mouseHelper.getCoords({ pageX: 0, pageY: 0 }, document.createElement('div'), charMeasure, 1, 10, 10), null); + }); + }); + + describe('when pageX/pageY are not supported', () => { + it('should return null', () => { + assert.equal(mouseHelper.getCoords({ pageX: undefined, pageY: undefined }, document.createElement('div'), charMeasure, 1, 10, 10), null); + }); + }); + + it('should return the cell that was clicked', () => { + let coords: [number, number]; + coords = mouseHelper.getCoords({ pageX: CHAR_WIDTH / 2, pageY: CHAR_HEIGHT / 2 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 1]); + coords = mouseHelper.getCoords({ pageX: CHAR_WIDTH, pageY: CHAR_HEIGHT }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 1]); + coords = mouseHelper.getCoords({ pageX: CHAR_WIDTH, pageY: CHAR_HEIGHT + 1 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 2]); + coords = mouseHelper.getCoords({ pageX: CHAR_WIDTH + 1, pageY: CHAR_HEIGHT }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [2, 1]); + }); + + it('should ensure the coordinates are returned within the terminal bounds', () => { + let coords: [number, number]; + coords = mouseHelper.getCoords({ pageX: -1, pageY: -1 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 1]); + // Event are double the cols/rows + coords = mouseHelper.getCoords({ pageX: CHAR_WIDTH * 20, pageY: CHAR_HEIGHT * 20 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [10, 10], 'coordinates should never come back as larger than the terminal'); + }); +}); diff --git a/src/utils/MouseHelper.ts b/src/utils/MouseHelper.ts new file mode 100644 index 000000000..75ea675cc --- /dev/null +++ b/src/utils/MouseHelper.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ICharMeasure } from '../Interfaces'; +import { IRenderer } from '../renderer/Interfaces'; + +export class MouseHelper { + constructor(private _renderer: IRenderer) {} + + public static getCoordsRelativeToElement(event: {pageX: number, pageY: number}, element: HTMLElement): [number, number] { + // Ignore browsers that don't support MouseEvent.pageX + if (event.pageX == null) { + return null; + } + + let x = event.pageX; + let y = event.pageY; + + // Converts the coordinates from being relative to the document to being + // relative to the terminal. + while (element) { + x -= element.offsetLeft; + y -= element.offsetTop; + element = 'offsetParent' in element ? element.offsetParent : element.parentElement; + } + return [x, y]; + } + + /** + * Gets coordinates within the terminal for a particular mouse event. The result + * is returned as an array in the form [x, y] instead of an object as it's a + * little faster and this function is used in some low level code. + * @param event The mouse event. + * @param element The terminal's container element. + * @param charMeasure The char measure object used to determine character sizes. + * @param colCount The number of columns in the terminal. + * @param rowCount The number of rows n the terminal. + * @param isSelection Whether the request is for the selection or not. This will + * apply an offset to the x value such that the left half of the cell will + * select that cell and the right half will select the next cell. + */ + public getCoords(event: {pageX: number, pageY: number}, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number, isSelection?: boolean): [number, number] { + // Coordinates cannot be measured if charMeasure has not been initialized + if (!charMeasure.width || !charMeasure.height) { + return null; + } + + const coords = MouseHelper.getCoordsRelativeToElement(event, element); + if (!coords) { + return null; + } + + coords[0] = Math.ceil((coords[0] + (isSelection ? this._renderer.dimensions.actualCellWidth / 2 : 0)) / this._renderer.dimensions.actualCellWidth); + coords[1] = Math.ceil(coords[1] / this._renderer.dimensions.actualCellHeight); + + // Ensure coordinates are within the terminal viewport. + coords[0] = Math.min(Math.max(coords[0], 1), colCount); + coords[1] = Math.min(Math.max(coords[1], 1), rowCount); + + return coords; + } + + /** + * Gets coordinates within the terminal for a particular mouse event, wrapping + * them to the bounds of the terminal and adding 32 to both the x and y values + * as expected by xterm. + * @param event The mouse event. + * @param element The terminal's container element. + * @param charMeasure The char measure object used to determine character sizes. + * @param colCount The number of columns in the terminal. + * @param rowCount The number of rows in the terminal. + */ + public getRawByteCoords(event: MouseEvent, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number): { x: number, y: number } { + const coords = this.getCoords(event, element, charMeasure, lineHeight, colCount, rowCount); + let x = coords[0]; + let y = coords[1]; + + // xterm sends raw bytes and starts at 32 (SP) for each. + x += 32; + y += 32; + + return { x, y }; + } +} diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index beed0aaa0..265733191 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -3,13 +3,14 @@ * @license MIT */ -import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper, ITheme, ILinkifier } from '../Interfaces'; +import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper, ITheme, ILinkifier, IMouseHelper } from '../Interfaces'; import { LineData } from '../Types'; import { Buffer } from '../Buffer'; import * as Browser from './Browser'; import { IColorSet, IRenderer, IRenderDimensions, IColorManager } from '../renderer/Interfaces'; export class MockTerminal implements ITerminal { + mouseHelper: IMouseHelper; renderer: IRenderer; linkifier: ILinkifier; isFocused: boolean;