Browse Source

isolated VT implementation into TerminalContainer

Eugene Pankov 7 years ago
parent
commit
0419900e1d

+ 2 - 0
terminus-ssh/src/api.ts

@@ -12,7 +12,9 @@ export interface SSHConnection {
 export class SSHSession extends BaseSession {
     constructor (private shell: any) {
         super()
+    }
 
+    start () {
         this.open = true
 
         this.shell.on('data', data => {

+ 128 - 252
terminus-terminal/src/components/terminalTab.component.ts

@@ -1,4 +1,4 @@
-import { Observable, BehaviorSubject, Subject, Subscription } from 'rxjs'
+import { Subject, Subscription } from 'rxjs'
 import { first } from 'rxjs/operators'
 import { ToastrService } from 'ngx-toastr'
 import { Component, NgZone, Inject, Optional, ViewChild, HostBinding, Input } from '@angular/core'
@@ -7,9 +7,11 @@ import { AppService, ConfigService, BaseTabComponent, ElectronService, HostAppSe
 import { IShell } from '../api'
 import { Session, SessionsService } from '../services/sessions.service'
 import { TerminalService } from '../services/terminal.service'
+import { TerminalContainersService } from '../services/terminalContainers.service'
 
 import { TerminalDecorator, ResizeEvent, SessionOptions } from '../api'
-import { hterm, preferenceManager } from '../hterm'
+import { TermContainer } from '../terminalContainers/termContainer'
+import { hterm } from '../hterm'
 
 @Component({
     selector: 'terminalTab',
@@ -28,24 +30,16 @@ export class TerminalTabComponent extends BaseTabComponent {
     @Input() zoom = 0
     @ViewChild('content') content
     @HostBinding('style.background-color') backgroundColor: string
-    hterm: any
+    termContainer: TermContainer
     sessionCloseSubscription: Subscription
     hotkeysSubscription: Subscription
-    bell$ = new Subject()
     size: ResizeEvent
-    resize$: Observable<ResizeEvent>
-    input$ = new Subject<string>()
     output$ = new Subject<string>()
-    contentUpdated$: Observable<void>
-    alternateScreenActive$ = new BehaviorSubject(false)
-    mouseEvent$ = new Subject<Event>()
     htermVisible = false
     shell: IShell
-    private resize_ = new Subject<ResizeEvent>()
-    private contentUpdated_ = new Subject<void>()
     private bellPlayer: HTMLAudioElement
-    private io: any
     private contextMenu: any
+    private termContainerSubscriptions: Subscription[] = []
 
     constructor (
         private zone: NgZone,
@@ -55,60 +49,40 @@ export class TerminalTabComponent extends BaseTabComponent {
         private sessions: SessionsService,
         private electron: ElectronService,
         private terminalService: TerminalService,
+        private terminalContainersService: TerminalContainersService,
         public config: ConfigService,
         private toastr: ToastrService,
         @Optional() @Inject(TerminalDecorator) private decorators: TerminalDecorator[],
     ) {
         super()
-        this.resize$ = this.resize_.asObservable()
         this.decorators = this.decorators || []
         this.setTitle('Terminal')
-        this.resize$.pipe(first()).subscribe(async resizeEvent => {
-            if (!this.session) {
-                this.session = this.sessions.addSession(
-                    Object.assign({}, this.sessionOptions, resizeEvent)
-                )
-            }
 
-            setTimeout(() => {
-                this.session.resize(resizeEvent.width, resizeEvent.height)
-            }, 1000)
+        this.session = new Session()
 
-            // this.session.output$.bufferTime(10).subscribe((datas) => {
-            this.session.output$.subscribe(data => {
-                this.zone.run(() => {
-                    this.output$.next(data)
-                    this.write(data)
-                })
-            })
-
-            this.sessionCloseSubscription = this.session.closed$.subscribe(() => {
-                this.app.closeTab(this)
-            })
-
-            this.session.releaseInitialDataBuffer()
-        })
         this.hotkeysSubscription = this.hotkeys.matchedHotkey.subscribe(hotkey => {
             if (!this.hasFocus) {
                 return
             }
             switch (hotkey) {
             case 'ctrl-c':
-                if (this.hterm.getSelectionText()) {
-                    this.hterm.copySelectionToClipboard()
-                    this.hterm.getDocument().getSelection().removeAllRanges()
+                if (this.termContainer.getSelection()) {
+                    this.termContainer.copySelection()
+                    this.termContainer.clearSelection()
+                    this.toastr.info('Copied')
                 } else {
                     this.sendInput('\x03')
                 }
                 break
             case 'copy':
-                this.hterm.copySelectionToClipboard()
+                this.termContainer.copySelection()
+                this.toastr.info('Copied')
                 break
             case 'paste':
                 this.paste()
                 break
             case 'clear':
-                this.clear()
+                this.termContainer.clear()
                 break
             case 'zoom-in':
                 this.zoomIn()
@@ -143,6 +117,29 @@ export class TerminalTabComponent extends BaseTabComponent {
         this.bellPlayer.src = require<string>('../bell.ogg')
     }
 
+    initializeSession (columns: number, rows: number) {
+        this.sessions.addSession(
+            this.session,
+            Object.assign({}, this.sessionOptions, {
+                width: columns,
+                height: rows,
+            })
+        )
+
+        // this.session.output$.bufferTime(10).subscribe((datas) => {
+        this.session.output$.subscribe(data => {
+            this.zone.run(() => {
+                this.output$.next(data)
+                this.write(data)
+            })
+        })
+
+        this.sessionCloseSubscription = this.session.closed$.subscribe(() => {
+            this.termContainer.destroy()
+            this.app.closeTab(this)
+        })
+    }
+
     getRecoveryToken (): any {
         return {
             type: 'app:terminal',
@@ -153,46 +150,49 @@ export class TerminalTabComponent extends BaseTabComponent {
     ngOnInit () {
         this.focused$.subscribe(() => {
             this.configure()
-            setTimeout(() => {
-                this.hterm.scrollPort_.resize()
-                this.hterm.scrollPort_.focus()
-            }, 100)
+            this.termContainer.focus()
         })
 
-        this.hterm = new hterm.hterm.Terminal()
-        this.config.enabledServices(this.decorators).forEach((decorator) => {
-            decorator.attach(this)
+        this.termContainer = this.terminalContainersService.getContainer(this.session)
+
+        this.termContainer.ready$.subscribe(() => {
+            this.htermVisible = true
         })
 
-        this.attachHTermHandlers(this.hterm)
+        this.termContainer.resize$.pipe(first()).subscribe(async ({columns, rows}) => {
+            if (!this.session.open) {
+                this.initializeSession(columns, rows)
+            }
+
+            setTimeout(() => {
+                this.session.resize(columns, rows)
+            }, 1000)
+
+            this.session.releaseInitialDataBuffer()
+        })
+
+        this.termContainer.attach(this.content.nativeElement)
+        this.attachTermContainerHandlers()
 
-        this.hterm.onTerminalReady = () => {
-            this.htermVisible = true
-            this.hterm.installKeyboard()
-            this.hterm.scrollPort_.setCtrlVPaste(true)
-            this.io = this.hterm.io.push()
-            this.attachIOHandlers(this.io)
-        }
-        this.hterm.decorate(this.content.nativeElement)
         this.configure()
 
+        this.config.enabledServices(this.decorators).forEach((decorator) => {
+            decorator.attach(this)
+        })
+
         setTimeout(() => {
             this.output$.subscribe(() => {
                 this.displayActivity()
             })
         }, 1000)
 
-        this.bell$.subscribe(() => {
+        this.termContainer.bell$.subscribe(() => {
             if (this.config.store.terminal.bell === 'visual') {
-                preferenceManager.set('background-color', 'rgba(128,128,128,.25)')
-                setTimeout(() => {
-                    this.configure()
-                }, 125)
+                this.termContainer.visualBell()
             }
             if (this.config.store.terminal.bell === 'audible') {
                 this.bellPlayer.play()
             }
-            // TODO audible
         })
 
         this.contextMenu = this.electron.remote.Menu.buildFromTemplate([
@@ -209,7 +209,8 @@ export class TerminalTabComponent extends BaseTabComponent {
                 click: () => {
                     this.zone.run(() => {
                         setTimeout(() => {
-                            this.hterm.copySelectionToClipboard()
+                            this.termContainer.copySelection()
+                            this.toastr.info('Copied')
                         })
                     })
                 }
@@ -225,117 +226,65 @@ export class TerminalTabComponent extends BaseTabComponent {
         ])
     }
 
-    attachHTermHandlers (hterm: any) {
-        hterm.setWindowTitle = title => this.zone.run(() => this.setTitle(title))
-
-        const _setAlternateMode = hterm.setAlternateMode.bind(hterm)
-        hterm.setAlternateMode = (state) => {
-            _setAlternateMode(state)
-            this.alternateScreenActive$.next(state)
-        }
-
-        const _copySelectionToClipboard = hterm.copySelectionToClipboard.bind(hterm)
-        hterm.copySelectionToClipboard = () => {
-            _copySelectionToClipboard()
-            this.toastr.info('Copied')
-        }
-
-        hterm.primaryScreen_.syncSelectionCaret = () => null
-        hterm.alternateScreen_.syncSelectionCaret = () => null
-        hterm.primaryScreen_.terminal = hterm
-        hterm.alternateScreen_.terminal = hterm
-
-        hterm.scrollPort_.onPaste_ = (event) => {
-            event.preventDefault()
-        }
-
-        const _resize = hterm.scrollPort_.resize.bind(hterm.scrollPort_)
-        hterm.scrollPort_.resize = () => {
-            if (!this.hasFocus) {
-                return
-            }
-            _resize()
+    detachTermContainerHandlers () {
+        for (let subscription of this.termContainerSubscriptions) {
+            subscription.unsubscribe()
         }
+        this.termContainerSubscriptions = []
+    }
 
-        const _onMouse = hterm.onMouse_.bind(hterm)
-        hterm.onMouse_ = (event) => {
-            this.mouseEvent$.next(event)
-            if (event.type === 'mousedown') {
-                if (event.which === 3) {
-                    if (this.config.store.terminal.rightClick === 'menu') {
-                        this.contextMenu.popup({
-                            x: event.pageX + this.content.nativeElement.getBoundingClientRect().left,
-                            y: event.pageY + this.content.nativeElement.getBoundingClientRect().top,
-                            async: true,
-                        })
-                    } else if (this.config.store.terminal.rightClick === 'paste') {
-                        this.paste()
+    attachTermContainerHandlers () {
+        this.detachTermContainerHandlers()
+        this.termContainerSubscriptions = [
+            this.termContainer.title$.subscribe(title => this.zone.run(() => this.setTitle(title))),
+
+            this.focused$.subscribe(() => this.termContainer.enableResizing = true),
+            this.blurred$.subscribe(() => this.termContainer.enableResizing = false),
+
+            this.termContainer.mouseEvent$.subscribe(event => {
+                if (event.type === 'mousedown') {
+                    if (event.which === 3) {
+                        if (this.config.store.terminal.rightClick === 'menu') {
+                            this.contextMenu.popup({
+                                async: true,
+                            })
+                        } else if (this.config.store.terminal.rightClick === 'paste') {
+                            this.paste()
+                        }
+                        event.preventDefault()
+                        event.stopPropagation()
+                        return
                     }
-                    event.preventDefault()
-                    event.stopPropagation()
-                    return
                 }
-            }
-            if (event.type === 'mousewheel') {
-                if (event.ctrlKey || event.metaKey) {
-                    if (event.wheelDeltaY > 0) {
-                        this.zoomIn()
-                    } else {
-                        this.zoomOut()
+                if (event.type === 'mousewheel') {
+                    if (event.ctrlKey || event.metaKey) {
+                        if ((event as MouseWheelEvent).wheelDeltaY > 0) {
+                            this.zoomIn()
+                        } else {
+                            this.zoomOut()
+                        }
+                    } else if (event.altKey) {
+                        event.preventDefault()
+                        let delta = Math.round((event as MouseWheelEvent).wheelDeltaY / 50)
+                        this.sendInput(((delta > 0) ? '\u001bOA' : '\u001bOB').repeat(Math.abs(delta)))
                     }
-                } else if (event.altKey) {
-                    event.preventDefault()
-                    let delta = Math.round(event.wheelDeltaY / 50)
-                    this.sendInput(((delta > 0) ? '\u001bOA' : '\u001bOB').repeat(Math.abs(delta)))
                 }
-            }
-            _onMouse(event)
-        }
-
-        hterm.ringBell = () => {
-            this.bell$.next()
-        }
+            }),
 
-        for (let screen of [hterm.primaryScreen_, hterm.alternateScreen_]) {
-            const _insertString = screen.insertString.bind(screen)
-            screen.insertString = (data) => {
-                _insertString(data)
-                this.contentUpdated_.next()
-            }
+            this.termContainer.input$.subscribe(data => {
+                this.sendInput(data)
+            }),
 
-            const _deleteChars = screen.deleteChars.bind(screen)
-            screen.deleteChars = (count) => {
-                let ret = _deleteChars(count)
-                this.contentUpdated_.next()
-                return ret
-            }
-        }
-
-        const _measureCharacterSize = hterm.scrollPort_.measureCharacterSize.bind(hterm.scrollPort_)
-        hterm.scrollPort_.measureCharacterSize = () => {
-            let size = _measureCharacterSize()
-            size.height += this.config.store.terminal.linePadding
-            return size
-        }
-    }
-
-    attachIOHandlers (io: any) {
-        io.onVTKeystroke = io.sendString = (data) => {
-            this.sendInput(data)
-            this.zone.run(() => {
-                this.input$.next(data)
-            })
-        }
-        io.onTerminalResize = (columns, rows) => {
-            // console.log(`Resizing to ${columns}x${rows}`)
-            this.zone.run(() => {
-                this.size = { width: columns, height: rows }
-                if (this.session) {
-                    this.session.resize(columns, rows)
-                }
-                this.resize_.next(this.size)
+            this.termContainer.resize$.subscribe(({columns, rows}) => {
+                console.log(`Resizing to ${columns}x${rows}`)
+                this.zone.run(() => {
+                    this.size = { width: columns, height: rows }
+                    if (this.session.open) {
+                        this.session.resize(columns, rows)
+                    }
+                })
             })
-        }
+        ]
     }
 
     sendInput (data: string) {
@@ -343,111 +292,48 @@ export class TerminalTabComponent extends BaseTabComponent {
     }
 
     write (data: string) {
-        this.io.writeUTF8(data)
+        this.termContainer.write(data)
     }
 
     paste () {
         let data = this.electron.clipboard.readText()
-        data = this.hterm.keyboard.encode(data)
-        if (this.hterm.options_.bracketedPaste) {
+        data = hterm.lib.encodeUTF8(data)
+        if (this.config.store.terminal.bracketedPaste) {
             data = '\x1b[200~' + data + '\x1b[201~'
         }
-        data = data.replace(/\r\n/g, '\n')
+        data = data.replace(/\n/g, '\r')
         this.sendInput(data)
     }
 
-    clear () {
-        this.hterm.wipeContents()
-        this.hterm.onVTKeystroke('\f')
-    }
-
     configure (): void {
-        let config = this.config.store
-        preferenceManager.set('font-family', `"${config.terminal.font}", "monospace-fallback", monospace`)
-        this.setFontSize()
-        preferenceManager.set('enable-bold', true)
-        // preferenceManager.set('audible-bell-sound', '')
-        preferenceManager.set('desktop-notification-bell', config.terminal.bell === 'notification')
-        preferenceManager.set('enable-clipboard-notice', false)
-        preferenceManager.set('receive-encoding', 'raw')
-        preferenceManager.set('send-encoding', 'raw')
-        preferenceManager.set('ctrl-plus-minus-zero-zoom', false)
-        preferenceManager.set('scrollbar-visible', this.hostApp.platform === Platform.macOS)
-        preferenceManager.set('copy-on-select', config.terminal.copyOnSelect)
-        preferenceManager.set('alt-is-meta', config.terminal.altIsMeta)
-        preferenceManager.set('alt-sends-what', 'browser-key')
-        preferenceManager.set('alt-gr-mode', 'ctrl-alt')
-        preferenceManager.set('pass-alt-number', true)
-        preferenceManager.set('cursor-blink', config.terminal.cursorBlink)
-        preferenceManager.set('clear-selection-after-copy', true)
-
-        if (config.terminal.colorScheme.foreground) {
-            preferenceManager.set('foreground-color', config.terminal.colorScheme.foreground)
-        }
-        if (config.terminal.background === 'colorScheme') {
-            if (config.terminal.colorScheme.background) {
-                this.backgroundColor = config.terminal.colorScheme.background
-                preferenceManager.set('background-color', config.terminal.colorScheme.background)
+        this.termContainer.configure(this.config.store)
+
+        if (this.config.store.terminal.background === 'colorScheme') {
+            if (this.config.store.terminal.colorScheme.background) {
+                this.backgroundColor = this.config.store.terminal.colorScheme.background
             }
         } else {
             this.backgroundColor = null
-            // hterm can't parse "transparent"
-            preferenceManager.set('background-color', 'transparent')
-        }
-        if (config.terminal.colorScheme.colors) {
-            preferenceManager.set('color-palette-overrides', config.terminal.colorScheme.colors)
-        }
-        if (config.terminal.colorScheme.cursor) {
-            preferenceManager.set('cursor-color', config.terminal.colorScheme.cursor)
-        }
-
-        let css = require('../hterm.userCSS.scss')
-        if (!config.terminal.ligatures) {
-            css += `
-                * {
-                    font-feature-settings: "liga" 0;
-                    font-variant-ligatures: none;
-                }
-            `
-        } else {
-            css += `
-                * {
-                    font-feature-settings: "liga" 1;
-                    font-variant-ligatures: initial;
-                }
-            `
-        }
-        css += config.appearance.css
-        this.hterm.setCSS(css)
-        this.hterm.setBracketedPaste(config.terminal.bracketedPaste)
-        this.hterm.defaultCursorShape = {
-            block: hterm.hterm.Terminal.cursorShape.BLOCK,
-            underline: hterm.hterm.Terminal.cursorShape.UNDERLINE,
-            beam: hterm.hterm.Terminal.cursorShape.BEAM,
-        }[config.terminal.cursor]
-        this.hterm.applyCursorShape()
-        this.hterm.setCursorBlink(config.terminal.cursorBlink)
-        if (config.terminal.cursorBlink) {
-            this.hterm.onCursorBlink_()
         }
     }
 
     zoomIn () {
         this.zoom++
-        this.setFontSize()
+        this.termContainer.setZoom(this.zoom)
     }
 
     zoomOut () {
         this.zoom--
-        this.setFontSize()
+        this.termContainer.setZoom(this.zoom)
     }
 
     resetZoom () {
         this.zoom = 0
-        this.setFontSize()
+        this.termContainer.setZoom(this.zoom)
     }
 
     ngOnDestroy () {
+        this.detachTermContainerHandlers()
         this.config.enabledServices(this.decorators).forEach(decorator => {
             decorator.detach(this)
         })
@@ -455,13 +341,7 @@ export class TerminalTabComponent extends BaseTabComponent {
         if (this.sessionCloseSubscription) {
             this.sessionCloseSubscription.unsubscribe()
         }
-        this.resize_.complete()
-        this.input$.complete()
         this.output$.complete()
-        this.contentUpdated_.complete()
-        this.alternateScreenActive$.complete()
-        this.mouseEvent$.complete()
-        this.bell$.complete()
     }
 
     async destroy () {
@@ -481,8 +361,4 @@ export class TerminalTabComponent extends BaseTabComponent {
         }
         return confirm(`"${children[0].command}" is still running. Close?`)
     }
-
-    private setFontSize () {
-        preferenceManager.set('font-size', this.config.store.terminal.fontSize * Math.pow(1.1, this.zoom))
-    }
 }

+ 3 - 1
terminus-terminal/src/index.ts

@@ -14,6 +14,7 @@ import { ColorPickerComponent } from './components/colorPicker.component'
 
 import { SessionsService, BaseSession } from './services/sessions.service'
 import { TerminalService } from './services/terminal.service'
+import { TerminalContainersService } from './services/terminalContainers.service'
 
 import { ScreenPersistenceProvider } from './persistence/screen'
 import { TMuxPersistenceProvider } from './persistence/tmux'
@@ -50,6 +51,7 @@ import { hterm } from './hterm'
     providers: [
         SessionsService,
         TerminalService,
+        TerminalContainersService,
 
         { provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
         { provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
@@ -123,4 +125,4 @@ export default class TerminalModule {
 }
 
 export * from './api'
-export { TerminalService, BaseSession, TerminalTabComponent }
+export { TerminalService, BaseSession, TerminalTabComponent, TerminalContainersService }

+ 19 - 11
terminus-terminal/src/pathDrop.ts

@@ -1,20 +1,25 @@
+import { Subscription } from 'rxjs'
 import { Injectable } from '@angular/core'
 import { TerminalDecorator } from './api'
 import { TerminalTabComponent } from './components/terminalTab.component'
 
 @Injectable()
 export class PathDropDecorator extends TerminalDecorator {
+    private subscriptions: Subscription[] = []
+
     attach (terminal: TerminalTabComponent): void {
         setTimeout(() => {
-            terminal.hterm.scrollPort_.document_.addEventListener('dragover', (event) => {
-                event.preventDefault()
-            })
-            terminal.hterm.scrollPort_.document_.addEventListener('drop', (event) => {
-                for (let file of event.dataTransfer.files) {
-                    this.injectPath(terminal, file.path)
-                }
-                event.preventDefault()
-            })
+            this.subscriptions = [
+                terminal.termContainer.dragOver$.subscribe(event => {
+                    event.preventDefault()
+                }),
+                terminal.termContainer.drop$.subscribe(event => {
+                    for (let file of event.dataTransfer.files as any) {
+                        this.injectPath(terminal, file.path)
+                    }
+                    event.preventDefault()
+                }),
+            ]
         })
     }
 
@@ -25,6 +30,9 @@ export class PathDropDecorator extends TerminalDecorator {
         terminal.sendInput(path + ' ')
     }
 
-    // tslint:disable-next-line no-empty
-    detach (terminal: TerminalTabComponent): void { }
+    detach (terminal: TerminalTabComponent): void {
+        for (let s of this.subscriptions) {
+            s.unsubscribe()
+        }
+    }
 }

+ 5 - 5
terminus-terminal/src/services/sessions.service.ts

@@ -49,6 +49,7 @@ export abstract class BaseSession {
         this.initialDataBuffer = null
     }
 
+    abstract start (options: SessionOptions)
     abstract resize (columns, rows)
     abstract write (data)
     abstract kill (signal?: string)
@@ -70,8 +71,7 @@ export abstract class BaseSession {
 export class Session extends BaseSession {
     private pty: any
 
-    constructor (options: SessionOptions) {
-        super()
+    start (options: SessionOptions) {
         this.name = options.name
         this.recoveryId = options.recoveryId
 
@@ -200,7 +200,7 @@ export class Session extends BaseSession {
 
 @Injectable()
 export class SessionsService {
-    sessions: {[id: string]: Session} = {}
+    sessions: {[id: string]: BaseSession} = {}
     logger: Logger
     private lastID = 0
 
@@ -225,10 +225,10 @@ export class SessionsService {
         return options
     }
 
-    addSession (options: SessionOptions): Session {
+    addSession (session: BaseSession, options: SessionOptions) {
         this.lastID++
         options.name = `session-${this.lastID}`
-        let session = new Session(options)
+        session.start(options)
         let persistence = this.getPersistence()
         session.destroyed$.pipe(first()).subscribe(() => {
             delete this.sessions[session.name]

+ 16 - 0
terminus-terminal/src/services/terminalContainers.service.ts

@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core'
+import { TermContainer } from '../terminalContainers/termContainer'
+import { HTermContainer } from '../terminalContainers/htermContainer'
+import { BaseSession } from '../services/sessions.service'
+
+@Injectable()
+export class TerminalContainersService {
+    private containers = new WeakMap<BaseSession, TermContainer>()
+
+    getContainer (session: BaseSession): TermContainer {
+        if (!this.containers.has(session)) {
+            this.containers.set(session, new HTermContainer())
+        }
+        return this.containers.get(session)
+    }
+}

+ 226 - 0
terminus-terminal/src/terminalContainers/htermContainer.ts

@@ -0,0 +1,226 @@
+import { TermContainer } from './termContainer'
+import { hterm, preferenceManager } from '../hterm'
+
+export class HTermContainer extends TermContainer {
+    term: any
+    io: any
+    private htermIframe: HTMLElement
+    private initialized = false
+    private configuredFontSize = 0
+    private configuredLinePadding = 0
+    private zoom = 0
+
+    attach (host: HTMLElement) {
+        if (!this.initialized) {
+            this.init()
+            this.initialized = true
+            this.term.decorate(host)
+            this.htermIframe = this.term.scrollPort_.iframe_
+        } else {
+            host.appendChild(this.htermIframe)
+        }
+    }
+
+    getSelection (): string {
+        return this.term.getSelectionText()
+    }
+
+    copySelection () {
+        this.term.copySelectionToClipboard()
+    }
+
+    clearSelection () {
+        this.term.getDocument().getSelection().removeAllRanges()
+    }
+
+    focus () {
+        setTimeout(() => {
+            this.term.scrollPort_.resize()
+            this.term.scrollPort_.focus()
+        }, 100)
+    }
+
+    write (data: string): void {
+        this.io.writeUTF8(data)
+    }
+
+    clear (): void {
+        this.term.wipeContents()
+        this.term.onVTKeystroke('\f')
+    }
+
+    configure (config: any): void {
+        this.configuredFontSize = config.terminal.fontSize
+        this.configuredLinePadding = config.terminal.linePadding
+        this.setFontSize()
+
+        preferenceManager.set('font-family', `"${config.terminal.font}", "monospace-fallback", monospace`)
+        preferenceManager.set('enable-bold', true)
+        // preferenceManager.set('audible-bell-sound', '')
+        preferenceManager.set('desktop-notification-bell', config.terminal.bell === 'notification')
+        preferenceManager.set('enable-clipboard-notice', false)
+        preferenceManager.set('receive-encoding', 'raw')
+        preferenceManager.set('send-encoding', 'raw')
+        preferenceManager.set('ctrl-plus-minus-zero-zoom', false)
+        preferenceManager.set('scrollbar-visible', process.platform === 'darwin')
+        preferenceManager.set('copy-on-select', config.terminal.copyOnSelect)
+        preferenceManager.set('alt-is-meta', config.terminal.altIsMeta)
+        preferenceManager.set('alt-sends-what', 'browser-key')
+        preferenceManager.set('alt-gr-mode', 'ctrl-alt')
+        preferenceManager.set('pass-alt-number', true)
+        preferenceManager.set('cursor-blink', config.terminal.cursorBlink)
+        preferenceManager.set('clear-selection-after-copy', true)
+
+        if (config.terminal.colorScheme.foreground) {
+            preferenceManager.set('foreground-color', config.terminal.colorScheme.foreground)
+        }
+
+        if (config.terminal.background === 'colorScheme') {
+            if (config.terminal.colorScheme.background) {
+                preferenceManager.set('background-color', config.terminal.colorScheme.background)
+            }
+        } else {
+            // hterm can't parse "transparent"
+            preferenceManager.set('background-color', 'transparent')
+        }
+
+        if (config.terminal.colorScheme.colors) {
+            preferenceManager.set('color-palette-overrides', config.terminal.colorScheme.colors)
+        }
+        if (config.terminal.colorScheme.cursor) {
+            preferenceManager.set('cursor-color', config.terminal.colorScheme.cursor)
+        }
+
+        let css = require('../hterm.userCSS.scss')
+        if (!config.terminal.ligatures) {
+            css += `
+                * {
+                    font-feature-settings: "liga" 0;
+                    font-variant-ligatures: none;
+                }
+            `
+        } else {
+            css += `
+                * {
+                    font-feature-settings: "liga" 1;
+                    font-variant-ligatures: initial;
+                }
+            `
+        }
+        css += config.appearance.css
+        this.term.setCSS(css)
+        this.term.setBracketedPaste(config.terminal.bracketedPaste)
+        this.term.defaultCursorShape = {
+            block: hterm.hterm.Terminal.cursorShape.BLOCK,
+            underline: hterm.hterm.Terminal.cursorShape.UNDERLINE,
+            beam: hterm.hterm.Terminal.cursorShape.BEAM,
+        }[config.terminal.cursor]
+        this.term.applyCursorShape()
+        this.term.setCursorBlink(config.terminal.cursorBlink)
+        if (config.terminal.cursorBlink) {
+            this.term.onCursorBlink_()
+        }
+    }
+
+    setZoom (zoom: number): void {
+        this.zoom = zoom
+        this.setFontSize()
+    }
+
+    visualBell (): void {
+        const color = preferenceManager.get('background-color')
+        preferenceManager.set('background-color', 'rgba(128,128,128,.25)')
+        setTimeout(() => {
+            preferenceManager.set('background-color', color)
+        }, 125)
+    }
+
+    private setFontSize () {
+        preferenceManager.set('font-size', this.configuredFontSize * Math.pow(1.1, this.zoom))
+    }
+
+    private init () {
+        this.term = new hterm.hterm.Terminal()
+        this.term.onTerminalReady = () => {
+            this.term.installKeyboard()
+            this.term.scrollPort_.setCtrlVPaste(true)
+            this.io = this.term.io.push()
+            this.io.onVTKeystroke = this.io.sendString = data => this.input.next(data)
+            this.io.onTerminalResize = (columns, rows) => {
+                console.log('hterm resize')
+                this.resize.next({ columns, rows })
+            }
+            this.ready.next(null)
+            this.ready.complete()
+
+            this.term.scrollPort_.document_.addEventListener('dragOver', event => {
+                this.dragOver.next(event)
+            })
+
+            this.term.scrollPort_.document_.addEventListener('drop', event => {
+                this.drop.next(event)
+            })
+        }
+        this.term.setWindowTitle = title => this.title.next(title)
+
+        const _setAlternateMode = this.term.setAlternateMode.bind(this.term)
+        this.term.setAlternateMode = (state) => {
+            _setAlternateMode(state)
+            this.alternateScreenActive.next(state)
+        }
+
+        this.term.primaryScreen_.syncSelectionCaret = () => null
+        this.term.alternateScreen_.syncSelectionCaret = () => null
+        this.term.primaryScreen_.terminal = this.term
+        this.term.alternateScreen_.terminal = this.term
+
+        this.term.scrollPort_.onPaste_ = (event) => {
+            event.preventDefault()
+        }
+
+        const _resize = this.term.scrollPort_.resize.bind(this.term.scrollPort_)
+        this.term.scrollPort_.resize = () => {
+            if (this.enableResizing) {
+                _resize()
+            }
+        }
+
+        const _onMouse = this.term.onMouse_.bind(this.term)
+        this.term.onMouse_ = (event) => {
+            this.mouseEvent.next(event)
+            if (event.type === 'mousedown' && event.which === 3) {
+                event.preventDefault()
+                event.stopPropagation()
+                return
+            }
+            if (event.type === 'mousewheel' && event.altKey) {
+                event.preventDefault()
+            }
+            _onMouse(event)
+        }
+
+        this.term.ringBell = () => this.bell.next()
+
+        for (let screen of [this.term.primaryScreen_, this.term.alternateScreen_]) {
+            const _insertString = screen.insertString.bind(screen)
+            screen.insertString = (data) => {
+                _insertString(data)
+                this.contentUpdated.next()
+            }
+
+            const _deleteChars = screen.deleteChars.bind(screen)
+            screen.deleteChars = (count) => {
+                let ret = _deleteChars(count)
+                this.contentUpdated.next()
+                return ret
+            }
+        }
+
+        const _measureCharacterSize = this.term.scrollPort_.measureCharacterSize.bind(this.term.scrollPort_)
+        this.term.scrollPort_.measureCharacterSize = () => {
+            let size = _measureCharacterSize()
+            size.height += this.configuredLinePadding
+            return size
+        }
+    }
+}

+ 56 - 0
terminus-terminal/src/terminalContainers/termContainer.ts

@@ -0,0 +1,56 @@
+import { Observable, Subject, AsyncSubject, ReplaySubject, BehaviorSubject } from 'rxjs'
+
+export abstract class TermContainer {
+    enableResizing = true
+    protected ready = new AsyncSubject<void>()
+    protected title = new ReplaySubject<string>(1)
+    protected alternateScreenActive = new BehaviorSubject<boolean>(false)
+    protected mouseEvent = new Subject<MouseEvent>()
+    protected bell = new Subject<void>()
+    protected contentUpdated = new Subject<void>()
+    protected input = new Subject<string>()
+    protected resize = new ReplaySubject<{columns: number, rows: number}>(1)
+    protected dragOver = new Subject<DragEvent>()
+    protected drop = new Subject<DragEvent>()
+
+    get ready$ (): Observable<void> { return this.ready }
+    get title$ (): Observable<string> { return this.title }
+    get alternateScreenActive$ (): Observable<boolean> { return this.alternateScreenActive }
+    get mouseEvent$ (): Observable<MouseEvent> { return this.mouseEvent }
+    get bell$ (): Observable<void> { return this.bell }
+    get contentUpdated$ (): Observable<void> { return this.contentUpdated }
+    get input$ (): Observable<string> { return this.input }
+    get resize$ (): Observable<{columns: number, rows: number}> { return this.resize }
+    get dragOver$ (): Observable<DragEvent> { return this.dragOver }
+    get drop$ (): Observable<DragEvent> { return this.drop }
+
+    abstract attach (host: HTMLElement): void
+
+    destroy (): void {
+        for (let o of [
+            this.ready,
+            this.title,
+            this.alternateScreenActive,
+            this.mouseEvent,
+            this.bell,
+            this.contentUpdated,
+            this.input,
+            this.resize,
+            this.dragOver,
+            this.drop,
+        ]) {
+            o.complete()
+        }
+    }
+
+    abstract getSelection (): string
+    abstract copySelection (): void
+    abstract clearSelection (): void
+    abstract focus (): void
+    abstract write (data: string): void
+    abstract clear (): void
+    abstract visualBell (): void
+
+    abstract configure (configStore: any): void
+    abstract setZoom (zoom: number): void
+}