/** * Copyright (c) 2024 The xterm.js authors. All rights reserved. * @license MIT */ import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import { ViewportConstants } from 'browser/shared/Constants'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services'; import { CoreMouseEventType } from 'common/Types'; import { addDisposableListener, scheduleAtNextAnimationFrame } from 'browser/Dom'; import { SmoothScrollableElement } from 'browser/scrollable/scrollableElement'; import type { IScrollableElementChangeOptions } from 'browser/scrollable/scrollableElementOptions'; import { Emitter, EventUtils } from 'common/Event'; import { Scrollable, ScrollbarVisibility, type IScrollEvent } from 'browser/scrollable/scrollable'; import { Gesture, EventType as GestureEventType, type IGestureEvent } from 'browser/scrollable/touch'; export class Viewport extends Disposable { protected _onRequestScrollLines = this._register(new Emitter()); public readonly onRequestScrollLines = this._onRequestScrollLines.event; private _scrollableElement: SmoothScrollableElement; private _styleElement: HTMLStyleElement; private _queuedAnimationFrame?: number; private _latestYDisp?: number; private _isSyncing: boolean = false; private _isHandlingScroll: boolean = false; private _suppressOnScrollHandler: boolean = false; constructor( element: HTMLElement, screenElement: HTMLElement, @IBufferService private readonly _bufferService: IBufferService, @ICoreBrowserService coreBrowserService: ICoreBrowserService, @ICoreMouseService coreMouseService: ICoreMouseService, @IThemeService themeService: IThemeService, @IOptionsService private readonly _optionsService: IOptionsService, @IRenderService private readonly _renderService: IRenderService ) { super(); const scrollable = this._register(new Scrollable({ forceIntegerValues: false, smoothScrollDuration: this._optionsService.rawOptions.smoothScrollDuration, // This is used over `IRenderService.addRefreshCallback` since it can be canceled scheduleAtNextAnimationFrame: cb => scheduleAtNextAnimationFrame(coreBrowserService.window, cb) })); this._register(this._optionsService.onSpecificOptionChange('smoothScrollDuration', () => { scrollable.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration); })); this._scrollableElement = this._register(new SmoothScrollableElement(screenElement, { vertical: ScrollbarVisibility.AUTO, horizontal: ScrollbarVisibility.HIDDEN, useShadows: false, mouseWheelSmoothScroll: true, verticalHasArrows: this._optionsService.rawOptions.scrollbar?.showArrows ?? false, ...this._getChangeOptions() }, scrollable)); this._register(this._optionsService.onMultipleOptionChange([ 'scrollSensitivity', 'fastScrollSensitivity', 'scrollbar' ], () => this._scrollableElement.updateOptions(this._getChangeOptions()))); // Don't handle mouse wheel if wheel events are supported by the current mouse prototcol this._register(coreMouseService.onProtocolChange(type => { this._scrollableElement.updateOptions({ handleMouseWheel: !(type & CoreMouseEventType.WHEEL) }); })); this._scrollableElement.setScrollDimensions({ height: 0, scrollHeight: 0 }); this._register(EventUtils.runAndSubscribe(themeService.onChangeColors, () => { element.style.backgroundColor = themeService.colors.background.css; this._scrollableElement.getDomNode().style.backgroundColor = themeService.colors.background.css; })); element.appendChild(this._scrollableElement.getDomNode()); this._register(toDisposable(() => this._scrollableElement.getDomNode().remove())); this._styleElement = coreBrowserService.mainDocument.createElement('style'); screenElement.appendChild(this._styleElement); this._register(toDisposable(() => this._styleElement.remove())); this._register(EventUtils.runAndSubscribe(themeService.onChangeColors, () => { this._styleElement.textContent = [ `.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {`, ` background: ${themeService.colors.scrollbarSliderBackground.css};`, `}`, `.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {`, ` background: ${themeService.colors.scrollbarSliderHoverBackground.css};`, `}`, `.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {`, ` background: ${themeService.colors.scrollbarSliderActiveBackground.css};`, `}` ].join('\n'); })); this._register(this._bufferService.onResize(() => this.queueSync())); this._register(this._bufferService.buffers.onBufferActivate(() => { // Reset _latestYDisp when switching buffers to prevent stale scroll position // from alt buffer contaminating normal buffer scroll position this._latestYDisp = undefined; this.queueSync(); })); this._register(this._bufferService.onScroll(() => this._sync())); this._register(this._scrollableElement.onScroll(e => this._handleScroll(e))); // Touch/gesture scrolling support this._register(Gesture.addTarget(screenElement)); this._register(addDisposableListener(screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleGestureChange(e))); } public scrollLines(disp: number): void { const pos = this._scrollableElement.getScrollPosition(); this._scrollableElement.setScrollPosition({ reuseAnimation: true, scrollTop: pos.scrollTop + disp * this._renderService.dimensions.css.cell.height }); } public scrollToLine(line: number, disableSmoothScroll?: boolean): void { if (disableSmoothScroll) { this._latestYDisp = line; } this._scrollableElement.setScrollPosition({ reuseAnimation: !disableSmoothScroll, scrollTop: line * this._renderService.dimensions.css.cell.height }); } private _getChangeOptions(): IScrollableElementChangeOptions { const showScrollbar = this._optionsService.rawOptions.scrollbar?.showScrollbar ?? true; const showArrows = this._optionsService.rawOptions.scrollbar?.showArrows ?? false; const verticalScrollbarSize = showScrollbar ? (this._optionsService.rawOptions.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH) : 0; return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity, vertical: showScrollbar ? ScrollbarVisibility.AUTO : ScrollbarVisibility.HIDDEN, verticalScrollbarSize, verticalHasArrows: showArrows }; } public queueSync(ydisp?: number): void { // Update state if (ydisp !== undefined) { this._latestYDisp = ydisp; } // Don't queue more than one callback if (this._queuedAnimationFrame !== undefined) { return; } this._queuedAnimationFrame = this._renderService.addRefreshCallback(() => { this._queuedAnimationFrame = undefined; this._sync(this._latestYDisp); }); } private _sync(ydisp: number = this._bufferService.buffer.ydisp): void { if (!this._renderService || this._isSyncing) { return; } this._isSyncing = true; // Ignore any onScroll event that happens as a result of dimensions changing as this should // never cause a scrollLines call, only setScrollPosition can do that. this._suppressOnScrollHandler = true; this._scrollableElement.setScrollDimensions({ height: this._renderService.dimensions.css.canvas.height, scrollHeight: this._renderService.dimensions.css.cell.height * this._bufferService.buffer.lines.length }); this._suppressOnScrollHandler = false; // If ydisp has been changed by some other component (input/buffer), then stop animating smooth // scroll and scroll there immediately. if (ydisp !== this._latestYDisp) { this._scrollableElement.setScrollPosition({ scrollTop: ydisp * this._renderService.dimensions.css.cell.height }); } this._isSyncing = false; } private _handleScroll(e: IScrollEvent): void { if (!this._renderService) { return; } if (this._isHandlingScroll || this._suppressOnScrollHandler) { return; } this._isHandlingScroll = true; const newRow = Math.round(e.scrollTop / this._renderService.dimensions.css.cell.height); const diff = newRow - this._bufferService.buffer.ydisp; if (diff !== 0) { this._latestYDisp = newRow; this._onRequestScrollLines.fire(diff); } this._isHandlingScroll = false; } private _handleGestureChange(e: IGestureEvent): void { e.preventDefault(); e.stopPropagation(); const pos = this._scrollableElement.getScrollPosition(); this._scrollableElement.setScrollPosition({ scrollTop: pos.scrollTop - e.translationY }); } }