tabby/tabby-terminal/src/frontends/xtermFrontend.ts
gh-log 424b062d5b
scroll_terminal_by_line (#10353)
Co-authored-by: gh-log <>
2025-03-12 00:48:34 +01:00

522 lines
18 KiB
TypeScript

import deepEqual from 'deep-equal'
import { BehaviorSubject, filter, firstValueFrom, takeUntil } from 'rxjs'
import { Injector } from '@angular/core'
import { ConfigService, getCSSFontFamily, getWindows10Build, HostAppService, HotkeysService, Platform, PlatformService, ThemesService } from 'tabby-core'
import { Frontend, SearchOptions, SearchState } from './frontend'
import { Terminal, ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { LigaturesAddon } from '@xterm/addon-ligatures'
import { ISearchOptions, SearchAddon } from '@xterm/addon-search'
import { WebglAddon } from '@xterm/addon-webgl'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { SerializeAddon } from '@xterm/addon-serialize'
import { ImageAddon } from '@xterm/addon-image'
import { CanvasAddon } from '@xterm/addon-canvas'
import { BaseTerminalProfile, TerminalColorScheme } from '../api/interfaces'
import { getTerminalBackgroundColor } from '../helpers'
import './xterm.css'
const COLOR_NAMES = [
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
]
class FlowControl {
private blocked = false
private blocked$ = new BehaviorSubject<boolean>(false)
private pendingCallbacks = 0
private lowWatermark = 5
private highWatermark = 10
private bytesWritten = 0
private bytesThreshold = 1024 * 128
constructor (private xterm: Terminal) { }
async write (data: string) {
if (this.blocked) {
await firstValueFrom(this.blocked$.pipe(filter(x => !x)))
}
this.bytesWritten += data.length
if (this.bytesWritten > this.bytesThreshold) {
this.pendingCallbacks++
this.bytesWritten = 0
if (!this.blocked && this.pendingCallbacks > this.highWatermark) {
this.blocked = true
this.blocked$.next(true)
}
this.xterm.write(data, () => {
this.pendingCallbacks--
if (this.blocked && this.pendingCallbacks < this.lowWatermark) {
this.blocked = false
this.blocked$.next(false)
}
})
} else {
this.xterm.write(data)
}
}
}
/** @hidden */
export class XTermFrontend extends Frontend {
enableResizing = true
xterm: Terminal
protected xtermCore: any
protected enableWebGL = false
private element?: HTMLElement
private configuredFontSize = 0
private configuredLinePadding = 0
private zoom = 0
private resizeHandler: () => void
private configuredTheme: ITheme = {}
private copyOnSelect = false
private preventNextOnSelectionChangeEvent = false
private search = new SearchAddon()
private searchState: SearchState = { resultCount: 0 }
private fitAddon = new FitAddon()
private serializeAddon = new SerializeAddon()
private ligaturesAddon?: LigaturesAddon
private webGLAddon?: WebglAddon
private canvasAddon?: CanvasAddon
private opened = false
private resizeObserver?: any
private flowControl: FlowControl
private configService: ConfigService
private hotkeysService: HotkeysService
private platformService: PlatformService
private hostApp: HostAppService
private themes: ThemesService
constructor (injector: Injector) {
super(injector)
this.configService = injector.get(ConfigService)
this.hotkeysService = injector.get(HotkeysService)
this.platformService = injector.get(PlatformService)
this.hostApp = injector.get(HostAppService)
this.themes = injector.get(ThemesService)
this.xterm = new Terminal({
allowTransparency: true,
allowProposedApi: true,
overviewRulerWidth: 8,
windowsPty: process.platform === 'win32' ? {
backend: this.configService.store.terminal.useConPTY ? 'conpty' : 'winpty',
buildNumber: getWindows10Build(),
} : undefined,
})
this.flowControl = new FlowControl(this.xterm)
this.xtermCore = this.xterm['_core']
this.xterm.onBinary(data => {
this.input.next(Buffer.from(data, 'binary'))
})
this.xterm.onData(data => {
this.input.next(Buffer.from(data, 'utf-8'))
})
this.xterm.onResize(({ cols, rows }) => {
this.resize.next({ rows, columns: cols })
})
this.xterm.onTitleChange(title => {
this.title.next(title)
})
this.xterm.onSelectionChange(() => {
if (this.getSelection()) {
if (this.copyOnSelect && !this.preventNextOnSelectionChangeEvent) {
this.copySelection()
}
this.preventNextOnSelectionChangeEvent = false
}
})
this.xterm.onBell(() => {
this.bell.next()
})
this.xterm.loadAddon(this.fitAddon)
this.xterm.loadAddon(this.serializeAddon)
this.xterm.loadAddon(new Unicode11Addon())
this.xterm.unicode.activeVersion = '11'
if (this.configService.store.terminal.sixel) {
this.xterm.loadAddon(new ImageAddon())
}
const keyboardEventHandler = (name: string, event: KeyboardEvent) => {
if (this.isAlternateScreenActive()) {
let modifiers = 0
modifiers += event.ctrlKey ? 1 : 0
modifiers += event.altKey ? 1 : 0
modifiers += event.shiftKey ? 1 : 0
modifiers += event.metaKey ? 1 : 0
if (event.key.startsWith('Arrow') && modifiers === 1) {
return true
}
}
// Ctrl-/
if (event.type === 'keydown' && event.key === '/' && event.ctrlKey) {
this.input.next(Buffer.from('\u001f', 'binary'))
return false
}
// Ctrl-@
if (event.type === 'keydown' && event.key === '@' && event.ctrlKey) {
this.input.next(Buffer.from('\u0000', 'binary'))
return false
}
this.hotkeysService.pushKeyEvent(name, event)
let ret = true
if (this.hotkeysService.matchActiveHotkey(true) !== null) {
event.stopPropagation()
event.preventDefault()
ret = false
}
return ret
}
this.xterm.attachCustomKeyEventHandler((event: KeyboardEvent) => {
if (this.hostApp.platform !== Platform.Web) {
if (
event.getModifierState('Meta') && event.key.toLowerCase() === 'v' ||
event.key === 'Insert' && event.shiftKey
) {
event.preventDefault()
return false
}
}
if (event.getModifierState('Meta') && event.key.startsWith('Arrow')) {
return false
}
return keyboardEventHandler('keydown', event)
})
this.xtermCore._scrollToBottom = this.xtermCore.scrollToBottom.bind(this.xtermCore)
this.xtermCore.scrollToBottom = () => null
this.resizeHandler = () => {
try {
if (this.xterm.element && getComputedStyle(this.xterm.element).getPropertyValue('height') !== 'auto') {
this.fitAddon.fit()
this.xtermCore.viewport._refresh()
}
} catch (e) {
// tends to throw when element wasn't shown yet
console.warn('Could not resize xterm', e)
}
}
const oldKeyUp = this.xtermCore._keyUp.bind(this.xtermCore)
this.xtermCore._keyUp = (e: KeyboardEvent) => {
this.xtermCore.updateCursorStyle(e)
if (keyboardEventHandler('keyup', e)) {
oldKeyUp(e)
}
}
this.xterm.buffer.onBufferChange(() => {
const altBufferActive = this.xterm.buffer.active.type === 'alternate'
this.alternateScreenActive.next(altBufferActive)
})
}
async attach (host: HTMLElement, profile: BaseTerminalProfile): Promise<void> {
this.element = host
this.xterm.open(host)
this.opened = true
// Work around font loading bugs
await new Promise(resolve => setTimeout(resolve, this.hostApp.platform === Platform.Web ? 1000 : 0))
// Just configure the colors to avoid a flash
this.configureColors(profile.terminalColorScheme)
if (this.enableWebGL) {
this.webGLAddon = new WebglAddon()
this.xterm.loadAddon(this.webGLAddon)
this.platformService.displayMetricsChanged$.pipe(
takeUntil(this.destroyed$),
).subscribe(() => {
this.webGLAddon?.clearTextureAtlas()
})
} else {
this.canvasAddon = new CanvasAddon()
this.xterm.loadAddon(this.canvasAddon)
this.platformService.displayMetricsChanged$.pipe(
takeUntil(this.destroyed$),
).subscribe(() => {
this.canvasAddon?.clearTextureAtlas()
})
}
// Allow an animation frame
await new Promise(r => setTimeout(r, 100))
this.ready.next()
this.ready.complete()
this.xterm.loadAddon(this.search)
this.search.onDidChangeResults(state => {
this.searchState = state
})
window.addEventListener('resize', this.resizeHandler)
this.resizeHandler()
// Allow an animation frame
await new Promise(r => setTimeout(r, 0))
host.addEventListener('dragOver', (event: any) => this.dragOver.next(event))
host.addEventListener('drop', event => this.drop.next(event))
host.addEventListener('mousedown', event => this.mouseEvent.next(event))
host.addEventListener('mouseup', event => this.mouseEvent.next(event))
host.addEventListener('mousewheel', event => this.mouseEvent.next(event as MouseEvent))
host.addEventListener('contextmenu', event => {
event.preventDefault()
event.stopPropagation()
})
this.resizeObserver = new window['ResizeObserver'](() => setTimeout(() => this.resizeHandler()))
this.resizeObserver.observe(host)
}
detach (_host: HTMLElement): void {
window.removeEventListener('resize', this.resizeHandler)
this.resizeObserver?.disconnect()
delete this.resizeObserver
}
destroy (): void {
super.destroy()
this.webGLAddon?.dispose()
this.canvasAddon?.dispose()
this.xterm.dispose()
}
getSelection (): string {
return this.xterm.getSelection()
}
copySelection (): void {
const text = this.getSelection()
if (!text.trim().length) {
return
}
if (text.length < 1024 * 32 && this.configService.store.terminal.copyAsHTML) {
this.platformService.setClipboard({
text: this.getSelection(),
html: this.getSelectionAsHTML(),
})
} else {
this.platformService.setClipboard({
text: this.getSelection(),
})
}
}
selectAll (): void {
this.xterm.selectAll()
}
clearSelection (): void {
this.xterm.clearSelection()
}
focus (): void {
setTimeout(() => this.xterm.focus())
}
async write (data: string): Promise<void> {
await this.flowControl.write(data)
}
clear (): void {
this.xterm.clear()
}
visualBell (): void {
if (this.element) {
this.element.style.animation = 'none'
setTimeout(() => {
this.element!.style.animation = 'terminalShakeFrames 0.3s ease'
})
}
}
scrollToTop (): void {
this.xterm.scrollToTop()
}
scrollPages (pages: number): void {
this.xterm.scrollPages(pages)
}
scrollLines (amount: number): void {
this.xterm.scrollLines(amount)
}
scrollToBottom (): void {
this.xtermCore._scrollToBottom()
}
private configureColors (scheme: TerminalColorScheme|undefined): void {
const appColorScheme = this.themes._getActiveColorScheme() as TerminalColorScheme
scheme = scheme ?? appColorScheme
const theme: ITheme = {
foreground: scheme.foreground,
selectionBackground: scheme.selection ?? '#88888888',
selectionForeground: scheme.selectionForeground ?? undefined,
background: getTerminalBackgroundColor(this.configService, this.themes, scheme) ?? '#00000000',
cursor: scheme.cursor,
cursorAccent: scheme.cursorAccent,
}
for (let i = 0; i < COLOR_NAMES.length; i++) {
theme[COLOR_NAMES[i]] = scheme.colors[i]
}
if (!deepEqual(this.configuredTheme, theme)) {
this.xterm.options.theme = theme
this.configuredTheme = theme
}
}
configure (profile: BaseTerminalProfile): void {
const config = this.configService.store
setImmediate(() => {
if (this.xterm.cols && this.xterm.rows && this.xtermCore.charMeasure) {
if (this.xtermCore.charMeasure) {
this.xtermCore.charMeasure.measure(this.xtermCore.options)
}
if (this.xtermCore.renderer) {
this.xtermCore.renderer._updateDimensions()
}
this.resizeHandler()
}
})
this.xtermCore.browser.isWindows = this.hostApp.platform === Platform.Windows
this.xtermCore.browser.isLinux = this.hostApp.platform === Platform.Linux
this.xtermCore.browser.isMac = this.hostApp.platform === Platform.macOS
this.xterm.options.fontFamily = getCSSFontFamily(config)
this.xterm.options.cursorStyle = {
beam: 'bar',
}[config.terminal.cursor] || config.terminal.cursor
this.xterm.options.cursorBlink = config.terminal.cursorBlink
this.xterm.options.macOptionIsMeta = config.terminal.altIsMeta
this.xterm.options.scrollback = config.terminal.scrollbackLines
this.xterm.options.wordSeparator = config.terminal.wordSeparator
this.xterm.options.drawBoldTextInBrightColors = config.terminal.drawBoldTextInBrightColors
this.xterm.options.fontWeight = config.terminal.fontWeight
this.xterm.options.fontWeightBold = config.terminal.fontWeightBold
this.xterm.options.minimumContrastRatio = config.terminal.minimumContrastRatio
this.configuredFontSize = config.terminal.fontSize
this.configuredLinePadding = config.terminal.linePadding
this.setFontSize()
this.copyOnSelect = config.terminal.copyOnSelect
this.configureColors(profile.terminalColorScheme)
if (this.opened && config.terminal.ligatures && !this.ligaturesAddon && this.hostApp.platform !== Platform.Web) {
this.ligaturesAddon = new LigaturesAddon()
this.xterm.loadAddon(this.ligaturesAddon)
}
}
setZoom (zoom: number): void {
this.zoom = zoom
this.setFontSize()
this.resizeHandler()
}
private getSearchOptions (searchOptions?: SearchOptions): ISearchOptions {
return {
...searchOptions,
decorations: {
matchOverviewRuler: '#888888',
activeMatchColorOverviewRuler: '#ffff00',
matchBackground: '#888888',
activeMatchBackground: '#ffff00',
},
}
}
private wrapSearchResult (result: boolean): SearchState {
if (!result) {
return { resultCount: 0 }
}
return this.searchState
}
findNext (term: string, searchOptions?: SearchOptions): SearchState {
if (this.copyOnSelect) {
this.preventNextOnSelectionChangeEvent = true
}
return this.wrapSearchResult(
this.search.findNext(term, this.getSearchOptions(searchOptions)),
)
}
findPrevious (term: string, searchOptions?: SearchOptions): SearchState {
if (this.copyOnSelect) {
this.preventNextOnSelectionChangeEvent = true
}
return this.wrapSearchResult(
this.search.findPrevious(term, this.getSearchOptions(searchOptions)),
)
}
cancelSearch (): void {
this.search.clearDecorations()
this.focus()
}
saveState (): any {
return this.serializeAddon.serialize({
excludeAltBuffer: true,
excludeModes: true,
scrollback: 1000,
})
}
restoreState (state: string): void {
this.xterm.write(state)
}
supportsBracketedPaste (): boolean {
return this.xterm.modes.bracketedPasteMode
}
isAlternateScreenActive (): boolean {
return this.xterm.buffer.active.type === 'alternate'
}
private setFontSize () {
const scale = Math.pow(1.1, this.zoom)
this.xterm.options.fontSize = this.configuredFontSize * scale
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
this.xterm.options.lineHeight = Math.max(1, (this.configuredFontSize + this.configuredLinePadding * 2) / this.configuredFontSize)
this.resizeHandler()
}
private getSelectionAsHTML (): string {
return this.serializeAddon.serializeAsHTML({ includeGlobalBackground: true, onlySelection: true })
}
}
/** @hidden */
export class XTermWebGLFrontend extends XTermFrontend {
protected enableWebGL = true
}