diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index ae867ee1e2147..235cd3fe9e935 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -124,7 +124,7 @@ export class ListView implements IDisposable { const scrollHeight = this.getContentHeight(); this.rowsContainer.style.height = `${scrollHeight}px`; - this.scrollableElement.updateState({ scrollHeight }); + this.scrollableElement.setScrollDimensions({ scrollHeight }); return deleted.map(i => i.element); } @@ -134,8 +134,8 @@ export class ListView implements IDisposable { } get renderHeight(): number { - const scrollState = this.scrollableElement.getScrollState(); - return scrollState.height; + const scrollDimensions = this.scrollableElement.getScrollDimensions(); + return scrollDimensions.height; } element(index: number): T { @@ -164,7 +164,7 @@ export class ListView implements IDisposable { } layout(height?: number): void { - this.scrollableElement.updateState({ + this.scrollableElement.setScrollDimensions({ height: height || DOM.getContentHeight(this._domNode) }); } @@ -221,12 +221,12 @@ export class ListView implements IDisposable { } getScrollTop(): number { - const scrollState = this.scrollableElement.getScrollState(); - return scrollState.scrollTop; + const scrollPosition = this.scrollableElement.getScrollPosition(); + return scrollPosition.scrollTop; } setScrollTop(scrollTop: number): void { - this.scrollableElement.updateState({ scrollTop }); + this.scrollableElement.setScrollPosition({ scrollTop }); } get scrollTop(): number { diff --git a/src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts b/src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts index 5d326d59eb4ce..ce17af707bf2a 100644 --- a/src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts +++ b/src/vs/base/browser/ui/scrollbar/abstractScrollbar.ts @@ -13,7 +13,7 @@ import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode'; import { ScrollbarState } from 'vs/base/browser/ui/scrollbar/scrollbarState'; import { ScrollbarArrow, ScrollbarArrowOptions } from 'vs/base/browser/ui/scrollbar/scrollbarArrow'; import { ScrollbarVisibilityController } from 'vs/base/browser/ui/scrollbar/scrollbarVisibilityController'; -import { Scrollable, ScrollbarVisibility } from 'vs/base/common/scrollable'; +import { Scrollable, ScrollbarVisibility, INewScrollPosition } from 'vs/base/common/scrollable'; /** * The orthogonal distance to the slider at which dragging "resets". This implements "snapping" @@ -193,7 +193,7 @@ export abstract class AbstractScrollbar extends Widget { private _onMouseDown(e: IMouseEvent): void { let domNodePosition = DomUtils.getDomNodePagePosition(this.domNode.domNode); - this.setDesiredScrollPosition(this._scrollbarState.getDesiredScrollPositionFromOffset(this._mouseDownRelativePosition(e, domNodePosition))); + this._setDesiredScrollPositionNow(this._scrollbarState.getDesiredScrollPositionFromOffset(this._mouseDownRelativePosition(e, domNodePosition))); if (e.leftButton) { e.preventDefault(); this._sliderMouseDown(e, () => { /*nothing to do*/ }); @@ -214,13 +214,13 @@ export abstract class AbstractScrollbar extends Widget { if (Platform.isWindows && mouseOrthogonalDelta > MOUSE_DRAG_RESET_DISTANCE) { // The mouse has wondered away from the scrollbar => reset dragging - this.setDesiredScrollPosition(initialScrollbarState.getScrollPosition()); + this._setDesiredScrollPositionNow(initialScrollbarState.getScrollPosition()); return; } const mousePosition = this._sliderMousePosition(mouseMoveData); const mouseDelta = mousePosition - initialMousePosition; - this.setDesiredScrollPosition(initialScrollbarState.getDesiredScrollPositionFromDelta(mouseDelta)); + this._setDesiredScrollPositionNow(initialScrollbarState.getDesiredScrollPositionFromDelta(mouseDelta)); }, () => { this.slider.toggleClassName('active', false); @@ -232,18 +232,12 @@ export abstract class AbstractScrollbar extends Widget { this._host.onDragStart(); } - public setDesiredScrollPosition(desiredScrollPosition: number): boolean { - desiredScrollPosition = this.validateScrollPosition(desiredScrollPosition); + private _setDesiredScrollPositionNow(_desiredScrollPosition: number): void { - let oldScrollPosition = this._getScrollPosition(); - this._setScrollPosition(desiredScrollPosition); - let newScrollPosition = this._getScrollPosition(); + let desiredScrollPosition: INewScrollPosition = {}; + this.writeScrollPosition(desiredScrollPosition, _desiredScrollPosition); - if (oldScrollPosition !== newScrollPosition) { - this._onElementScrollPosition(this._getScrollPosition()); - return true; - } - return false; + this._scrollable.setScrollPositionNow(desiredScrollPosition); } // ----------------- Overwrite these @@ -255,7 +249,5 @@ export abstract class AbstractScrollbar extends Widget { protected abstract _sliderMousePosition(e: ISimplifiedMouseEvent): number; protected abstract _sliderOrthogonalMousePosition(e: ISimplifiedMouseEvent): number; - protected abstract _getScrollPosition(): number; - protected abstract _setScrollPosition(elementScrollPosition: number): void; - public abstract validateScrollPosition(desiredScrollPosition: number): number; + public abstract writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void; } diff --git a/src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts b/src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts index f6da6af6bc36c..ac973201db59a 100644 --- a/src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts +++ b/src/vs/base/browser/ui/scrollbar/horizontalScrollbar.ts @@ -8,7 +8,7 @@ import { AbstractScrollbar, ScrollbarHost, ISimplifiedMouseEvent } from 'vs/base import { StandardMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { IDomNodePagePosition } from 'vs/base/browser/dom'; import { ScrollableElementResolvedOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; -import { Scrollable, ScrollEvent, ScrollbarVisibility } from 'vs/base/common/scrollable'; +import { Scrollable, ScrollEvent, ScrollbarVisibility, INewScrollPosition } from 'vs/base/common/scrollable'; import { ScrollbarState } from 'vs/base/browser/ui/scrollbar/scrollbarState'; import { ARROW_IMG_SIZE } from 'vs/base/browser/ui/scrollbar/scrollbarArrow'; @@ -89,18 +89,7 @@ export class HorizontalScrollbar extends AbstractScrollbar { return e.posy; } - protected _getScrollPosition(): number { - const scrollState = this._scrollable.getState(); - return scrollState.scrollLeft; - } - - protected _setScrollPosition(scrollPosition: number) { - this._scrollable.updateState({ - scrollLeft: scrollPosition - }); - } - - public validateScrollPosition(desiredScrollPosition: number): number { - return this._scrollable.validateScrollLeft(desiredScrollPosition); + public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void { + target.scrollLeft = scrollPosition; } } diff --git a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts index 993b7e385df8d..d45f05a670326 100644 --- a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts +++ b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts @@ -13,7 +13,7 @@ import { HorizontalScrollbar } from 'vs/base/browser/ui/scrollbar/horizontalScro import { VerticalScrollbar } from 'vs/base/browser/ui/scrollbar/verticalScrollbar'; import { ScrollableElementCreationOptions, ScrollableElementChangeOptions, ScrollableElementResolvedOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; -import { Scrollable, ScrollState, ScrollEvent, INewScrollState, ScrollbarVisibility } from 'vs/base/common/scrollable'; +import { Scrollable, ScrollEvent, ScrollbarVisibility, INewScrollDimensions, IScrollDimensions, INewScrollPosition, IScrollPosition } from 'vs/base/common/scrollable'; import { Widget } from 'vs/base/browser/ui/widget'; import { TimeoutTimer } from 'vs/base/common/async'; import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode'; @@ -22,55 +22,161 @@ import Event, { Emitter } from 'vs/base/common/event'; const HIDE_TIMEOUT = 500; const SCROLL_WHEEL_SENSITIVITY = 50; +const SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED = true; export interface IOverviewRulerLayoutInfo { parent: HTMLElement; insertBefore: HTMLElement; } -export class ScrollableElement extends Widget { +class MouseWheelClassifierItem { + public timestamp: number; + public deltaX: number; + public deltaY: number; + public score: number; + + constructor(timestamp: number, deltaX: number, deltaY: number) { + this.timestamp = timestamp; + this.deltaX = deltaX; + this.deltaY = deltaY; + this.score = 0; + } +} + +export class MouseWheelClassifier { + + public static INSTANCE = new MouseWheelClassifier(); + + private readonly _capacity: number; + private _memory: MouseWheelClassifierItem[]; + private _front: number; + private _rear: number; + + constructor() { + this._capacity = 5; + this._memory = []; + this._front = -1; + this._rear = -1; + } + + public isPhysicalMouseWheel(): boolean { + if (this._front === -1 && this._rear === -1) { + // no elements + return false; + } + + // 0.5 * last + 0.25 * before last + 0.125 * before before last + ... + let remainingInfluence = 1; + let score = 0; + let iteration = 1; + + let index = this._rear; + do { + const influence = (index === this._front ? remainingInfluence : Math.pow(2, -iteration)); + remainingInfluence -= influence; + score += this._memory[index].score * influence; + + if (index === this._front) { + break; + } + + index = (this._capacity + index - 1) % this._capacity; + iteration++; + } while (true); + + return (score <= 0.5); + } + + public accept(timestamp: number, deltaX: number, deltaY: number): void { + const item = new MouseWheelClassifierItem(timestamp, deltaX, deltaY); + item.score = this._computeScore(item); + + if (this._front === -1 && this._rear === -1) { + this._memory[0] = item; + this._front = 0; + this._rear = 0; + } else { + this._rear = (this._rear + 1) % this._capacity; + if (this._rear === this._front) { + // Drop oldest + this._front = (this._front + 1) % this._capacity; + } + this._memory[this._rear] = item; + } + } + + /** + * A score between 0 and 1 for `item`. + * - a score towards 0 indicates that the source appears to be a physical mouse wheel + * - a score towards 1 indicates that the source appears to be a touchpad or magic mouse, etc. + */ + private _computeScore(item: MouseWheelClassifierItem): number { + + if (Math.abs(item.deltaX) > 0 && Math.abs(item.deltaY) > 0) { + // both axes exercised => definitely not a physical mouse wheel + return 1; + } + + let score: number = 0.5; + const prev = (this._front === -1 && this._rear === -1 ? null : this._memory[this._rear]); + if (prev) { + // const deltaT = item.timestamp - prev.timestamp; + // if (deltaT < 1000 / 30) { + // // sooner than X times per second => indicator that this is not a physical mouse wheel + // score += 0.25; + // } + + // if (item.deltaX === prev.deltaX && item.deltaY === prev.deltaY) { + // // equal amplitude => indicator that this is a physical mouse wheel + // score -= 0.25; + // } + } + + if (Math.abs(item.deltaX - Math.round(item.deltaX)) > 0 || Math.abs(item.deltaY - Math.round(item.deltaY)) > 0) { + // non-integer deltas => indicator that this is not a physical mouse wheel + score += 0.25; + } + + return Math.min(Math.max(score, 0), 1); + } +} - private _options: ScrollableElementResolvedOptions; - private _scrollable: Scrollable; - private _verticalScrollbar: VerticalScrollbar; - private _horizontalScrollbar: HorizontalScrollbar; - private _domNode: HTMLElement; +export abstract class AbstractScrollableElement extends Widget { - private _leftShadowDomNode: FastDomNode; - private _topShadowDomNode: FastDomNode; - private _topLeftShadowDomNode: FastDomNode; + private readonly _options: ScrollableElementResolvedOptions; + protected readonly _scrollable: Scrollable; + private readonly _verticalScrollbar: VerticalScrollbar; + private readonly _horizontalScrollbar: HorizontalScrollbar; + private readonly _domNode: HTMLElement; - private _listenOnDomNode: HTMLElement; + private readonly _leftShadowDomNode: FastDomNode; + private readonly _topShadowDomNode: FastDomNode; + private readonly _topLeftShadowDomNode: FastDomNode; + + private readonly _listenOnDomNode: HTMLElement; private _mouseWheelToDispose: IDisposable[]; private _isDragging: boolean; private _mouseIsOver: boolean; - private _hideTimeout: TimeoutTimer; + private readonly _hideTimeout: TimeoutTimer; private _shouldRender: boolean; - private _onScroll = this._register(new Emitter()); + private readonly _onScroll = this._register(new Emitter()); public onScroll: Event = this._onScroll.event; - constructor(element: HTMLElement, options: ScrollableElementCreationOptions, scrollable?: Scrollable) { + protected constructor(element: HTMLElement, options: ScrollableElementCreationOptions, scrollable?: Scrollable) { super(); element.style.overflow = 'hidden'; this._options = resolveOptions(options); - - if (typeof scrollable === 'undefined') { - this._scrollable = this._register(new Scrollable()); - } else { - this._scrollable = scrollable; - } + this._scrollable = scrollable; this._register(this._scrollable.onScroll((e) => { this._onDidScroll(e); this._onScroll.fire(e); })); - // this._scrollable = this._register(new DelegateScrollable(scrollable, () => this._onScroll())); - let scrollbarHost: ScrollbarHost = { onMouseWheel: (mouseWheelEvent: StandardMouseWheelEvent) => this._onMouseWheel(mouseWheelEvent), onDragStart: () => this._onDragStart(), @@ -152,12 +258,12 @@ export class ScrollableElement extends Widget { this._verticalScrollbar.delegateSliderMouseDown(e, onDragFinished); } - public updateState(newState: INewScrollState): void { - this._scrollable.updateState(newState); + public getScrollDimensions(): IScrollDimensions { + return this._scrollable.getScrollDimensions(); } - public getScrollState(): ScrollState { - return this._scrollable.getState(); + public setScrollDimensions(dimensions: INewScrollDimensions): void { + this._scrollable.setScrollDimensions(dimensions); } /** @@ -214,8 +320,13 @@ export class ScrollableElement extends Widget { } private _onMouseWheel(e: StandardMouseWheelEvent): void { - let desiredScrollTop = -1; - let desiredScrollLeft = -1; + + const classifier = MouseWheelClassifier.INSTANCE; + if (SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED) { + classifier.accept(Date.now(), e.deltaX, e.deltaY); + } + + // console.log(`${Date.now()}, ${e.deltaY}, ${e.deltaX}`); if (e.deltaY || e.deltaX) { let deltaY = e.deltaY * this._options.mouseWheelScrollSensitivity; @@ -243,31 +354,35 @@ export class ScrollableElement extends Widget { } } - const scrollState = this._scrollable.getState(); + const futureScrollPosition = this._scrollable.getFutureScrollPosition(); + + let desiredScrollPosition: INewScrollPosition = {}; if (deltaY) { - let currentScrollTop = scrollState.scrollTop; - desiredScrollTop = this._verticalScrollbar.validateScrollPosition((desiredScrollTop !== -1 ? desiredScrollTop : currentScrollTop) - SCROLL_WHEEL_SENSITIVITY * deltaY); - if (desiredScrollTop === currentScrollTop) { - desiredScrollTop = -1; - } + const desiredScrollTop = futureScrollPosition.scrollTop - SCROLL_WHEEL_SENSITIVITY * deltaY; + this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop); } if (deltaX) { - let currentScrollLeft = scrollState.scrollLeft; - desiredScrollLeft = this._horizontalScrollbar.validateScrollPosition((desiredScrollLeft !== -1 ? desiredScrollLeft : currentScrollLeft) - SCROLL_WHEEL_SENSITIVITY * deltaX); - if (desiredScrollLeft === currentScrollLeft) { - desiredScrollLeft = -1; - } + const desiredScrollLeft = futureScrollPosition.scrollLeft - SCROLL_WHEEL_SENSITIVITY * deltaX; + this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft); } - if (desiredScrollTop !== -1 || desiredScrollLeft !== -1) { - if (desiredScrollTop !== -1) { - this._shouldRender = this._verticalScrollbar.setDesiredScrollPosition(desiredScrollTop) || this._shouldRender; - desiredScrollTop = -1; - } - if (desiredScrollLeft !== -1) { - this._shouldRender = this._horizontalScrollbar.setDesiredScrollPosition(desiredScrollLeft) || this._shouldRender; - desiredScrollLeft = -1; + // Check that we are scrolling towards a location which is valid + desiredScrollPosition = this._scrollable.validateScrollPosition(desiredScrollPosition); + + if (futureScrollPosition.scrollLeft !== desiredScrollPosition.scrollLeft || futureScrollPosition.scrollTop !== desiredScrollPosition.scrollTop) { + + const canPerformSmoothScroll = ( + SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED + && this._options.mouseWheelSmoothScroll + && classifier.isPhysicalMouseWheel() + ); + + if (canPerformSmoothScroll) { + this._scrollable.setScrollPositionSmooth(desiredScrollPosition); + } else { + this._scrollable.setScrollPositionNow(desiredScrollPosition); } + this._shouldRender = true; } } @@ -315,7 +430,7 @@ export class ScrollableElement extends Widget { this._verticalScrollbar.render(); if (this._options.useShadows) { - const scrollState = this._scrollable.getState(); + const scrollState = this._scrollable.getCurrentScrollPosition(); let enableTop = scrollState.scrollTop > 0; let enableLeft = scrollState.scrollLeft > 0; @@ -367,6 +482,33 @@ export class ScrollableElement extends Widget { } } +export class ScrollableElement extends AbstractScrollableElement { + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions) { + options = options || {}; + options.mouseWheelSmoothScroll = false; + const scrollable = new Scrollable(0, (callback) => DomUtils.scheduleAtNextAnimationFrame(callback)); + super(element, options, scrollable); + this._register(scrollable); + } + + public setScrollPosition(update: INewScrollPosition): void { + this._scrollable.setScrollPositionNow(update); + } + + public getScrollPosition(): IScrollPosition { + return this._scrollable.getCurrentScrollPosition(); + } +} + +export class SmoothScrollableElement extends AbstractScrollableElement { + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions, scrollable: Scrollable) { + super(element, options, scrollable); + } + +} + export class DomScrollableElement extends ScrollableElement { private _element: HTMLElement; @@ -387,13 +529,14 @@ export class DomScrollableElement extends ScrollableElement { public scanDomNode(): void { // widh, scrollLeft, scrollWidth, height, scrollTop, scrollHeight - this.updateState({ + this.setScrollDimensions({ width: this._element.clientWidth, scrollWidth: this._element.scrollWidth, - scrollLeft: this._element.scrollLeft, - height: this._element.clientHeight, - scrollHeight: this._element.scrollHeight, + scrollHeight: this._element.scrollHeight + }); + this.setScrollPosition({ + scrollLeft: this._element.scrollLeft, scrollTop: this._element.scrollTop, }); } @@ -409,6 +552,7 @@ function resolveOptions(opts: ScrollableElementCreationOptions): ScrollableEleme alwaysConsumeMouseWheel: (typeof opts.alwaysConsumeMouseWheel !== 'undefined' ? opts.alwaysConsumeMouseWheel : false), scrollYToX: (typeof opts.scrollYToX !== 'undefined' ? opts.scrollYToX : false), mouseWheelScrollSensitivity: (typeof opts.mouseWheelScrollSensitivity !== 'undefined' ? opts.mouseWheelScrollSensitivity : 1), + mouseWheelSmoothScroll: (typeof opts.mouseWheelSmoothScroll !== 'undefined' ? opts.mouseWheelSmoothScroll : true), arrowSize: (typeof opts.arrowSize !== 'undefined' ? opts.arrowSize : 11), listenOnDomNode: (typeof opts.listenOnDomNode !== 'undefined' ? opts.listenOnDomNode : null), diff --git a/src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts b/src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts index 5485e4eb84b5a..069a6ea6860da 100644 --- a/src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts +++ b/src/vs/base/browser/ui/scrollbar/scrollableElementOptions.ts @@ -26,6 +26,11 @@ export interface ScrollableElementCreationOptions { * Defaults to true */ handleMouseWheel?: boolean; + /** + * If mouse wheel is handled, make mouse wheel scrolling smooth. + * Defaults to true. + */ + mouseWheelSmoothScroll?: boolean; /** * Flip axes. Treat vertical scrolling like horizontal and vice-versa. * Defaults to false. @@ -114,6 +119,7 @@ export interface ScrollableElementResolvedOptions { scrollYToX: boolean; alwaysConsumeMouseWheel: boolean; mouseWheelScrollSensitivity: number; + mouseWheelSmoothScroll: boolean; arrowSize: number; listenOnDomNode: HTMLElement; horizontal: ScrollbarVisibility; diff --git a/src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts b/src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts index 373940f95b635..98d74206690b6 100644 --- a/src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts +++ b/src/vs/base/browser/ui/scrollbar/verticalScrollbar.ts @@ -8,7 +8,7 @@ import { AbstractScrollbar, ScrollbarHost, ISimplifiedMouseEvent } from 'vs/base import { StandardMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { IDomNodePagePosition } from 'vs/base/browser/dom'; import { ScrollableElementResolvedOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; -import { Scrollable, ScrollEvent, ScrollbarVisibility } from 'vs/base/common/scrollable'; +import { Scrollable, ScrollEvent, ScrollbarVisibility, INewScrollPosition } from 'vs/base/common/scrollable'; import { ScrollbarState } from 'vs/base/browser/ui/scrollbar/scrollbarState'; import { ARROW_IMG_SIZE } from 'vs/base/browser/ui/scrollbar/scrollbarArrow'; @@ -90,18 +90,7 @@ export class VerticalScrollbar extends AbstractScrollbar { return e.posx; } - protected _getScrollPosition(): number { - const scrollState = this._scrollable.getState(); - return scrollState.scrollTop; - } - - protected _setScrollPosition(scrollPosition: number): void { - this._scrollable.updateState({ - scrollTop: scrollPosition - }); - } - - public validateScrollPosition(desiredScrollPosition: number): number { - return this._scrollable.validateScrollTop(desiredScrollPosition); + public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void { + target.scrollTop = scrollPosition; } } diff --git a/src/vs/base/common/scrollable.ts b/src/vs/base/common/scrollable.ts index 31d821d4ed8ff..c9f3dc2fb6288 100644 --- a/src/vs/base/common/scrollable.ts +++ b/src/vs/base/common/scrollable.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import Event, { Emitter } from 'vs/base/common/event'; export enum ScrollbarVisibility { @@ -31,7 +31,7 @@ export interface ScrollEvent { scrollTopChanged: boolean; } -export class ScrollState { +export class ScrollState implements IScrollDimensions, IScrollPosition { _scrollStateBrand: void; public readonly width: number; @@ -95,6 +95,28 @@ export class ScrollState { ); } + public withScrollDimensions(update: INewScrollDimensions): ScrollState { + return new ScrollState( + (typeof update.width !== 'undefined' ? update.width : this.width), + (typeof update.scrollWidth !== 'undefined' ? update.scrollWidth : this.scrollWidth), + this.scrollLeft, + (typeof update.height !== 'undefined' ? update.height : this.height), + (typeof update.scrollHeight !== 'undefined' ? update.scrollHeight : this.scrollHeight), + this.scrollTop + ); + } + + public withScrollPosition(update: INewScrollPosition): ScrollState { + return new ScrollState( + this.width, + this.scrollWidth, + (typeof update.scrollLeft !== 'undefined' ? update.scrollLeft : this.scrollLeft), + this.height, + this.scrollHeight, + (typeof update.scrollTop !== 'undefined' ? update.scrollTop : this.scrollTop) + ); + } + public createScrollEvent(previous: ScrollState): ScrollEvent { let widthChanged = (this.width !== previous.width); let scrollWidthChanged = (this.scrollWidth !== previous.scrollWidth); @@ -125,13 +147,25 @@ export class ScrollState { } -export interface INewScrollState { +export interface IScrollDimensions { + readonly width: number; + readonly scrollWidth: number; + readonly height: number; + readonly scrollHeight: number; +} +export interface INewScrollDimensions { width?: number; scrollWidth?: number; - scrollLeft?: number; - height?: number; scrollHeight?: number; +} + +export interface IScrollPosition { + readonly scrollLeft: number; + readonly scrollTop: number; +} +export interface INewScrollPosition { + scrollLeft?: number; scrollTop?: number; } @@ -139,52 +173,220 @@ export class Scrollable extends Disposable { _scrollableBrand: void; + private _smoothScrollDuration: number; + private readonly _scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable; private _state: ScrollState; + private _smoothScrolling: SmoothScrollingOperation; private _onScroll = this._register(new Emitter()); public onScroll: Event = this._onScroll.event; - constructor() { + constructor(smoothScrollDuration: number, scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable) { super(); + this._smoothScrollDuration = smoothScrollDuration; + this._scheduleAtNextAnimationFrame = scheduleAtNextAnimationFrame; this._state = new ScrollState(0, 0, 0, 0, 0, 0); + this._smoothScrolling = null; + } + + public dispose(): void { + if (this._smoothScrolling) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + } + super.dispose(); } - public getState(): ScrollState { + public setSmoothScrollDuration(smoothScrollDuration: number): void { + this._smoothScrollDuration = smoothScrollDuration; + } + + public validateScrollPosition(scrollPosition: INewScrollPosition): IScrollPosition { + return this._state.withScrollPosition(scrollPosition); + } + + public getScrollDimensions(): IScrollDimensions { return this._state; } - public validateScrollTop(desiredScrollTop: number): number { - desiredScrollTop = Math.round(desiredScrollTop); - desiredScrollTop = Math.max(desiredScrollTop, 0); - desiredScrollTop = Math.min(desiredScrollTop, this._state.scrollHeight - this._state.height); - return desiredScrollTop; + public setScrollDimensions(dimensions: INewScrollDimensions): void { + const newState = this._state.withScrollDimensions(dimensions); + this._setState(newState); + + // Validate outstanding animated scroll position target + if (this._smoothScrolling) { + this._smoothScrolling.acceptScrollDimensions(this._state); + } } - public validateScrollLeft(desiredScrollLeft: number): number { - desiredScrollLeft = Math.round(desiredScrollLeft); - desiredScrollLeft = Math.max(desiredScrollLeft, 0); - desiredScrollLeft = Math.min(desiredScrollLeft, this._state.scrollWidth - this._state.width); - return desiredScrollLeft; + /** + * Returns the final scroll position that the instance will have once the smooth scroll animation concludes. + * If no scroll animation is occuring, it will return the current scroll position instead. + */ + public getFutureScrollPosition(): IScrollPosition { + if (this._smoothScrolling) { + return this._smoothScrolling.to; + } + return this._state; } - public updateState(update: INewScrollState): void { - const oldState = this._state; - const newState = new ScrollState( - (typeof update.width !== 'undefined' ? update.width : oldState.width), - (typeof update.scrollWidth !== 'undefined' ? update.scrollWidth : oldState.scrollWidth), - (typeof update.scrollLeft !== 'undefined' ? update.scrollLeft : oldState.scrollLeft), - (typeof update.height !== 'undefined' ? update.height : oldState.height), - (typeof update.scrollHeight !== 'undefined' ? update.scrollHeight : oldState.scrollHeight), - (typeof update.scrollTop !== 'undefined' ? update.scrollTop : oldState.scrollTop) - ); + /** + * Returns the current scroll position. + * Note: This result might be an intermediate scroll position, as there might be an ongoing smooth scroll animation. + */ + public getCurrentScrollPosition(): IScrollPosition { + return this._state; + } + + public setScrollPositionNow(update: INewScrollPosition): void { + // no smooth scrolling requested + const newState = this._state.withScrollPosition(update); + + // Terminate any outstanding smooth scrolling + if (this._smoothScrolling) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + } + + this._setState(newState); + } + + public setScrollPositionSmooth(update: INewScrollPosition): void { + if (this._smoothScrollDuration === 0) { + // Smooth scrolling not supported. + return this.setScrollPositionNow(update); + } + + if (this._smoothScrolling) { + // Combine our pending scrollLeft/scrollTop with incoming scrollLeft/scrollTop + update = { + scrollLeft: (typeof update.scrollLeft === 'undefined' ? this._smoothScrolling.to.scrollLeft : update.scrollLeft), + scrollTop: (typeof update.scrollTop === 'undefined' ? this._smoothScrolling.to.scrollTop : update.scrollTop) + }; + + // Validate `update` + const validTarget = this._state.withScrollPosition(update); + + if (this._smoothScrolling.to.scrollLeft === validTarget.scrollLeft && this._smoothScrolling.to.scrollTop === validTarget.scrollTop) { + // No need to interrupt or extend the current animation since we're going to the same place + return; + } + + const newSmoothScrolling = this._smoothScrolling.combine(this._state, validTarget, this._smoothScrollDuration); + this._smoothScrolling.dispose(); + this._smoothScrolling = newSmoothScrolling; + } else { + // Validate `update` + const validTarget = this._state.withScrollPosition(update); + + this._smoothScrolling = SmoothScrollingOperation.start(this._state, validTarget, this._smoothScrollDuration); + } + + // Begin smooth scrolling animation + this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => { + this._smoothScrolling.animationFrameDisposable = null; + this._performSmoothScrolling(); + }); + } + + private _performSmoothScrolling(): void { + const update = this._smoothScrolling.tick(); + const newState = this._state.withScrollPosition(update); + + this._setState(newState); + if (update.isDone) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + return; + } + + // Continue smooth scrolling animation + this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => { + this._smoothScrolling.animationFrameDisposable = null; + this._performSmoothScrolling(); + }); + } + + private _setState(newState: ScrollState): void { + const oldState = this._state; if (oldState.equals(newState)) { // no change return; } - this._state = newState; this._onScroll.fire(this._state.createScrollEvent(oldState)); } } + +class SmoothScrollingUpdate implements IScrollPosition { + + public readonly scrollLeft: number; + public readonly scrollTop: number; + public readonly isDone: boolean; + + constructor(scrollLeft: number, scrollTop: number, isDone: boolean) { + this.scrollLeft = scrollLeft; + this.scrollTop = scrollTop; + this.isDone = isDone; + } + +} + +class SmoothScrollingOperation { + + public readonly from: IScrollPosition; + public to: IScrollPosition; + public readonly duration: number; + private readonly _startTime: number; + public animationFrameDisposable: IDisposable; + + private constructor(from: IScrollPosition, to: IScrollPosition, startTime: number, duration: number) { + this.from = from; + this.to = to; + this.duration = duration; + this._startTime = startTime; + this.animationFrameDisposable = null; + } + + public dispose(): void { + if (this.animationFrameDisposable !== null) { + this.animationFrameDisposable.dispose(); + this.animationFrameDisposable = null; + } + } + + public acceptScrollDimensions(state: ScrollState): void { + this.to = state.withScrollPosition(this.to); + } + + public tick(): SmoothScrollingUpdate { + const completion = (Date.now() - this._startTime) / this.duration; + + if (completion < 1) { + const t = easeOutCubic(completion); + const newScrollLeft = this.from.scrollLeft + (this.to.scrollLeft - this.from.scrollLeft) * t; + const newScrollTop = this.from.scrollTop + (this.to.scrollTop - this.from.scrollTop) * t; + return new SmoothScrollingUpdate(newScrollLeft, newScrollTop, false); + } + + return new SmoothScrollingUpdate(this.to.scrollLeft, this.to.scrollTop, true); + } + + public combine(from: IScrollPosition, to: IScrollPosition, duration: number): SmoothScrollingOperation { + return SmoothScrollingOperation.start(from, to, duration); + } + + public static start(from: IScrollPosition, to: IScrollPosition, duration: number): SmoothScrollingOperation { + return new SmoothScrollingOperation(from, to, Date.now(), duration); + } +} + +function easeInCubic(t) { + return Math.pow(t, 3); +} + +function easeOutCubic(t) { + return 1 - easeInCubic(1 - t); +} diff --git a/src/vs/base/parts/tree/browser/treeView.ts b/src/vs/base/parts/tree/browser/treeView.ts index 38a82069f3e13..ac5ba32ad5c7c 100644 --- a/src/vs/base/parts/tree/browser/treeView.ts +++ b/src/vs/base/parts/tree/browser/treeView.ts @@ -847,27 +847,29 @@ export class TreeView extends HeightMap { } public get viewHeight() { - const scrollState = this.scrollableElement.getScrollState(); - return scrollState.height; + const scrollDimensions = this.scrollableElement.getScrollDimensions(); + return scrollDimensions.height; } public set viewHeight(viewHeight: number) { - this.scrollableElement.updateState({ + this.scrollableElement.setScrollDimensions({ height: viewHeight, scrollHeight: this.getTotalHeight() }); } public get scrollTop(): number { - const scrollState = this.scrollableElement.getScrollState(); - return scrollState.scrollTop; + const scrollPosition = this.scrollableElement.getScrollPosition(); + return scrollPosition.scrollTop; } public set scrollTop(scrollTop: number) { - this.scrollableElement.updateState({ - scrollTop: scrollTop, + this.scrollableElement.setScrollDimensions({ scrollHeight: this.getTotalHeight() }); + this.scrollableElement.setScrollPosition({ + scrollTop: scrollTop + }); } public getScrollPosition(): number { diff --git a/src/vs/base/test/browser/ui/scrollbar/scrollableElement.test.ts b/src/vs/base/test/browser/ui/scrollbar/scrollableElement.test.ts new file mode 100644 index 0000000000000..046f8cc3c1014 --- /dev/null +++ b/src/vs/base/test/browser/ui/scrollbar/scrollableElement.test.ts @@ -0,0 +1,525 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { MouseWheelClassifier } from "vs/base/browser/ui/scrollbar/scrollableElement"; +import * as assert from 'assert'; + +export type IMouseWheelEvent = [number, number, number]; + +suite('MouseWheelClassifier', () => { + + test('OSX - Apple Magic Mouse', () => { + const testData: IMouseWheelEvent[] = [ + [1503409622410, -0.025, 0], + [1503409622435, -0.175, 0], + [1503409622446, -0.225, 0], + [1503409622489, -0.65, 0], + [1503409622514, -1.225, 0], + [1503409622537, -1.025, 0], + [1503409622543, -0.55, 0], + [1503409622587, -0.75, 0], + [1503409622623, -1.45, 0], + [1503409622641, -1.325, 0], + [1503409622663, -0.6, 0], + [1503409622681, -1.125, 0], + [1503409622703, -0.5166666666666667, 0], + [1503409622721, -0.475, 0], + [1503409622822, -0.425, 0], + [1503409622871, -1.9916666666666667, 0], + [1503409622933, -0.7, 0], + [1503409622991, -0.725, 0], + [1503409623032, -0.45, 0], + [1503409623083, -0.25, 0], + [1503409623122, -0.4, 0], + [1503409623176, -0.2, 0], + [1503409623197, -0.225, 0], + [1503409623219, -0.05, 0], + [1503409623249, -0.1, 0], + [1503409623278, -0.1, 0], + [1503409623292, -0.025, 0], + [1503409623315, -0.025, 0], + [1503409623324, -0.05, 0], + [1503409623356, -0.025, 0], + [1503409623415, -0.025, 0], + [1503409623443, -0.05, 0], + [1503409623452, -0.025, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, false); + } + }); + + test('OSX - Apple Touch Pad', () => { + const testData: IMouseWheelEvent[] = [ + [1503409780792, 0.025, 0], + [1503409780808, 0.175, -0.025], + [1503409780811, 0.35, -0.05], + [1503409780816, 0.55, -0.075], + [1503409780836, 0.825, -0.1], + [1503409780840, 0.725, -0.075], + [1503409780842, 1.5, -0.125], + [1503409780848, 1.1, -0.1], + [1503409780877, 2.05, -0.1], + [1503409780882, 3.9, 0], + [1503409780908, 3.825, 0], + [1503409780915, 3.65, 0], + [1503409780940, 3.45, 0], + [1503409780949, 3.25, 0], + [1503409780979, 3.075, 0], + [1503409780982, 2.9, 0], + [1503409781016, 2.75, 0], + [1503409781018, 2.625, 0], + [1503409781051, 2.5, 0], + [1503409781071, 2.4, 0], + [1503409781089, 2.3, 0], + [1503409781111, 2.175, 0], + [1503409781140, 3.975, 0], + [1503409781165, 1.8, 0], + [1503409781183, 3.3, 0], + [1503409781202, 1.475, 0], + [1503409781223, 1.375, 0], + [1503409781244, 1.275, 0], + [1503409781269, 2.25, 0], + [1503409781285, 1.025, 0], + [1503409781300, 0.925, 0], + [1503409781303, 0.875, 0], + [1503409781321, 0.8, 0], + [1503409781333, 0.725, 0], + [1503409781355, 0.65, 0], + [1503409781370, 0.6, 0], + [1503409781384, 0.55, 0], + [1503409781410, 0.5, 0], + [1503409781422, 0.475, 0], + [1503409781435, 0.425, 0], + [1503409781454, 0.4, 0], + [1503409781470, 0.35, 0], + [1503409781486, 0.325, 0], + [1503409781501, 0.3, 0], + [1503409781519, 0.275, 0], + [1503409781534, 0.25, 0], + [1503409781553, 0.225, 0], + [1503409781569, 0.2, 0], + [1503409781589, 0.2, 0], + [1503409781601, 0.175, 0], + [1503409781621, 0.15, 0], + [1503409781631, 0.15, 0], + [1503409781652, 0.125, 0], + [1503409781667, 0.125, 0], + [1503409781685, 0.125, 0], + [1503409781703, 0.1, 0], + [1503409781715, 0.1, 0], + [1503409781734, 0.1, 0], + [1503409781753, 0.075, 0], + [1503409781768, 0.075, 0], + [1503409781783, 0.075, 0], + [1503409781801, 0.075, 0], + [1503409781815, 0.05, 0], + [1503409781836, 0.05, 0], + [1503409781850, 0.05, 0], + [1503409781865, 0.05, 0], + [1503409781880, 0.05, 0], + [1503409781899, 0.025, 0], + [1503409781916, 0.025, 0], + [1503409781933, 0.025, 0], + [1503409781952, 0.025, 0], + [1503409781965, 0.025, 0], + [1503409781996, 0.025, 0], + [1503409782015, 0.025, 0], + [1503409782045, 0.025, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, false); + } + }); + + test('OSX - Razer Physical Mouse Wheel', () => { + const testData: IMouseWheelEvent[] = [ + [1503409880776, -1, 0], + [1503409880791, -1, 0], + [1503409880810, -4, 0], + [1503409880820, -5, 0], + [1503409880848, -6, 0], + [1503409880876, -7, 0], + [1503409881319, -1, 0], + [1503409881387, -1, 0], + [1503409881407, -2, 0], + [1503409881443, -4, 0], + [1503409881444, -5, 0], + [1503409881470, -6, 0], + [1503409881496, -7, 0], + [1503409881812, -1, 0], + [1503409881829, -1, 0], + [1503409881850, -4, 0], + [1503409881871, -5, 0], + [1503409881896, -13, 0], + [1503409881914, -16, 0], + [1503409882551, -1, 0], + [1503409882589, -1, 0], + [1503409882625, -2, 0], + [1503409883035, -1, 0], + [1503409883098, -1, 0], + [1503409883143, -2, 0], + [1503409883217, -2, 0], + [1503409883270, -3, 0], + [1503409883388, -3, 0], + [1503409883531, -3, 0], + [1503409884095, -1, 0], + [1503409884122, -1, 0], + [1503409884160, -3, 0], + [1503409884208, -4, 0], + [1503409884292, -4, 0], + [1503409884447, -1, 0], + [1503409884788, -1, 0], + [1503409884835, -1, 0], + [1503409884898, -2, 0], + [1503409884965, -3, 0], + [1503409885085, -2, 0], + [1503409885552, -1, 0], + [1503409885619, -1, 0], + [1503409885670, -1, 0], + [1503409885733, -2, 0], + [1503409885784, -4, 0], + [1503409885916, -3, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, true); + } + }); + + test('Windows - Microsoft Arc Touch', () => { + const testData: IMouseWheelEvent[] = [ + [1503418316909, -2, 0], + [1503418316985, -2, 0], + [1503418316988, -4, 0], + [1503418317034, -2, 0], + [1503418317071, -2, 0], + [1503418317094, -2, 0], + [1503418317133, -2, 0], + [1503418317170, -2, 0], + [1503418317192, -2, 0], + [1503418317265, -2, 0], + [1503418317289, -2, 0], + [1503418317365, -2, 0], + [1503418317414, -2, 0], + [1503418317458, -2, 0], + [1503418317513, -2, 0], + [1503418317583, -2, 0], + [1503418317637, -2, 0], + [1503418317720, -2, 0], + [1503418317786, -2, 0], + [1503418317832, -2, 0], + [1503418317933, -2, 0], + [1503418318037, -2, 0], + [1503418318134, -2, 0], + [1503418318267, -2, 0], + [1503418318411, -2, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, true); + } + }); + + test('Windows - SurfaceBook TouchPad', () => { + const testData: IMouseWheelEvent[] = [ + [1503418499174, -3.35, 0], + [1503418499177, -0.9333333333333333, 0], + [1503418499222, -2.091666666666667, 0], + [1503418499238, -1.5666666666666667, 0], + [1503418499242, -1.8, 0], + [1503418499271, -2.5166666666666666, 0], + [1503418499283, -0.7666666666666667, 0], + [1503418499308, -2.033333333333333, 0], + [1503418499320, -2.85, 0], + [1503418499372, -1.5333333333333334, 0], + [1503418499373, -2.8, 0], + [1503418499411, -1.6166666666666667, 0], + [1503418499413, -1.9166666666666667, 0], + [1503418499443, -0.9333333333333333, 0], + [1503418499446, -0.9833333333333333, 0], + [1503418499458, -0.7666666666666667, 0], + [1503418499482, -0.9666666666666667, 0], + [1503418499485, -0.36666666666666664, 0], + [1503418499508, -0.5833333333333334, 0], + [1503418499532, -0.48333333333333334, 0], + [1503418499541, -0.6333333333333333, 0], + [1503418499571, -0.18333333333333332, 0], + [1503418499573, -0.4, 0], + [1503418499595, -0.15, 0], + [1503418499608, -0.23333333333333334, 0], + [1503418499625, -0.18333333333333332, 0], + [1503418499657, -0.13333333333333333, 0], + [1503418499674, -0.15, 0], + [1503418499676, -0.03333333333333333, 0], + [1503418499691, -0.016666666666666666, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, false); + } + }); + + test('Windows - Razer physical wheel', () => { + const testData: IMouseWheelEvent[] = [ + [1503418638271, -2, 0], + [1503418638317, -2, 0], + [1503418638336, -2, 0], + [1503418638350, -2, 0], + [1503418638360, -2, 0], + [1503418638366, -2, 0], + [1503418638407, -2, 0], + [1503418638694, -2, 0], + [1503418638742, -2, 0], + [1503418638744, -2, 0], + [1503418638746, -2, 0], + [1503418638780, -2, 0], + [1503418638782, -2, 0], + [1503418638810, -2, 0], + [1503418639127, -2, 0], + [1503418639168, -2, 0], + [1503418639194, -2, 0], + [1503418639197, -4, 0], + [1503418639244, -2, 0], + [1503418639248, -2, 0], + [1503418639586, -2, 0], + [1503418639653, -2, 0], + [1503418639667, -4, 0], + [1503418639677, -2, 0], + [1503418639681, -2, 0], + [1503418639728, -2, 0], + [1503418639997, -2, 0], + [1503418640034, -2, 0], + [1503418640039, -2, 0], + [1503418640065, -2, 0], + [1503418640080, -2, 0], + [1503418640097, -2, 0], + [1503418640141, -2, 0], + [1503418640413, -2, 0], + [1503418640456, -2, 0], + [1503418640490, -2, 0], + [1503418640492, -4, 0], + [1503418640494, -2, 0], + [1503418640546, -2, 0], + [1503418640781, -2, 0], + [1503418640823, -2, 0], + [1503418640824, -2, 0], + [1503418640829, -2, 0], + [1503418640864, -2, 0], + [1503418640874, -2, 0], + [1503418640876, -2, 0], + [1503418641168, -2, 0], + [1503418641203, -2, 0], + [1503418641224, -2, 0], + [1503418641240, -2, 0], + [1503418641254, -4, 0], + [1503418641270, -2, 0], + [1503418641546, -2, 0], + [1503418641612, -2, 0], + [1503418641625, -6, 0], + [1503418641634, -2, 0], + [1503418641680, -2, 0], + [1503418641961, -2, 0], + [1503418642004, -2, 0], + [1503418642016, -4, 0], + [1503418642044, -2, 0], + [1503418642065, -2, 0], + [1503418642083, -2, 0], + [1503418642349, -2, 0], + [1503418642378, -2, 0], + [1503418642390, -2, 0], + [1503418642408, -2, 0], + [1503418642413, -2, 0], + [1503418642448, -2, 0], + [1503418642468, -2, 0], + [1503418642746, -2, 0], + [1503418642800, -2, 0], + [1503418642814, -4, 0], + [1503418642816, -2, 0], + [1503418642857, -2, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, true); + } + }); + + test('Windows - Logitech physical wheel', () => { + const testData: IMouseWheelEvent[] = [ + [1503418872930, -2, 0], + [1503418872952, -2, 0], + [1503418872969, -2, 0], + [1503418873022, -2, 0], + [1503418873042, -2, 0], + [1503418873076, -2, 0], + [1503418873368, -2, 0], + [1503418873393, -2, 0], + [1503418873404, -2, 0], + [1503418873425, -2, 0], + [1503418873479, -2, 0], + [1503418873520, -2, 0], + [1503418873758, -2, 0], + [1503418873759, -2, 0], + [1503418873762, -2, 0], + [1503418873807, -2, 0], + [1503418873830, -4, 0], + [1503418873850, -2, 0], + [1503418874076, -2, 0], + [1503418874116, -2, 0], + [1503418874136, -4, 0], + [1503418874148, -2, 0], + [1503418874150, -2, 0], + [1503418874409, -2, 0], + [1503418874452, -2, 0], + [1503418874472, -2, 0], + [1503418874474, -4, 0], + [1503418874543, -2, 0], + [1503418874566, -2, 0], + [1503418874778, -2, 0], + [1503418874780, -2, 0], + [1503418874801, -2, 0], + [1503418874822, -2, 0], + [1503418874832, -2, 0], + [1503418874845, -2, 0], + [1503418875122, -2, 0], + [1503418875158, -2, 0], + [1503418875180, -2, 0], + [1503418875195, -4, 0], + [1503418875239, -2, 0], + [1503418875260, -2, 0], + [1503418875490, -2, 0], + [1503418875525, -2, 0], + [1503418875547, -4, 0], + [1503418875556, -4, 0], + [1503418875630, -2, 0], + [1503418875852, -2, 0], + [1503418875895, -2, 0], + [1503418875935, -2, 0], + [1503418875941, -4, 0], + [1503418876198, -2, 0], + [1503418876242, -2, 0], + [1503418876270, -4, 0], + [1503418876279, -2, 0], + [1503418876333, -2, 0], + [1503418876342, -2, 0], + [1503418876585, -2, 0], + [1503418876609, -2, 0], + [1503418876623, -2, 0], + [1503418876644, -2, 0], + [1503418876646, -2, 0], + [1503418876678, -2, 0], + [1503418877330, -2, 0], + [1503418877354, -2, 0], + [1503418877368, -2, 0], + [1503418877397, -2, 0], + [1503418877411, -2, 0], + [1503418877748, -2, 0], + [1503418877756, -2, 0], + [1503418877778, -2, 0], + [1503418877793, -2, 0], + [1503418877807, -2, 0], + [1503418878091, -2, 0], + [1503418878133, -2, 0], + [1503418878137, -4, 0], + [1503418878181, -2, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, true); + } + }); + + test('Windows - Microsoft basic v2 physical wheel', () => { + const testData: IMouseWheelEvent[] = [ + [1503418994564, -2, 0], + [1503418994643, -2, 0], + [1503418994676, -2, 0], + [1503418994691, -2, 0], + [1503418994727, -2, 0], + [1503418994799, -2, 0], + [1503418994850, -2, 0], + [1503418995259, -2, 0], + [1503418995321, -2, 0], + [1503418995328, -2, 0], + [1503418995343, -2, 0], + [1503418995402, -2, 0], + [1503418995454, -2, 0], + [1503418996052, -2, 0], + [1503418996095, -2, 0], + [1503418996107, -2, 0], + [1503418996120, -2, 0], + [1503418996146, -2, 0], + [1503418996471, -2, 0], + [1503418996530, -2, 0], + [1503418996549, -2, 0], + [1503418996561, -2, 0], + [1503418996571, -2, 0], + [1503418996636, -2, 0], + [1503418996936, -2, 0], + [1503418997002, -2, 0], + [1503418997006, -2, 0], + [1503418997043, -2, 0], + [1503418997045, -2, 0], + [1503418997092, -2, 0], + [1503418997357, -2, 0], + [1503418997394, -2, 0], + [1503418997410, -2, 0], + [1503418997426, -2, 0], + [1503418997442, -2, 0], + [1503418997486, -2, 0], + [1503418997757, -2, 0], + [1503418997807, -2, 0], + [1503418997813, -2, 0], + [1503418997850, -2, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + assert.equal(actual, true); + } + }); +}); diff --git a/src/vs/editor/browser/controller/mouseHandler.ts b/src/vs/editor/browser/controller/mouseHandler.ts index 60ff6e4d650c5..982043e7e5f7b 100644 --- a/src/vs/editor/browser/controller/mouseHandler.ts +++ b/src/vs/editor/browser/controller/mouseHandler.ts @@ -423,16 +423,16 @@ class MouseDownOperation extends Disposable { const mouseColumn = this._getMouseColumn(e); if (e.posy < editorContent.y) { - let aboveLineNumber = viewLayout.getLineNumberAtVerticalOffset(Math.max(viewLayout.getScrollTop() - (editorContent.y - e.posy), 0)); + let aboveLineNumber = viewLayout.getLineNumberAtVerticalOffset(Math.max(viewLayout.getCurrentScrollTop() - (editorContent.y - e.posy), 0)); return new MouseTarget(null, editorBrowser.MouseTargetType.OUTSIDE_EDITOR, mouseColumn, new Position(aboveLineNumber, 1)); } if (e.posy > editorContent.y + editorContent.height) { - let belowLineNumber = viewLayout.getLineNumberAtVerticalOffset(viewLayout.getScrollTop() + (e.posy - editorContent.y)); + let belowLineNumber = viewLayout.getLineNumberAtVerticalOffset(viewLayout.getCurrentScrollTop() + (e.posy - editorContent.y)); return new MouseTarget(null, editorBrowser.MouseTargetType.OUTSIDE_EDITOR, mouseColumn, new Position(belowLineNumber, model.getLineMaxColumn(belowLineNumber))); } - let possibleLineNumber = viewLayout.getLineNumberAtVerticalOffset(viewLayout.getScrollTop() + (e.posy - editorContent.y)); + let possibleLineNumber = viewLayout.getLineNumberAtVerticalOffset(viewLayout.getCurrentScrollTop() + (e.posy - editorContent.y)); if (e.posx < editorContent.x) { return new MouseTarget(null, editorBrowser.MouseTargetType.OUTSIDE_EDITOR, mouseColumn, new Position(possibleLineNumber, 1)); diff --git a/src/vs/editor/browser/controller/mouseTarget.ts b/src/vs/editor/browser/controller/mouseTarget.ts index 3626e6cfb7002..4290a20048704 100644 --- a/src/vs/editor/browser/controller/mouseTarget.ts +++ b/src/vs/editor/browser/controller/mouseTarget.ts @@ -327,12 +327,12 @@ class HitTestContext { return this._viewHelper.getPositionFromDOMInfo(spanNode, offset); } - public getScrollTop(): number { - return this._context.viewLayout.getScrollTop(); + public getCurrentScrollTop(): number { + return this._context.viewLayout.getCurrentScrollTop(); } - public getScrollLeft(): number { - return this._context.viewLayout.getScrollLeft(); + public getCurrentScrollLeft(): number { + return this._context.viewLayout.getCurrentScrollLeft(); } } @@ -351,8 +351,8 @@ abstract class BareHitTestRequest { this.editorPos = editorPos; this.pos = pos; - this.mouseVerticalOffset = Math.max(0, ctx.getScrollTop() + pos.y - editorPos.y); - this.mouseContentHorizontalOffset = ctx.getScrollLeft() + pos.x - editorPos.x - ctx.layoutInfo.contentLeft; + this.mouseVerticalOffset = Math.max(0, ctx.getCurrentScrollTop() + pos.y - editorPos.y); + this.mouseContentHorizontalOffset = ctx.getCurrentScrollLeft() + pos.x - editorPos.x - ctx.layoutInfo.contentLeft; this.isInMarginArea = (pos.x - editorPos.x < ctx.layoutInfo.contentLeft); this.isInContentArea = !this.isInMarginArea; this.mouseColumn = Math.max(0, MouseTargetFactory._getMouseColumn(this.mouseContentHorizontalOffset, ctx.typicalHalfwidthCharacterWidth)); @@ -649,7 +649,7 @@ export class MouseTargetFactory { public getMouseColumn(editorPos: EditorPagePosition, pos: PageCoordinates): number { let layoutInfo = this._context.configuration.editor.layoutInfo; - let mouseContentHorizontalOffset = this._context.viewLayout.getScrollLeft() + pos.x - editorPos.x - layoutInfo.contentLeft; + let mouseContentHorizontalOffset = this._context.viewLayout.getCurrentScrollLeft() + pos.x - editorPos.x - layoutInfo.contentLeft; return MouseTargetFactory._getMouseColumn(mouseContentHorizontalOffset, this._context.configuration.editor.fontInfo.typicalHalfwidthCharacterWidth); } diff --git a/src/vs/editor/browser/controller/pointerHandler.ts b/src/vs/editor/browser/controller/pointerHandler.ts index fdd809a2e1a8f..35b948c66340d 100644 --- a/src/vs/editor/browser/controller/pointerHandler.ts +++ b/src/vs/editor/browser/controller/pointerHandler.ts @@ -99,11 +99,7 @@ class MsPointerHandler extends MouseHandler implements IDisposable { } private _onGestureChange(e: IThrottledGestureEvent): void { - const viewLayout = this._context.viewLayout; - viewLayout.setScrollPosition({ - scrollLeft: viewLayout.getScrollLeft() - e.translationX, - scrollTop: viewLayout.getScrollTop() - e.translationY, - }); + this._context.viewLayout.deltaScrollNow(-e.translationX, -e.translationY); } public dispose(): void { @@ -181,11 +177,7 @@ class StandardPointerHandler extends MouseHandler implements IDisposable { } private _onGestureChange(e: IThrottledGestureEvent): void { - const viewLayout = this._context.viewLayout; - viewLayout.setScrollPosition({ - scrollLeft: viewLayout.getScrollLeft() - e.translationX, - scrollTop: viewLayout.getScrollTop() - e.translationY, - }); + this._context.viewLayout.deltaScrollNow(-e.translationX, -e.translationY); } public dispose(): void { @@ -227,11 +219,7 @@ class TouchHandler extends MouseHandler { } private onChange(e: GestureEvent): void { - const viewLayout = this._context.viewLayout; - viewLayout.setScrollPosition({ - scrollLeft: viewLayout.getScrollLeft() - e.translationX, - scrollTop: viewLayout.getScrollTop() - e.translationY, - }); + this._context.viewLayout.deltaScrollNow(-e.translationX, -e.translationY); } } diff --git a/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts b/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts index 4f5f918abe78e..552bb8f4d2aca 100644 --- a/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts +++ b/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts @@ -6,7 +6,7 @@ import * as dom from 'vs/base/browser/dom'; import { ScrollableElementCreationOptions, ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; -import { IOverviewRulerLayoutInfo, ScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { IOverviewRulerLayoutInfo, SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { INewScrollPosition } from 'vs/editor/common/editorCommon'; import { ViewPart, PartFingerprint, PartFingerprints } from 'vs/editor/browser/view/viewPart'; import { ViewContext } from 'vs/editor/common/view/viewContext'; @@ -19,7 +19,7 @@ import { ISimplifiedMouseEvent } from 'vs/base/browser/ui/scrollbar/abstractScro export class EditorScrollbar extends ViewPart { - private scrollbar: ScrollableElement; + private scrollbar: SmoothScrollableElement; private scrollbarDomNode: FastDomNode; constructor( @@ -52,7 +52,7 @@ export class EditorScrollbar extends ViewPart { mouseWheelScrollSensitivity: configScrollbarOpts.mouseWheelScrollSensitivity, }; - this.scrollbar = this._register(new ScrollableElement(linesContent.domNode, scrollbarOptions, this._context.viewLayout.scrollable)); + this.scrollbar = this._register(new SmoothScrollableElement(linesContent.domNode, scrollbarOptions, this._context.viewLayout.scrollable)); PartFingerprints.write(this.scrollbar.getDomNode(), PartFingerprint.ScrollableElement); this.scrollbarDomNode = createFastDomNode(this.scrollbar.getDomNode()); @@ -69,7 +69,7 @@ export class EditorScrollbar extends ViewPart { if (lookAtScrollTop) { let deltaTop = domNode.scrollTop; if (deltaTop) { - newScrollPosition.scrollTop = this._context.viewLayout.getScrollTop() + deltaTop; + newScrollPosition.scrollTop = this._context.viewLayout.getCurrentScrollTop() + deltaTop; domNode.scrollTop = 0; } } @@ -77,12 +77,12 @@ export class EditorScrollbar extends ViewPart { if (lookAtScrollLeft) { let deltaLeft = domNode.scrollLeft; if (deltaLeft) { - newScrollPosition.scrollLeft = this._context.viewLayout.getScrollLeft() + deltaLeft; + newScrollPosition.scrollLeft = this._context.viewLayout.getCurrentScrollLeft() + deltaLeft; domNode.scrollLeft = 0; } } - this._context.viewLayout.setScrollPosition(newScrollPosition); + this._context.viewLayout.setScrollPositionNow(newScrollPosition); }; // I've seen this happen both on the view dom node & on the lines content dom node. diff --git a/src/vs/editor/browser/viewParts/lines/viewLines.ts b/src/vs/editor/browser/viewParts/lines/viewLines.ts index 60a1d51c3ed62..f7db650f05e49 100644 --- a/src/vs/editor/browser/viewParts/lines/viewLines.ts +++ b/src/vs/editor/browser/viewParts/lines/viewLines.ts @@ -36,6 +36,23 @@ class LastRenderedData { } } +class HorizontalRevealRequest { + + public readonly lineNumber: number; + public readonly startColumn: number; + public readonly endColumn: number; + public readonly startScrollTop: number; + public readonly stopScrollTop: number; + + constructor(lineNumber: number, startColumn: number, endColumn: number, startScrollTop: number, stopScrollTop: number) { + this.lineNumber = lineNumber; + this.startColumn = startColumn; + this.endColumn = endColumn; + this.startScrollTop = startScrollTop; + this.stopScrollTop = stopScrollTop; + } +} + export class ViewLines extends ViewPart implements IVisibleLinesHost, IViewLines { /** * Adds this ammount of pixels to the right of lines (no-one wants to type near the edge of the viewport) @@ -59,7 +76,7 @@ export class ViewLines extends ViewPart implements IVisibleLinesHost, private _maxLineWidth: number; private _asyncUpdateLineWidths: RunOnceScheduler; - private _lastCursorRevealRangeHorizontallyEvent: viewEvents.ViewRevealRangeRequestEvent; + private _horizontalRevealRequest: HorizontalRevealRequest; private _lastRenderedData: LastRenderedData; constructor(context: ViewContext, linesContent: FastDomNode) { @@ -90,7 +107,7 @@ export class ViewLines extends ViewPart implements IVisibleLinesHost, this._lastRenderedData = new LastRenderedData(); - this._lastCursorRevealRangeHorizontallyEvent = null; + this._horizontalRevealRequest = null; } public dispose(): void { @@ -199,19 +216,43 @@ export class ViewLines extends ViewPart implements IVisibleLinesHost, return this._visibleLines.onLinesInserted(e); } public onRevealRangeRequest(e: viewEvents.ViewRevealRangeRequestEvent): boolean { - let newScrollTop = this._computeScrollTopToRevealRange(this._context.viewLayout.getCurrentViewport(), e.range, e.verticalType); + let desiredScrollTop = this._computeScrollTopToRevealRange(this._context.viewLayout.getCurrentViewport(), e.range, e.verticalType); + + // validate the new desired scroll top + let newScrollPosition = this._context.viewLayout.validateScrollPosition({ scrollTop: desiredScrollTop }); if (e.revealHorizontal) { - this._lastCursorRevealRangeHorizontallyEvent = e; + if (e.range.startLineNumber !== e.range.endLineNumber) { + // Two or more lines? => scroll to base (That's how you see most of the two lines) + newScrollPosition = { + scrollTop: newScrollPosition.scrollTop, + scrollLeft: 0 + }; + } else { + // We don't necessarily know the horizontal offset of this range since the line might not be in the view... + this._horizontalRevealRequest = new HorizontalRevealRequest(e.range.startLineNumber, e.range.startColumn, e.range.endColumn, this._context.viewLayout.getCurrentScrollTop(), newScrollPosition.scrollTop); + } + } else { + this._horizontalRevealRequest = null; } - this._context.viewLayout.setScrollPosition({ // TODO@Alex: scrolling vertically can be moved to the view model - scrollTop: newScrollTop - }); + this._context.viewLayout.setScrollPositionSmooth(newScrollPosition); return true; } public onScrollChanged(e: viewEvents.ViewScrollChangedEvent): boolean { + if (this._horizontalRevealRequest && e.scrollLeftChanged) { + // cancel any outstanding horizontal reveal request if someone else scrolls horizontally. + this._horizontalRevealRequest = null; + } + if (this._horizontalRevealRequest && e.scrollTopChanged) { + const min = Math.min(this._horizontalRevealRequest.startScrollTop, this._horizontalRevealRequest.stopScrollTop); + const max = Math.max(this._horizontalRevealRequest.startScrollTop, this._horizontalRevealRequest.stopScrollTop); + if (e.scrollTop < min || e.scrollTop > max) { + // cancel any outstanding horizontal reveal request if someone else scrolls vertically. + this._horizontalRevealRequest = null; + } + } this.domNode.setWidth(e.scrollWidth); return this._visibleLines.onScrollChanged(e) || true; } @@ -440,33 +481,41 @@ export class ViewLines extends ViewPart implements IVisibleLinesHost, // (2) compute horizontal scroll position: // - this must happen after the lines are in the DOM since it might need a line that rendered just now // - it might change `scrollWidth` and `scrollLeft` - if (this._lastCursorRevealRangeHorizontallyEvent) { - let revealHorizontalRange = this._lastCursorRevealRangeHorizontallyEvent.range; - this._lastCursorRevealRangeHorizontallyEvent = null; + if (this._horizontalRevealRequest) { - // allow `visibleRangesForRange2` to work - this.onDidRender(); + const revealLineNumber = this._horizontalRevealRequest.lineNumber; + const revealStartColumn = this._horizontalRevealRequest.startColumn; + const revealEndColumn = this._horizontalRevealRequest.endColumn; - // compute new scroll position - let newScrollLeft = this._computeScrollLeftToRevealRange(revealHorizontalRange); + // Check that we have the line that contains the horizontal range in the viewport + if (viewportData.startLineNumber <= revealLineNumber && revealLineNumber <= viewportData.endLineNumber) { - let isViewportWrapping = this._isViewportWrapping; - if (!isViewportWrapping) { - // ensure `scrollWidth` is large enough - this._ensureMaxLineWidth(newScrollLeft.maxHorizontalOffset); - } + this._horizontalRevealRequest = null; + + // allow `visibleRangesForRange2` to work + this.onDidRender(); + + // compute new scroll position + let newScrollLeft = this._computeScrollLeftToRevealRange(revealLineNumber, revealStartColumn, revealEndColumn); - // set `scrollLeft` - this._context.viewLayout.setScrollPosition({ - scrollLeft: newScrollLeft.scrollLeft - }); + let isViewportWrapping = this._isViewportWrapping; + if (!isViewportWrapping) { + // ensure `scrollWidth` is large enough + this._ensureMaxLineWidth(newScrollLeft.maxHorizontalOffset); + } + + // set `scrollLeft` + this._context.viewLayout.setScrollPositionSmooth({ + scrollLeft: newScrollLeft.scrollLeft + }); + } } // (3) handle scrolling this._linesContent.setLayerHinting(this._canUseLayerHinting); - const adjustedScrollTop = this._context.viewLayout.getScrollTop() - viewportData.bigNumbersDelta; + const adjustedScrollTop = this._context.viewLayout.getCurrentScrollTop() - viewportData.bigNumbersDelta; this._linesContent.setTop(-adjustedScrollTop); - this._linesContent.setLeft(-this._context.viewLayout.getScrollLeft()); + this._linesContent.setLeft(-this._context.viewLayout.getCurrentScrollLeft()); // Update max line width (not so important, it is just so the horizontal scrollbar doesn't get too small) this._asyncUpdateLineWidths.schedule(); @@ -515,23 +564,15 @@ export class ViewLines extends ViewPart implements IVisibleLinesHost, return newScrollTop; } - private _computeScrollLeftToRevealRange(range: Range): { scrollLeft: number; maxHorizontalOffset: number; } { + private _computeScrollLeftToRevealRange(lineNumber: number, startColumn: number, endColumn: number): { scrollLeft: number; maxHorizontalOffset: number; } { let maxHorizontalOffset = 0; - if (range.startLineNumber !== range.endLineNumber) { - // Two or more lines? => scroll to base (That's how you see most of the two lines) - return { - scrollLeft: 0, - maxHorizontalOffset: maxHorizontalOffset - }; - } - let viewport = this._context.viewLayout.getCurrentViewport(); let viewportStartX = viewport.left; let viewportEndX = viewportStartX + viewport.width; - let visibleRanges = this.visibleRangesForRange2(range); + let visibleRanges = this.visibleRangesForRange2(new Range(lineNumber, startColumn, lineNumber, endColumn)); let boxStartX = Number.MAX_VALUE; let boxEndX = 0; diff --git a/src/vs/editor/browser/viewParts/minimap/minimap.ts b/src/vs/editor/browser/viewParts/minimap/minimap.ts index 206351eb6dae7..0a0c8f77b8187 100644 --- a/src/vs/editor/browser/viewParts/minimap/minimap.ts +++ b/src/vs/editor/browser/viewParts/minimap/minimap.ts @@ -528,14 +528,14 @@ export class Minimap extends ViewPart { if (platform.isWindows && mouseOrthogonalDelta > MOUSE_DRAG_RESET_DISTANCE) { // The mouse has wondered away from the scrollbar => reset dragging - this._context.viewLayout.setScrollPosition({ + this._context.viewLayout.setScrollPositionNow({ scrollTop: initialSliderState.scrollTop }); return; } const mouseDelta = mouseMoveData.posy - initialMousePosition; - this._context.viewLayout.setScrollPosition({ + this._context.viewLayout.setScrollPositionNow({ scrollTop: initialSliderState.getDesiredScrollTopFromDelta(mouseDelta) }); }, diff --git a/src/vs/editor/browser/widget/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditorWidget.ts index 6717aa0219366..860876c299a7e 100644 --- a/src/vs/editor/browser/widget/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditorWidget.ts @@ -21,7 +21,7 @@ import { ICodeEditorService } from 'vs/editor/common/services/codeEditorService' import { Configuration } from 'vs/editor/browser/config/configuration'; import * as editorBrowser from 'vs/editor/browser/editorBrowser'; import { View, IOverlayWidgetData, IContentWidgetData } from 'vs/editor/browser/view/viewImpl'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import Event, { Emitter } from 'vs/base/common/event'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { InternalEditorAction } from 'vs/editor/common/editorAction'; @@ -403,6 +403,10 @@ export abstract class CodeEditorWidget extends CommonCodeEditor implements edito } } + protected _scheduleAtNextAnimationFrame(callback: () => void): IDisposable { + return dom.scheduleAtNextAnimationFrame(callback); + } + protected _createView(): void { this._view = new View( this._commandService, diff --git a/src/vs/editor/common/commonCodeEditor.ts b/src/vs/editor/common/commonCodeEditor.ts index c636c9f8f5fc3..f3a58e289d594 100644 --- a/src/vs/editor/common/commonCodeEditor.ts +++ b/src/vs/editor/common/commonCodeEditor.ts @@ -513,7 +513,7 @@ export abstract class CommonCodeEditor extends Disposable implements editorCommo if (!this.hasView) { return -1; } - return this.viewModel.viewLayout.getScrollLeft(); + return this.viewModel.viewLayout.getCurrentScrollLeft(); } public getScrollHeight(): number { @@ -526,7 +526,7 @@ export abstract class CommonCodeEditor extends Disposable implements editorCommo if (!this.hasView) { return -1; } - return this.viewModel.viewLayout.getScrollTop(); + return this.viewModel.viewLayout.getCurrentScrollTop(); } public setScrollLeft(newScrollLeft: number): void { @@ -536,7 +536,7 @@ export abstract class CommonCodeEditor extends Disposable implements editorCommo if (typeof newScrollLeft !== 'number') { throw new Error('Invalid arguments'); } - this.viewModel.viewLayout.setScrollPosition({ + this.viewModel.viewLayout.setScrollPositionNow({ scrollLeft: newScrollLeft }); } @@ -547,7 +547,7 @@ export abstract class CommonCodeEditor extends Disposable implements editorCommo if (typeof newScrollTop !== 'number') { throw new Error('Invalid arguments'); } - this.viewModel.viewLayout.setScrollPosition({ + this.viewModel.viewLayout.setScrollPositionNow({ scrollTop: newScrollTop }); } @@ -555,7 +555,7 @@ export abstract class CommonCodeEditor extends Disposable implements editorCommo if (!this.hasView) { return; } - this.viewModel.viewLayout.setScrollPosition(position); + this.viewModel.viewLayout.setScrollPositionNow(position); } public saveViewState(): editorCommon.ICodeEditorViewState { @@ -862,7 +862,7 @@ export abstract class CommonCodeEditor extends Disposable implements editorCommo this.model.onBeforeAttached(); - this.viewModel = new ViewModel(this.id, this._configuration, this.model); + this.viewModel = new ViewModel(this.id, this._configuration, this.model, (callback) => this._scheduleAtNextAnimationFrame(callback)); this.listenersToRemove.push(this.model.addBulkListener((events) => { for (let i = 0, len = events.length; i < len; i++) { @@ -935,6 +935,7 @@ export abstract class CommonCodeEditor extends Disposable implements editorCommo } } + protected abstract _scheduleAtNextAnimationFrame(callback: () => void): IDisposable; protected abstract _createView(): void; protected _postDetachModelCleanup(detachedModel: editorCommon.IModel): void { diff --git a/src/vs/editor/common/config/commonEditorConfig.ts b/src/vs/editor/common/config/commonEditorConfig.ts index c0666b78a986c..11ef7a113739b 100644 --- a/src/vs/editor/common/config/commonEditorConfig.ts +++ b/src/vs/editor/common/config/commonEditorConfig.ts @@ -248,6 +248,11 @@ const editorConfiguration: IConfigurationNode = { 'default': EDITOR_DEFAULTS.viewInfo.scrollBeyondLastLine, 'description': nls.localize('scrollBeyondLastLine', "Controls if the editor will scroll beyond the last line") }, + 'editor.smoothScrolling': { + 'type': 'boolean', + 'default': EDITOR_DEFAULTS.viewInfo.smoothScrolling, + 'description': nls.localize('smoothScrolling', "Controls if the editor will scroll using an animation") + }, 'editor.minimap.enabled': { 'type': 'boolean', 'default': EDITOR_DEFAULTS.viewInfo.minimap.enabled, diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 5c90ebb782f30..5be479dd74198 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -266,6 +266,11 @@ export interface IEditorOptions { * Defaults to true. */ scrollBeyondLastLine?: boolean; + /** + * Enable that the editor animates scrolling to a position. + * Defaults to true. + */ + smoothScrolling?: boolean; /** * Enable that the editor will install an interval to check if its container dom node size has changed. * Enabling this might have a severe performance impact. @@ -748,6 +753,7 @@ export interface InternalEditorViewOptions { readonly cursorStyle: TextEditorCursorStyle; readonly hideCursorInOverviewRuler: boolean; readonly scrollBeyondLastLine: boolean; + readonly smoothScrolling: boolean; readonly stopRenderingLineAfter: number; readonly renderWhitespace: 'none' | 'boundary' | 'all'; readonly renderControlCharacters: boolean; @@ -1014,6 +1020,7 @@ export class InternalEditorOptions { && a.cursorStyle === b.cursorStyle && a.hideCursorInOverviewRuler === b.hideCursorInOverviewRuler && a.scrollBeyondLastLine === b.scrollBeyondLastLine + && a.smoothScrolling === b.smoothScrolling && a.stopRenderingLineAfter === b.stopRenderingLineAfter && a.renderWhitespace === b.renderWhitespace && a.renderControlCharacters === b.renderControlCharacters @@ -1609,6 +1616,7 @@ export class EditorOptionsValidator { cursorStyle: _cursorStyleFromString(opts.cursorStyle, defaults.cursorStyle), hideCursorInOverviewRuler: _boolean(opts.hideCursorInOverviewRuler, defaults.hideCursorInOverviewRuler), scrollBeyondLastLine: _boolean(opts.scrollBeyondLastLine, defaults.scrollBeyondLastLine), + smoothScrolling: _boolean(opts.smoothScrolling, defaults.smoothScrolling), stopRenderingLineAfter: _clampedInt(opts.stopRenderingLineAfter, defaults.stopRenderingLineAfter, -1, Constants.MAX_SAFE_SMALL_INTEGER), renderWhitespace: renderWhitespace, renderControlCharacters: _boolean(opts.renderControlCharacters, defaults.renderControlCharacters), @@ -1709,6 +1717,7 @@ export class InternalEditorOptionsFactory { cursorStyle: opts.viewInfo.cursorStyle, hideCursorInOverviewRuler: opts.viewInfo.hideCursorInOverviewRuler, scrollBeyondLastLine: opts.viewInfo.scrollBeyondLastLine, + smoothScrolling: opts.viewInfo.smoothScrolling, stopRenderingLineAfter: opts.viewInfo.stopRenderingLineAfter, renderWhitespace: (accessibilityIsOn ? 'none' : opts.viewInfo.renderWhitespace), // DISABLED WHEN SCREEN READER IS ATTACHED renderControlCharacters: (accessibilityIsOn ? false : opts.viewInfo.renderControlCharacters), // DISABLED WHEN SCREEN READER IS ATTACHED @@ -2130,6 +2139,7 @@ export const EDITOR_DEFAULTS: IValidatedEditorOptions = { cursorStyle: TextEditorCursorStyle.Line, hideCursorInOverviewRuler: false, scrollBeyondLastLine: true, + smoothScrolling: true, stopRenderingLineAfter: 10000, renderWhitespace: 'none', renderControlCharacters: false, diff --git a/src/vs/editor/common/controller/coreCommands.ts b/src/vs/editor/common/controller/coreCommands.ts index f3eaebb76c4cb..9022c8747e4a7 100644 --- a/src/vs/editor/common/controller/coreCommands.ts +++ b/src/vs/editor/common/controller/coreCommands.ts @@ -1114,7 +1114,7 @@ export namespace CoreNavigationCommands { noOfLines = args.value; } const deltaLines = (args.direction === EditorScroll_.Direction.Up ? -1 : 1) * noOfLines; - return context.getScrollTop() + deltaLines * context.config.lineHeight; + return context.getCurrentScrollTop() + deltaLines * context.config.lineHeight; } } diff --git a/src/vs/editor/common/controller/cursor.ts b/src/vs/editor/common/controller/cursor.ts index 2aa2755fe6808..6cdcd4070ce1c 100644 --- a/src/vs/editor/common/controller/cursor.ts +++ b/src/vs/editor/common/controller/cursor.ts @@ -210,7 +210,7 @@ export class Cursor extends viewEvents.ViewEventEmitter implements ICursors { } public scrollTo(desiredScrollTop: number): void { - this._viewModel.viewLayout.setScrollPosition({ + this._viewModel.viewLayout.setScrollPositionSmooth({ scrollTop: desiredScrollTop }); } diff --git a/src/vs/editor/common/controller/cursorCommon.ts b/src/vs/editor/common/controller/cursorCommon.ts index eb7b37719647c..9196b6e88303e 100644 --- a/src/vs/editor/common/controller/cursorCommon.ts +++ b/src/vs/editor/common/controller/cursorCommon.ts @@ -304,8 +304,8 @@ export class CursorContext { return this.viewModel.coordinatesConverter.convertModelRangeToViewRange(modelRange); } - public getScrollTop(): number { - return this.viewModel.viewLayout.getScrollTop(); + public getCurrentScrollTop(): number { + return this.viewModel.viewLayout.getCurrentScrollTop(); } public getCompletelyVisibleViewRange(): Range { diff --git a/src/vs/editor/common/viewLayout/viewLayout.ts b/src/vs/editor/common/viewLayout/viewLayout.ts index 4af8cbd523567..17575467cf08a 100644 --- a/src/vs/editor/common/viewLayout/viewLayout.ts +++ b/src/vs/editor/common/viewLayout/viewLayout.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import { Disposable } from 'vs/base/common/lifecycle'; -import { Scrollable, ScrollState, ScrollEvent, ScrollbarVisibility } from 'vs/base/common/scrollable'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { Scrollable, ScrollEvent, ScrollbarVisibility, IScrollDimensions, IScrollPosition } from 'vs/base/common/scrollable'; import * as editorCommon from 'vs/editor/common/editorCommon'; import { LinesLayout } from 'vs/editor/common/viewLayout/linesLayout'; import { IViewLayout, IViewWhitespaceViewportData, Viewport } from 'vs/editor/common/viewModel/viewModel'; @@ -14,6 +14,8 @@ import { IEditorWhitespace } from 'vs/editor/common/viewLayout/whitespaceCompute import Event from 'vs/base/common/event'; import { IConfigurationChangedEvent } from 'vs/editor/common/config/editorOptions'; +const SMOOTH_SCROLLING_TIME = 125; + export class ViewLayout extends Disposable implements IViewLayout { static LINES_HORIZONTAL_EXTRA_PX = 30; @@ -24,14 +26,16 @@ export class ViewLayout extends Disposable implements IViewLayout { public readonly scrollable: Scrollable; public readonly onDidScroll: Event; - constructor(configuration: editorCommon.IConfiguration, lineCount: number) { + constructor(configuration: editorCommon.IConfiguration, lineCount: number, scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable) { super(); this._configuration = configuration; this._linesLayout = new LinesLayout(lineCount, this._configuration.editor.lineHeight); - this.scrollable = this._register(new Scrollable()); - this.scrollable.updateState({ + this.scrollable = this._register(new Scrollable(0, scheduleAtNextAnimationFrame)); + this._configureSmoothScrollDuration(); + + this.scrollable.setScrollDimensions({ width: configuration.editor.layoutInfo.contentWidth, height: configuration.editor.layoutInfo.contentHeight }); @@ -52,6 +56,10 @@ export class ViewLayout extends Disposable implements IViewLayout { this._updateHeight(); } + private _configureSmoothScrollDuration(): void { + this.scrollable.setSmoothScrollDuration(this._configuration.editor.viewInfo.smoothScrolling ? SMOOTH_SCROLLING_TIME : 0); + } + // ---- begin view event handlers public onConfigurationChanged(e: IConfigurationChangedEvent): void { @@ -59,11 +67,14 @@ export class ViewLayout extends Disposable implements IViewLayout { this._linesLayout.setLineHeight(this._configuration.editor.lineHeight); } if (e.layoutInfo) { - this.scrollable.updateState({ + this.scrollable.setScrollDimensions({ width: this._configuration.editor.layoutInfo.contentWidth, height: this._configuration.editor.layoutInfo.contentHeight }); } + if (e.viewInfo) { + this._configureSmoothScrollDuration(); + } this._updateHeight(); } public onFlushed(lineCount: number): void { @@ -81,12 +92,12 @@ export class ViewLayout extends Disposable implements IViewLayout { // ---- end view event handlers - private _getHorizontalScrollbarHeight(scrollState: ScrollState): number { + private _getHorizontalScrollbarHeight(scrollDimensions: IScrollDimensions): number { if (this._configuration.editor.viewInfo.scrollbar.horizontal === ScrollbarVisibility.Hidden) { // horizontal scrollbar not visible return 0; } - if (scrollState.width >= scrollState.scrollWidth) { + if (scrollDimensions.width >= scrollDimensions.scrollWidth) { // horizontal scrollbar not visible return 0; } @@ -94,20 +105,20 @@ export class ViewLayout extends Disposable implements IViewLayout { } private _getTotalHeight(): number { - const scrollState = this.scrollable.getState(); + const scrollDimensions = this.scrollable.getScrollDimensions(); let result = this._linesLayout.getLinesTotalHeight(); if (this._configuration.editor.viewInfo.scrollBeyondLastLine) { - result += scrollState.height - this._configuration.editor.lineHeight; + result += scrollDimensions.height - this._configuration.editor.lineHeight; } else { - result += this._getHorizontalScrollbarHeight(scrollState); + result += this._getHorizontalScrollbarHeight(scrollDimensions); } - return Math.max(scrollState.height, result); + return Math.max(scrollDimensions.height, result); } private _updateHeight(): void { - this.scrollable.updateState({ + this.scrollable.setScrollDimensions({ scrollHeight: this._getTotalHeight() }); } @@ -115,12 +126,13 @@ export class ViewLayout extends Disposable implements IViewLayout { // ---- Layouting logic public getCurrentViewport(): Viewport { - const scrollState = this.scrollable.getState(); + const scrollDimensions = this.scrollable.getScrollDimensions(); + const currentScrollPosition = this.scrollable.getCurrentScrollPosition(); return new Viewport( - scrollState.scrollTop, - scrollState.scrollLeft, - scrollState.width, - scrollState.height + currentScrollPosition.scrollTop, + currentScrollPosition.scrollLeft, + scrollDimensions.width, + scrollDimensions.height ); } @@ -134,7 +146,7 @@ export class ViewLayout extends Disposable implements IViewLayout { public onMaxLineWidthChanged(maxLineWidth: number): void { let newScrollWidth = this._computeScrollWidth(maxLineWidth, this.getCurrentViewport().width); - this.scrollable.updateState({ + this.scrollable.setScrollDimensions({ scrollWidth: newScrollWidth }); @@ -145,14 +157,14 @@ export class ViewLayout extends Disposable implements IViewLayout { // ---- view state public saveState(): editorCommon.IViewState { - const scrollState = this.scrollable.getState(); - let scrollTop = scrollState.scrollTop; + const currentScrollPosition = this.scrollable.getFutureScrollPosition(); + let scrollTop = currentScrollPosition.scrollTop; let firstLineNumberInViewport = this._linesLayout.getLineNumberAtOrAfterVerticalOffset(scrollTop); let whitespaceAboveFirstLine = this._linesLayout.getWhitespaceAccumulatedHeightBeforeLineNumber(firstLineNumberInViewport); return { scrollTop: scrollTop, scrollTopWithoutViewZones: scrollTop - whitespaceAboveFirstLine, - scrollLeft: scrollState.scrollLeft + scrollLeft: currentScrollPosition.scrollLeft }; } @@ -161,7 +173,7 @@ export class ViewLayout extends Disposable implements IViewLayout { if (typeof state.scrollTopWithoutViewZones === 'number' && !this._linesLayout.hasWhitespace()) { restoreScrollTop = state.scrollTopWithoutViewZones; } - this.scrollable.updateState({ + this.scrollable.setScrollPositionNow({ scrollLeft: state.scrollLeft, scrollTop: restoreScrollTop }); @@ -197,14 +209,14 @@ export class ViewLayout extends Disposable implements IViewLayout { } public getLinesViewportDataAtScrollTop(scrollTop: number): IPartialViewLinesViewportData { // do some minimal validations on scrollTop - const scrollState = this.scrollable.getState(); - if (scrollTop + scrollState.height > scrollState.scrollHeight) { - scrollTop = scrollState.scrollHeight - scrollState.height; + const scrollDimensions = this.scrollable.getScrollDimensions(); + if (scrollTop + scrollDimensions.height > scrollDimensions.scrollHeight) { + scrollTop = scrollDimensions.scrollHeight - scrollDimensions.height; } if (scrollTop < 0) { scrollTop = 0; } - return this._linesLayout.getLinesViewportData(scrollTop, scrollTop + scrollState.height); + return this._linesLayout.getLinesViewportData(scrollTop, scrollTop + scrollDimensions.height); } public getWhitespaceViewportData(): IViewWhitespaceViewportData[] { const visibleBox = this.getCurrentViewport(); @@ -218,23 +230,40 @@ export class ViewLayout extends Disposable implements IViewLayout { public getScrollWidth(): number { - const scrollState = this.scrollable.getState(); - return scrollState.scrollWidth; - } - public getScrollLeft(): number { - const scrollState = this.scrollable.getState(); - return scrollState.scrollLeft; + const scrollDimensions = this.scrollable.getScrollDimensions(); + return scrollDimensions.scrollWidth; } public getScrollHeight(): number { - const scrollState = this.scrollable.getState(); - return scrollState.scrollHeight; + const scrollDimensions = this.scrollable.getScrollDimensions(); + return scrollDimensions.scrollHeight; + } + + public getCurrentScrollLeft(): number { + const currentScrollPosition = this.scrollable.getCurrentScrollPosition(); + return currentScrollPosition.scrollLeft; } - public getScrollTop(): number { - const scrollState = this.scrollable.getState(); - return scrollState.scrollTop; + public getCurrentScrollTop(): number { + const currentScrollPosition = this.scrollable.getCurrentScrollPosition(); + return currentScrollPosition.scrollTop; } - public setScrollPosition(position: editorCommon.INewScrollPosition): void { - this.scrollable.updateState(position); + public validateScrollPosition(scrollPosition: editorCommon.INewScrollPosition): IScrollPosition { + return this.scrollable.validateScrollPosition(scrollPosition); + } + + public setScrollPositionNow(position: editorCommon.INewScrollPosition): void { + this.scrollable.setScrollPositionNow(position); + } + + public setScrollPositionSmooth(position: editorCommon.INewScrollPosition): void { + this.scrollable.setScrollPositionSmooth(position); + } + + public deltaScrollNow(deltaScrollLeft: number, deltaScrollTop: number): void { + const currentScrollPosition = this.scrollable.getCurrentScrollPosition(); + this.scrollable.setScrollPositionNow({ + scrollLeft: currentScrollPosition.scrollLeft + deltaScrollLeft, + scrollTop: currentScrollPosition.scrollTop + deltaScrollTop + }); } } diff --git a/src/vs/editor/common/viewModel/viewModel.ts b/src/vs/editor/common/viewModel/viewModel.ts index d7abff3dfd326..e506f578b2ecd 100644 --- a/src/vs/editor/common/viewModel/viewModel.ts +++ b/src/vs/editor/common/viewModel/viewModel.ts @@ -11,7 +11,7 @@ import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; import { ViewEvent, IViewEventListener } from 'vs/editor/common/view/viewEvents'; import { IDisposable } from 'vs/base/common/lifecycle'; -import { Scrollable } from 'vs/base/common/scrollable'; +import { Scrollable, IScrollPosition } from 'vs/base/common/scrollable'; import { IPartialViewLinesViewportData } from 'vs/editor/common/viewLayout/viewLinesViewportData'; import { IEditorWhitespace } from 'vs/editor/common/viewLayout/whitespaceComputer'; @@ -44,12 +44,17 @@ export interface IViewLayout { onMaxLineWidthChanged(width: number): void; - getScrollLeft(): number; getScrollWidth(): number; getScrollHeight(): number; - getScrollTop(): number; + + getCurrentScrollLeft(): number; + getCurrentScrollTop(): number; getCurrentViewport(): Viewport; - setScrollPosition(position: INewScrollPosition): void; + + validateScrollPosition(scrollPosition: INewScrollPosition): IScrollPosition; + setScrollPositionNow(position: INewScrollPosition): void; + setScrollPositionSmooth(position: INewScrollPosition): void; + deltaScrollNow(deltaScrollLeft: number, deltaScrollTop: number): void; getLinesViewportData(): IPartialViewLinesViewportData; getLinesViewportDataAtScrollTop(scrollTop: number): IPartialViewLinesViewportData; diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 12b09a4a62d70..aaef276893278 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -21,6 +21,7 @@ import { IConfigurationChangedEvent } from 'vs/editor/common/config/editorOption import { CharacterHardWrappingLineMapperFactory } from 'vs/editor/common/viewModel/characterHardWrappingLineMapper'; import { ViewLayout } from 'vs/editor/common/viewLayout/viewLayout'; import { Color } from 'vs/base/common/color'; +import { IDisposable } from "vs/base/common/lifecycle"; const USE_IDENTITY_LINES_COLLECTION = true; @@ -38,7 +39,7 @@ export class ViewModel extends viewEvents.ViewEventEmitter implements IViewModel private _isDisposing: boolean; private _centeredViewLine: number; - constructor(editorId: number, configuration: editorCommon.IConfiguration, model: editorCommon.IModel) { + constructor(editorId: number, configuration: editorCommon.IConfiguration, model: editorCommon.IModel, scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable) { super(); this.editorId = editorId; @@ -70,7 +71,7 @@ export class ViewModel extends viewEvents.ViewEventEmitter implements IViewModel this.coordinatesConverter = this.lines.createCoordinatesConverter(); - this.viewLayout = this._register(new ViewLayout(this.configuration, this.getLineCount())); + this.viewLayout = this._register(new ViewLayout(this.configuration, this.getLineCount(), scheduleAtNextAnimationFrame)); this._register(this.viewLayout.onDidScroll((e) => { this._emit([new viewEvents.ViewScrollChangedEvent(e)]); @@ -125,7 +126,7 @@ export class ViewModel extends viewEvents.ViewEventEmitter implements IViewModel this.decorations.onLineMappingChanged(); this.viewLayout.onFlushed(this.getLineCount()); - if (this.viewLayout.getScrollTop() !== 0) { + if (this.viewLayout.getCurrentScrollTop() !== 0) { // Never change the scroll position from 0 to something else... revealPreviousCenteredModelRange = true; } diff --git a/src/vs/editor/test/common/controller/cursor.test.ts b/src/vs/editor/test/common/controller/cursor.test.ts index 4b18c7cdc14b6..b71db2b9c93c2 100644 --- a/src/vs/editor/test/common/controller/cursor.test.ts +++ b/src/vs/editor/test/common/controller/cursor.test.ts @@ -152,7 +152,7 @@ suite('Editor Controller - Cursor', () => { thisModel = Model.createFromString(text); thisConfiguration = new TestConfiguration(null); - thisViewModel = new ViewModel(0, thisConfiguration, thisModel); + thisViewModel = new ViewModel(0, thisConfiguration, thisModel, null); thisCursor = new Cursor(thisConfiguration, thisModel, thisViewModel); }); @@ -736,7 +736,7 @@ suite('Editor Controller - Cursor', () => { 'var newer = require("gulp-newer");', ].join('\n')); const config = new TestConfiguration(null); - const viewModel = new ViewModel(0, config, model); + const viewModel = new ViewModel(0, config, model, null); const cursor = new Cursor(config, model, viewModel); moveTo(cursor, 1, 4, false); @@ -775,7 +775,7 @@ suite('Editor Controller - Cursor', () => { '', ].join('\n')); const config = new TestConfiguration(null); - const viewModel = new ViewModel(0, config, model); + const viewModel = new ViewModel(0, config, model, null); const cursor = new Cursor(config, model, viewModel); moveTo(cursor, 10, 10, false); @@ -837,7 +837,7 @@ suite('Editor Controller - Cursor', () => { '', ].join('\n')); const config = new TestConfiguration(null); - const viewModel = new ViewModel(0, config, model); + const viewModel = new ViewModel(0, config, model, null); const cursor = new Cursor(config, model, viewModel); moveTo(cursor, 10, 10, false); @@ -886,7 +886,7 @@ suite('Editor Controller - Cursor', () => { 'var newer = require("gulp-newer");', ].join('\n')); const config = new TestConfiguration(null); - const viewModel = new ViewModel(0, config, model); + const viewModel = new ViewModel(0, config, model, null); const cursor = new Cursor(config, model, viewModel); moveTo(cursor, 1, 4, false); @@ -3118,7 +3118,7 @@ function usingCursor(opts: ICursorOpts, callback: (model: Model, cursor: Cursor) let model = Model.createFromString(opts.text.join('\n'), opts.modelOpts, opts.languageIdentifier); model.forceTokenization(model.getLineCount()); let config = new TestConfiguration(opts.editorOpts); - let viewModel = new ViewModel(0, config, model); + let viewModel = new ViewModel(0, config, model, null); let cursor = new Cursor(config, model, viewModel); callback(model, cursor); diff --git a/src/vs/editor/test/common/controller/cursorMoveCommand.test.ts b/src/vs/editor/test/common/controller/cursorMoveCommand.test.ts index 2b9ac20069d6f..57fe5b79395dd 100644 --- a/src/vs/editor/test/common/controller/cursorMoveCommand.test.ts +++ b/src/vs/editor/test/common/controller/cursorMoveCommand.test.ts @@ -33,7 +33,7 @@ suite('Cursor move command test', () => { thisModel = Model.createFromString(text); thisConfiguration = new TestConfiguration(null); - thisViewModel = new ViewModel(0, thisConfiguration, thisModel); + thisViewModel = new ViewModel(0, thisConfiguration, thisModel, null); thisCursor = new Cursor(thisConfiguration, thisModel, thisViewModel); }); diff --git a/src/vs/editor/test/common/mocks/mockCodeEditor.ts b/src/vs/editor/test/common/mocks/mockCodeEditor.ts index 52e03d799064c..bc2370fcd46a0 100644 --- a/src/vs/editor/test/common/mocks/mockCodeEditor.ts +++ b/src/vs/editor/test/common/mocks/mockCodeEditor.ts @@ -15,6 +15,7 @@ import * as editorCommon from 'vs/editor/common/editorCommon'; import { Model } from 'vs/editor/common/model/model'; import { TestConfiguration } from 'vs/editor/test/common/mocks/testConfiguration'; import * as editorOptions from 'vs/editor/common/config/editorOptions'; +import { IDisposable } from "vs/base/common/lifecycle"; export class MockCodeEditor extends CommonCodeEditor { protected _createConfiguration(options: editorOptions.IEditorOptions): CommonEditorConfiguration { @@ -28,6 +29,7 @@ export class MockCodeEditor extends CommonCodeEditor { public hasWidgetFocus(): boolean { return true; }; protected _enableEmptySelectionClipboard(): boolean { return false; } + protected _scheduleAtNextAnimationFrame(callback: () => void): IDisposable { throw new Error('Notimplemented'); } protected _createView(): void { } protected _registerDecorationType(key: string, options: editorCommon.IDecorationRenderOptions, parentTypeKey?: string): void { throw new Error('NotImplemented'); } diff --git a/src/vs/editor/test/common/viewModel/testViewModel.ts b/src/vs/editor/test/common/viewModel/testViewModel.ts index a041d80037a54..9584e043983df 100644 --- a/src/vs/editor/test/common/viewModel/testViewModel.ts +++ b/src/vs/editor/test/common/viewModel/testViewModel.ts @@ -16,7 +16,7 @@ export function testViewModel(text: string[], options: MockCodeEditorCreationOpt let model = Model.createFromString(text.join('\n')); - let viewModel = new ViewModel(EDITOR_ID, configuration, model); + let viewModel = new ViewModel(EDITOR_ID, configuration, model, null); callback(viewModel, model); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 97c8a13a94d56..b137933244cac 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -2824,6 +2824,11 @@ declare module monaco.editor { * Defaults to true. */ scrollBeyondLastLine?: boolean; + /** + * Enable that the editor animates scrolling to a position. + * Defaults to true. + */ + smoothScrolling?: boolean; /** * Enable that the editor will install an interval to check if its container dom node size has changed. * Enabling this might have a severe performance impact. @@ -3245,6 +3250,7 @@ declare module monaco.editor { readonly cursorStyle: TextEditorCursorStyle; readonly hideCursorInOverviewRuler: boolean; readonly scrollBeyondLastLine: boolean; + readonly smoothScrolling: boolean; readonly stopRenderingLineAfter: number; readonly renderWhitespace: 'none' | 'boundary' | 'all'; readonly renderControlCharacters: boolean; diff --git a/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts b/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts index c0eb5a14a1b8a..bb00ee4d2b9f9 100644 --- a/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts @@ -127,7 +127,7 @@ export class TabsTitleControl extends TitleControl { // Forward scrolling inside the container to our custom scrollbar this.toUnbind.push(DOM.addDisposableListener(this.tabsContainer, DOM.EventType.SCROLL, e => { if (DOM.hasClass(this.tabsContainer, 'scroll')) { - this.scrollbar.updateState({ + this.scrollbar.setScrollPosition({ scrollLeft: this.tabsContainer.scrollLeft // during DND the container gets scrolled so we need to update the custom scrollbar }); } @@ -458,7 +458,7 @@ export class TabsTitleControl extends TitleControl { const totalContainerWidth = this.tabsContainer.scrollWidth; // Update scrollbar - this.scrollbar.updateState({ + this.scrollbar.setScrollDimensions({ width: visibleContainerWidth, scrollWidth: totalContainerWidth }); @@ -478,14 +478,14 @@ export class TabsTitleControl extends TitleControl { // Tab is overflowing to the right: Scroll minimally until the element is fully visible to the right // Note: only try to do this if we actually have enough width to give to show the tab fully! if (activeTabFits && containerScrollPosX + visibleContainerWidth < activeTabPosX + activeTabWidth) { - this.scrollbar.updateState({ + this.scrollbar.setScrollPosition({ scrollLeft: containerScrollPosX + ((activeTabPosX + activeTabWidth) /* right corner of tab */ - (containerScrollPosX + visibleContainerWidth) /* right corner of view port */) }); } // Tab is overlflowng to the left or does not fit: Scroll it into view to the left else if (containerScrollPosX > activeTabPosX || !activeTabFits) { - this.scrollbar.updateState({ + this.scrollbar.setScrollPosition({ scrollLeft: this.activeTab.offsetLeft }); } @@ -565,7 +565,7 @@ export class TabsTitleControl extends TitleControl { } // moving in the tabs container can have an impact on scrolling position, so we need to update the custom scrollbar - this.scrollbar.updateState({ + this.scrollbar.setScrollPosition({ scrollLeft: this.tabsContainer.scrollLeft }); })); diff --git a/src/vs/workbench/parts/extensions/browser/extensionEditor.ts b/src/vs/workbench/parts/extensions/browser/extensionEditor.ts index c947f78d20df0..fb9961d043e61 100644 --- a/src/vs/workbench/parts/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/parts/extensions/browser/extensionEditor.ts @@ -445,8 +445,8 @@ export class ExtensionEditor extends BaseEditor { const tree = this.renderDependencies(content, extensionDependencies); const layout = () => { scrollableContent.scanDomNode(); - const scrollState = scrollableContent.getScrollState(); - tree.layout(scrollState.height); + const scrollDimensions = scrollableContent.getScrollDimensions(); + tree.layout(scrollDimensions.height); }; const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { layout }); this.contentDisposables.push(toDisposable(removeLayoutParticipant)); diff --git a/src/vs/workbench/parts/welcome/walkThrough/electron-browser/walkThroughPart.ts b/src/vs/workbench/parts/welcome/walkThrough/electron-browser/walkThroughPart.ts index 763338760e905..c8c2a0ebe74a5 100644 --- a/src/vs/workbench/parts/welcome/walkThrough/electron-browser/walkThroughPart.ts +++ b/src/vs/workbench/parts/welcome/walkThrough/electron-browser/walkThroughPart.ts @@ -134,11 +134,12 @@ export class WalkThroughPart extends BaseEditor { } private updatedScrollPosition() { - const scrollState = this.scrollbar.getScrollState(); - const scrollHeight = scrollState.scrollHeight; + const scrollDimensions = this.scrollbar.getScrollDimensions(); + const scrollPosition = this.scrollbar.getScrollPosition(); + const scrollHeight = scrollDimensions.scrollHeight; if (scrollHeight && this.input instanceof WalkThroughInput) { - const scrollTop = scrollState.scrollTop; - const height = scrollState.height; + const scrollTop = scrollPosition.scrollTop; + const height = scrollDimensions.height; this.input.relativeScrollPosition(scrollTop / scrollHeight, (scrollTop + height) / scrollHeight); } } @@ -163,9 +164,9 @@ export class WalkThroughPart extends BaseEditor { this.disposables.push(this.addEventListener(this.content, 'focusin', e => { // Work around scrolling as side-effect of setting focus on the offscreen zone widget (#18929) if (e.target instanceof HTMLElement && e.target.classList.contains('zone-widget-container')) { - let scrollState = this.scrollbar.getScrollState(); - this.content.scrollTop = scrollState.scrollTop; - this.content.scrollLeft = scrollState.scrollLeft; + const scrollPosition = this.scrollbar.getScrollPosition(); + this.content.scrollTop = scrollPosition.scrollTop; + this.content.scrollLeft = scrollPosition.scrollLeft; } })); } @@ -186,7 +187,7 @@ export class WalkThroughPart extends BaseEditor { if (scrollTarget && innerContent) { const targetTop = scrollTarget.getBoundingClientRect().top - 20; const containerTop = innerContent.getBoundingClientRect().top; - this.scrollbar.updateState({ scrollTop: targetTop - containerTop }); + this.scrollbar.setScrollPosition({ scrollTop: targetTop - containerTop }); } } else { this.open(URI.parse(node.href)); @@ -261,13 +262,13 @@ export class WalkThroughPart extends BaseEditor { } arrowUp() { - const scrollState = this.scrollbar.getScrollState(); - this.scrollbar.updateState({ scrollTop: scrollState.scrollTop - this.getArrowScrollHeight() }); + const scrollPosition = this.scrollbar.getScrollPosition(); + this.scrollbar.setScrollPosition({ scrollTop: scrollPosition.scrollTop - this.getArrowScrollHeight() }); } arrowDown() { - const scrollState = this.scrollbar.getScrollState(); - this.scrollbar.updateState({ scrollTop: scrollState.scrollTop + this.getArrowScrollHeight() }); + const scrollPosition = this.scrollbar.getScrollPosition(); + this.scrollbar.setScrollPosition({ scrollTop: scrollPosition.scrollTop + this.getArrowScrollHeight() }); } private getArrowScrollHeight() { @@ -279,13 +280,15 @@ export class WalkThroughPart extends BaseEditor { } pageUp() { - const scrollState = this.scrollbar.getScrollState(); - this.scrollbar.updateState({ scrollTop: scrollState.scrollTop - scrollState.height }); + const scrollDimensions = this.scrollbar.getScrollDimensions(); + const scrollPosition = this.scrollbar.getScrollPosition(); + this.scrollbar.setScrollPosition({ scrollTop: scrollPosition.scrollTop - scrollDimensions.height }); } pageDown() { - const scrollState = this.scrollbar.getScrollState(); - this.scrollbar.updateState({ scrollTop: scrollState.scrollTop + scrollState.height }); + const scrollDimensions = this.scrollbar.getScrollDimensions(); + const scrollPosition = this.scrollbar.getScrollPosition(); + this.scrollbar.setScrollPosition({ scrollTop: scrollPosition.scrollTop + scrollDimensions.height }); } setInput(input: WalkThroughInput, options: EditorOptions): TPromise { @@ -367,13 +370,14 @@ export class WalkThroughPart extends BaseEditor { const lineHeight = editor.getConfiguration().lineHeight; const lineTop = (targetTop + (e.position.lineNumber - 1) * lineHeight) - containerTop; const lineBottom = lineTop + lineHeight; - const scrollState = this.scrollbar.getScrollState(); - const scrollTop = scrollState.scrollTop; - const height = scrollState.height; + const scrollDimensions = this.scrollbar.getScrollDimensions(); + const scrollPosition = this.scrollbar.getScrollPosition(); + const scrollTop = scrollPosition.scrollTop; + const height = scrollDimensions.height; if (scrollTop > lineTop) { - this.scrollbar.updateState({ scrollTop: lineTop }); + this.scrollbar.setScrollPosition({ scrollTop: lineTop }); } else if (scrollTop < lineBottom - height) { - this.scrollbar.updateState({ scrollTop: lineBottom - height }); + this.scrollbar.setScrollPosition({ scrollTop: lineBottom - height }); } } })); @@ -485,11 +489,11 @@ export class WalkThroughPart extends BaseEditor { memento[WALK_THROUGH_EDITOR_VIEW_STATE_PREFERENCE_KEY] = editorViewStateMemento; } - const scrollState = this.scrollbar.getScrollState(); + const scrollPosition = this.scrollbar.getScrollPosition(); const editorViewState: IWalkThroughEditorViewState = { viewState: { - scrollTop: scrollState.scrollTop, - scrollLeft: scrollState.scrollLeft + scrollTop: scrollPosition.scrollTop, + scrollLeft: scrollPosition.scrollLeft } }; @@ -512,7 +516,7 @@ export class WalkThroughPart extends BaseEditor { if (fileViewState) { const state: IWalkThroughEditorViewState = fileViewState[this.position]; if (state) { - this.scrollbar.updateState(state.viewState); + this.scrollbar.setScrollPosition(state.viewState); } } }