From 70b463b086cfcd9eebac5711b1181b3dbf14ecae Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Sun, 3 Mar 2019 22:56:58 +0100 Subject: [PATCH] splits WIP (#49) --- terminus-core/src/api/index.ts | 1 + terminus-core/src/api/tabRecovery.ts | 2 +- .../src/components/baseTab.component.ts | 3 - .../src/components/splitTab.component.ts | 283 ++++++++++++++++++ terminus-core/src/configDefaults.linux.yaml | 14 + terminus-core/src/configDefaults.macos.yaml | 14 + terminus-core/src/configDefaults.windows.yaml | 14 + terminus-core/src/index.ts | 3 + terminus-core/src/services/app.service.ts | 48 +-- terminus-core/src/services/hotkeys.service.ts | 32 ++ terminus-core/src/services/tabs.service.ts | 38 +++ terminus-terminal/package.json | 4 +- .../src/frontends/xtermFrontend.ts | 3 + 13 files changed, 431 insertions(+), 28 deletions(-) create mode 100644 terminus-core/src/components/splitTab.component.ts create mode 100644 terminus-core/src/services/tabs.service.ts diff --git a/terminus-core/src/api/index.ts b/terminus-core/src/api/index.ts index 6cb04371..56f7eec4 100644 --- a/terminus-core/src/api/index.ts +++ b/terminus-core/src/api/index.ts @@ -16,3 +16,4 @@ export { HotkeysService } from '../services/hotkeys.service' export { HostAppService, Platform } from '../services/hostApp.service' export { ShellIntegrationService } from '../services/shellIntegration.service' export { ThemesService } from '../services/themes.service' +export { TabsService } from '../services/tabs.service' diff --git a/terminus-core/src/api/tabRecovery.ts b/terminus-core/src/api/tabRecovery.ts index b4f3105b..b14fc454 100644 --- a/terminus-core/src/api/tabRecovery.ts +++ b/terminus-core/src/api/tabRecovery.ts @@ -1,4 +1,4 @@ -import { TabComponentType } from '../services/app.service' +import { TabComponentType } from '../services/tabs.service' export interface RecoveredTab { type: TabComponentType, diff --git a/terminus-core/src/components/baseTab.component.ts b/terminus-core/src/components/baseTab.component.ts index f185d07d..808c6481 100644 --- a/terminus-core/src/components/baseTab.component.ts +++ b/terminus-core/src/components/baseTab.component.ts @@ -6,8 +6,6 @@ export interface BaseTabProcess { } export abstract class BaseTabComponent { - private static lastTabID = 0 - id: number title: string customTitle: string hasFocus = false @@ -31,7 +29,6 @@ export abstract class BaseTabComponent { get destroyed$ (): Observable { return this.destroyed } constructor () { - this.id = BaseTabComponent.lastTabID++ this.focused$.subscribe(() => { this.hasFocus = true }) diff --git a/terminus-core/src/components/splitTab.component.ts b/terminus-core/src/components/splitTab.component.ts new file mode 100644 index 00000000..7cbd1d3e --- /dev/null +++ b/terminus-core/src/components/splitTab.component.ts @@ -0,0 +1,283 @@ +import { Subscription } from 'rxjs' +import { Component, ViewChild, ViewContainerRef, EmbeddedViewRef } from '@angular/core' +import { BaseTabComponent } from './baseTab.component' +import { TabsService } from '../services/tabs.service' +import { HotkeysService } from '../services/hotkeys.service' + +export declare type SplitOrientation = 'v' | 'h' +export declare type SplitDirection = 'r' | 't' | 'b' | 'l' + +export class SplitContainer { + orientation: SplitOrientation = 'h' + children: (BaseTabComponent | SplitContainer)[] = [] + ratios: number[] = [] + + allTabs () { + let r = [] + for (let child of this.children) { + if (child instanceof SplitContainer) { + r = r.concat(child.allTabs()) + } else { + r.push(child) + } + } + return r + } + + normalize () { + for (let i = 0; i < this.children.length; i++) { + let child = this.children[i] + + if (child instanceof SplitContainer) { + child.normalize() + + if (child.children.length === 0) { + this.children.splice(i, 1) + this.ratios.splice(i, 1) + i-- + continue + } else if (child.children.length === 1) { + this.children[i] = child.children[0] + } else if (child.orientation === this.orientation) { + let ratio = this.ratios[i] + this.children.splice(i, 1) + this.ratios.splice(i, 1) + for (let j = 0; j < child.children.length; j++) { + this.children.splice(i, 0, child.children[j]) + this.ratios.splice(i, 0, child.ratios[j] * ratio) + i++ + } + } + } + } + + let s = 0 + for (let x of this.ratios) { + s += x + } + this.ratios = this.ratios.map(x => x / s) + } +} + +@Component({ + selector: 'splitTab', + template: '', + styles: [ + ':host { position: relative; flex: auto; display: block; }' + ] +}) +export class SplitTabComponent extends BaseTabComponent { + root: SplitContainer + viewRefs: Map> = new Map() + @ViewChild('vc', { read: ViewContainerRef }) viewContainer: ViewContainerRef + hotkeysSubscription: Subscription + focusedTab: BaseTabComponent + + constructor ( + protected hotkeys: HotkeysService, + private tabsService: TabsService, + ) { + super() + this.root = new SplitContainer() + this.setTitle('') + + this.focused$.subscribe(() => { + this.allTabs().forEach(x => x.emitFocused()) + this.focus(this.focusedTab) + }) + this.blurred$.subscribe(() => this.allTabs().forEach(x => x.emitBlurred())) + + this.hotkeysSubscription = this.hotkeys.matchedHotkey.subscribe(hotkey => { + if (!this.hasFocus) { + return + } + switch (hotkey) { + case 'split-right': + this.splitTab(this.focusedTab, 'r') + break + case 'split-bottom': + this.splitTab(this.focusedTab, 'b') + break + case 'split-top': + this.splitTab(this.focusedTab, 't') + break + case 'split-left': + this.splitTab(this.focusedTab, 'l') + break + case 'split-nav-left': + this.navigate('l') + break + case 'split-nav-right': + this.navigate('r') + break + case 'split-nav-up': + this.navigate('t') + break + case 'split-nav-down': + this.navigate('b') + break + } + }) + } + + ngOnDestroy () { + this.hotkeysSubscription.unsubscribe() + } + + allTabs () { + return [...this.root.allTabs()] + } + + focus (tab: BaseTabComponent) { + this.focusedTab = tab + for (let x of this.allTabs()) { + if (x !== tab) { + x.emitBlurred() + } + } + if (tab) { + tab.emitFocused() + } + this.layout() + } + + focusAnyIn (parent: BaseTabComponent | SplitContainer) { + if (!parent) { + return + } + if (parent instanceof SplitContainer) { + this.focusAnyIn(parent.children[0]) + } else { + this.focus(parent) + } + } + + insert (tab: BaseTabComponent, relative: BaseTabComponent, dir: SplitDirection) { + let target = this.getParent(relative) || this.root + let insertIndex = target.children.indexOf(relative) + + if ( + (target.orientation === 'v' && ['l', 'r'].includes(dir)) || + (target.orientation === 'h' && ['t', 'b'].includes(dir)) + ) { + let newContainer = new SplitContainer() + newContainer.orientation = (target.orientation === 'v') ? 'h' : 'v' + newContainer.children = [relative] + newContainer.ratios = [1] + target.children[insertIndex] = newContainer + target = newContainer + insertIndex = 0 + } + + if (insertIndex === -1) { + insertIndex = 0 + } else { + insertIndex += (dir === 'l' || dir === 't') ? 0 : 1 + } + + for (let i = 0; i < target.children.length; i++) { + target.ratios[i] *= target.children.length / (target.children.length + 1) + } + target.ratios.splice(insertIndex, 0, 1 / (target.children.length + 1)) + target.children.splice(insertIndex, 0, tab) + + let ref = this.viewContainer.insert(tab.hostView) as EmbeddedViewRef + this.viewRefs.set(tab, ref) + + ref.rootNodes[0].addEventListener('click', () => this.focus(tab)) + + setImmediate(() => { + this.layout() + this.focus(tab) + }) + + tab.titleChange$.subscribe(t => this.setTitle(t)) + tab.activity$.subscribe(a => a ? this.displayActivity() : this.clearActivity()) + tab.progress$.subscribe(p => this.setProgress(p)) + if (tab.title) { + this.setTitle(tab.title) + } + } + + navigate (dir: SplitDirection) { + let rel: BaseTabComponent | SplitContainer = this.focusedTab + let parent = this.getParent(rel) + let orientation = ['l', 'r'].includes(dir) ? 'h' : 'v' + + while (parent !== this.root && parent.orientation !== orientation) { + rel = parent + parent = this.getParent(rel) + } + + if (parent.orientation !== orientation) { + return + } + + let index = parent.children.indexOf(rel) + if (['l', 't'].includes(dir)) { + if (index > 0) { + this.focusAnyIn(parent.children[index - 1]) + } + } else { + if (index < parent.children.length - 1) { + this.focusAnyIn(parent.children[index + 1]) + } + } + } + + async splitTab (tab: BaseTabComponent, dir: SplitDirection) { + let newTab = await this.tabsService.duplicate(tab) + this.insert(newTab, tab, dir) + } + + getParent (tab: BaseTabComponent | SplitContainer, root?: SplitContainer): SplitContainer { + root = root || this.root + for (let child of root.children) { + if (child instanceof SplitContainer) { + let r = this.getParent(tab, child) + if (r) { + return r + } + } + if (child === tab) { + return root + } + } + return null + } + + async canClose (): Promise { + return !(await Promise.all(this.allTabs().map(x => x.canClose()))).some(x => !x) + } + + private layout () { + this.root.normalize() + this.layoutInternal(this.root, 0, 0, 100, 100) + } + + private layoutInternal (root: SplitContainer, x: number, y: number, w: number, h: number) { + let size = (root.orientation === 'v') ? h : w + let sizes = root.ratios.map(x => x * size) + + let offset = 0 + root.children.forEach((child, i) => { + let childX = (root.orientation === 'v') ? x : (x + offset) + let childY = (root.orientation === 'v') ? (y + offset) : y + let childW = (root.orientation === 'v') ? w : sizes[i] + let childH = (root.orientation === 'v') ? sizes[i] : h + if (child instanceof SplitContainer) { + this.layoutInternal(child, childX, childY, childW, childH) + } else { + let element = this.viewRefs.get(child).rootNodes[0] + element.style.position = 'absolute' + element.style.left = `${childX}%` + element.style.top = `${childY}%` + element.style.width = `${childW}%` + element.style.height = `${childH}%` + + element.style.opacity = (child === this.focusedTab) ? 1 : 0.75 + } + offset += sizes[i] + }) + } +} diff --git a/terminus-core/src/configDefaults.linux.yaml b/terminus-core/src/configDefaults.linux.yaml index ae8ab804..5edb8c94 100644 --- a/terminus-core/src/configDefaults.linux.yaml +++ b/terminus-core/src/configDefaults.linux.yaml @@ -36,4 +36,18 @@ hotkeys: - 'Alt-9' tab-10: - 'Alt-0' + split-right: + - 'Ctrl-Shift-E' + split-bottom: + - 'Ctrl-Shift-D' + split-left: [] + split-top: [] + split-nav-right: + - 'Ctrl-Alt-ArrowRight' + split-nav-down: + - 'Ctrl-Alt-ArrowDown' + split-nav-up: + - 'Ctrl-Alt-ArrowUp' + split-nav-left: + - 'Ctrl-Alt-ArrowLeft' pluginBlacklist: ['ssh'] diff --git a/terminus-core/src/configDefaults.macos.yaml b/terminus-core/src/configDefaults.macos.yaml index a5355344..616c8d0a 100644 --- a/terminus-core/src/configDefaults.macos.yaml +++ b/terminus-core/src/configDefaults.macos.yaml @@ -34,4 +34,18 @@ hotkeys: - '⌘-9' tab-10: - '⌘-0' + split-right: + - '⌘-Shift-D' + split-bottom: + - '⌘-D' + split-left: [] + split-top: [] + split-nav-right: + - '⌘-⌥-ArrowRight' + split-nav-down: + - '⌘-⌥-ArrowDown' + split-nav-up: + - '⌘-⌥-ArrowUp' + split-nav-left: + - '⌘-⌥-ArrowLeft' pluginBlacklist: ['ssh'] diff --git a/terminus-core/src/configDefaults.windows.yaml b/terminus-core/src/configDefaults.windows.yaml index 1c62c816..ccfa3726 100644 --- a/terminus-core/src/configDefaults.windows.yaml +++ b/terminus-core/src/configDefaults.windows.yaml @@ -36,4 +36,18 @@ hotkeys: - 'Alt-9' tab-10: - 'Alt-0' + split-right: + - 'Ctrl-Shift-E' + split-bottom: + - 'Ctrl-Shift-D' + split-left: [] + split-top: [] + split-nav-right: + - 'Ctrl-Alt-ArrowRight' + split-nav-down: + - 'Ctrl-Alt-ArrowDown' + split-nav-up: + - 'Ctrl-Alt-ArrowUp' + split-nav-left: + - 'Ctrl-Alt-ArrowLeft' pluginBlacklist: [] diff --git a/terminus-core/src/index.ts b/terminus-core/src/index.ts index e49223d6..08ac7541 100644 --- a/terminus-core/src/index.ts +++ b/terminus-core/src/index.ts @@ -18,6 +18,7 @@ import { TitleBarComponent } from './components/titleBar.component' import { ToggleComponent } from './components/toggle.component' import { WindowControlsComponent } from './components/windowControls.component' import { RenameTabModalComponent } from './components/renameTabModal.component' +import { SplitTabComponent } from './components/splitTab.component' import { AutofocusDirective } from './directives/autofocus.directive' @@ -66,10 +67,12 @@ const PROVIDERS = [ RenameTabModalComponent, SafeModeModalComponent, AutofocusDirective, + SplitTabComponent, ], entryComponents: [ RenameTabModalComponent, SafeModeModalComponent, + SplitTabComponent, ], exports: [ CheckboxComponent, diff --git a/terminus-core/src/services/app.service.ts b/terminus-core/src/services/app.service.ts index fe4ec348..a05f4196 100644 --- a/terminus-core/src/services/app.service.ts +++ b/terminus-core/src/services/app.service.ts @@ -1,13 +1,13 @@ import { Observable, Subject, AsyncSubject } from 'rxjs' import { takeUntil } from 'rxjs/operators' -import { Injectable, ComponentFactoryResolver, Injector } from '@angular/core' +import { Injectable } from '@angular/core' import { BaseTabComponent } from '../components/baseTab.component' +import { SplitTabComponent } from '../components/splitTab.component' import { Logger, LogService } from './log.service' import { ConfigService } from './config.service' import { HostAppService } from './hostApp.service' import { TabRecoveryService } from './tabRecovery.service' - -export declare type TabComponentType = new (...args: any[]) => BaseTabComponent +import { TabsService, TabComponentType } from './tabs.service' class CompletionObserver { get done$ (): Observable { return this.done } @@ -58,19 +58,20 @@ export class AppService { get ready$ (): Observable { return this.ready } constructor ( - private componentFactoryResolver: ComponentFactoryResolver, private config: ConfigService, private hostApp: HostAppService, - private injector: Injector, private tabRecovery: TabRecoveryService, + private tabsService: TabsService, log: LogService, ) { this.logger = log.create('app') this.hostApp.windowCloseRequest$.subscribe(() => this.closeWindow()) - this.tabRecovery.recoverTabs().then(tabs => { - for (let tab of tabs) { + /*this.tabRecovery.recoverTabs().then(tabs => { + for (let + this.openNewTab(tab.type, tab.options) + tab of tabs) { this.openNewTab(tab.type, tab.options) } @@ -80,16 +81,10 @@ export class AppService { setInterval(() => { tabRecovery.saveTabs(this.tabs) }, 30000) - }) + })*/ } - openNewTab (type: TabComponentType, inputs?: any): BaseTabComponent { - let componentFactory = this.componentFactoryResolver.resolveComponentFactory(type) - let componentRef = componentFactory.create(this.injector) - let tab = componentRef.instance - tab.hostView = componentRef.hostView - Object.assign(tab, inputs || {}) - + addTabRaw (tab: BaseTabComponent) { this.tabs.push(tab) this.selectTab(tab) this.tabsChanged.next() @@ -100,6 +95,19 @@ export class AppService { this.hostApp.setTitle(title) } }) + } + + openNewTabRaw (type: TabComponentType, inputs?: any): BaseTabComponent { + let tab = this.tabsService.create(type, inputs) + this.addTabRaw(tab) + return tab + } + + openNewTab (type: TabComponentType, inputs?: any): BaseTabComponent { + let splitTab = this.tabsService.create(SplitTabComponent) as SplitTabComponent + let tab = this.tabsService.create(type, inputs) + splitTab.insert(tab, null, 'r') + this.addTabRaw(splitTab) return tab } @@ -178,13 +186,9 @@ export class AppService { } async duplicateTab (tab: BaseTabComponent) { - let token = await tab.getRecoveryToken() - if (!token) { - return - } - let recoveredTab = await this.tabRecovery.recoverTab(token) - if (recoveredTab) { - this.openNewTab(recoveredTab.type, recoveredTab.options) + let dup = await this.tabsService.duplicate(tab) + if (dup) { + this.addTabRaw(dup) } } diff --git a/terminus-core/src/services/hotkeys.service.ts b/terminus-core/src/services/hotkeys.service.ts index ff371c70..85348660 100644 --- a/terminus-core/src/services/hotkeys.service.ts +++ b/terminus-core/src/services/hotkeys.service.ts @@ -275,6 +275,38 @@ export class AppHotkeyProvider extends HotkeyProvider { id: 'tab-10', name: 'Tab 10', }, + { + id: 'split-right', + name: 'Split to the right', + }, + { + id: 'split-bottom', + name: 'Split to the bottom', + }, + { + id: 'split-left', + name: 'Split to the left', + }, + { + id: 'split-top', + name: 'Split to the top', + }, + { + id: 'split-nav-up', + name: 'Focus the pane above', + }, + { + id: 'split-nav-down', + name: 'Focus the pane below', + }, + { + id: 'split-nav-left', + name: 'Focus the pane on the left', + }, + { + id: 'split-nav-right', + name: 'Focus the pane on the right', + }, ] async provide (): Promise { diff --git a/terminus-core/src/services/tabs.service.ts b/terminus-core/src/services/tabs.service.ts new file mode 100644 index 00000000..5389c0f4 --- /dev/null +++ b/terminus-core/src/services/tabs.service.ts @@ -0,0 +1,38 @@ +import { Injectable, ComponentFactoryResolver, Injector } from '@angular/core' +import { BaseTabComponent } from '../components/baseTab.component' +import { TabRecoveryService } from './tabRecovery.service' + +export declare type TabComponentType = new (...args: any[]) => BaseTabComponent + +@Injectable({ providedIn: 'root' }) +export class TabsService { + constructor ( + private componentFactoryResolver: ComponentFactoryResolver, + private injector: Injector, + private tabRecovery: TabRecoveryService, + ) { + + } + + create (type: TabComponentType, inputs?: any): BaseTabComponent { + let componentFactory = this.componentFactoryResolver.resolveComponentFactory(type) + let componentRef = componentFactory.create(this.injector) + let tab = componentRef.instance + tab.hostView = componentRef.hostView + Object.assign(tab, inputs || {}) + return tab + } + + async duplicate (tab: BaseTabComponent): Promise { + let token = await tab.getRecoveryToken() + if (!token) { + return null + } + let dup = await this.tabRecovery.recoverTab(token) + if (dup) { + return this.create(dup.type, dup.options) + } + return null + } + +} diff --git a/terminus-terminal/package.json b/terminus-terminal/package.json index 4889dd38..0b06ac5c 100644 --- a/terminus-terminal/package.json +++ b/terminus-terminal/package.json @@ -46,11 +46,11 @@ "mz": "^2.6.0", "node-pty": "^0.8.0", "ps-node": "^0.1.6", - "runes": "^0.4.2", - "windows-native-registry": "^1.0.6" + "runes": "^0.4.2" }, "optionalDependencies": { "macos-native-processlist": "^1.0.0", + "windows-native-registry": "^1.0.6", "windows-process-tree": "^0.2.3" }, "false": {} diff --git a/terminus-terminal/src/frontends/xtermFrontend.ts b/terminus-terminal/src/frontends/xtermFrontend.ts index 2d2f1bd6..84790074 100644 --- a/terminus-terminal/src/frontends/xtermFrontend.ts +++ b/terminus-terminal/src/frontends/xtermFrontend.ts @@ -79,6 +79,9 @@ export class XTermFrontend extends Frontend { host.addEventListener('mousedown', event => this.mouseEvent.next(event as MouseEvent)) host.addEventListener('mouseup', event => this.mouseEvent.next(event as MouseEvent)) host.addEventListener('mousewheel', event => this.mouseEvent.next(event as MouseEvent)) + + let ro = new window['ResizeObserver'](() => this.resizeHandler()) + ro.observe(host) } detach (host: HTMLElement): void {