UNPKG

@jupyterlab/application

Version:
1,769 lines (1,578 loc) 84.2 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /* eslint-disable @typescript-eslint/no-explicit-any */ import type { DocumentRegistry } from '@jupyterlab/docregistry'; import { DocumentWidget } from '@jupyterlab/docregistry'; import type { ITranslator } from '@jupyterlab/translation'; import { nullTranslator } from '@jupyterlab/translation'; import type { SidePanel } from '@jupyterlab/ui-components'; import { classes, LabIcon, TabBarSvg, tabIcon, TabPanelSvg } from '@jupyterlab/ui-components'; import { ArrayExt, find, map } from '@lumino/algorithm'; import { JSONExt, PromiseDelegate, Token } from '@lumino/coreutils'; import type { IMessageHandler, Message } from '@lumino/messaging'; import { MessageLoop } from '@lumino/messaging'; import { Debouncer } from '@lumino/polling'; import type { ISignal } from '@lumino/signaling'; import { Signal } from '@lumino/signaling'; import type { DockLayout, DockPanel, TabPanel, Title } from '@lumino/widgets'; import { AccordionPanel, BoxLayout, BoxPanel, FocusTracker, Panel, SplitPanel, StackedPanel, TabBar, Widget } from '@lumino/widgets'; import type { JupyterFrontEnd } from './frontend'; import type { LayoutRestorer } from './layoutrestorer'; import { OptimizedDockPanelSvg } from './dockpanel'; /** * The class name added to AppShell instances. */ const APPLICATION_SHELL_CLASS = 'jp-LabShell'; /** * The class name added to side bar instances. */ const SIDEBAR_CLASS = 'jp-SideBar'; /** * The class name added to the current widget's title. */ const CURRENT_CLASS = 'jp-mod-current'; /** * The class name added to the active widget's title. */ const ACTIVE_CLASS = 'jp-mod-active'; /** * The default rank of items added to a sidebar. */ const DEFAULT_RANK = 900; const ACTIVITY_CLASS = 'jp-Activity'; /** * The default relative size of the down area when it is expanded. */ const DEFAULT_DOWN_AREA_SIZE = 0.25; /** * The JupyterLab application shell token. */ export const ILabShell = new Token<ILabShell>( '@jupyterlab/application:ILabShell', 'A service for interacting with the JupyterLab shell. The top-level `application` object also has a reference to the shell, but it has a restricted interface in order to be agnostic to different shell implementations on the application. Use this to get more detailed information about currently active widgets and layout state.' ); /** * The JupyterLab application shell interface. */ export interface ILabShell extends LabShell {} /** * The namespace for `ILabShell` type information. */ export namespace ILabShell { /** * The areas of the application shell where widgets can reside. */ export type Area = | 'main' | 'header' | 'top' | 'menu' | 'left' | 'right' | 'bottom' | 'down'; /** * The restorable description of an area within the main dock panel. */ export type AreaConfig = DockLayout.AreaConfig; /** * An options object for creating a lab shell object. */ export type IOptions = { /** * Whether the layout should wait to be restored before adding widgets or not. * * #### Notes * It defaults to true */ waitForRestore?: boolean; }; /** * An arguments object for the changed signals. */ export type IChangedArgs = FocusTracker.IChangedArgs<Widget>; export interface IConfig { /** * The method for hiding widgets in the dock panel. * * The default is `display`. * * Using `scale` will often increase performance as most browsers will not trigger style computation * for the transform action. * * `contentVisibility` is only available in Chromium-based browsers. */ hiddenMode: 'display' | 'scale' | 'contentVisibility'; /** * Whether to show padding around the main dock panel area. * * Set to `false` for a more compact layout. */ dockPanelPadding?: boolean; /** * Whether to freeze panel dimensions during handle drag to improve resize * performance when panels contain heavy DOM content. * * The default is `true`. */ optimizeResize?: boolean; /** * Position of the side activity bars. * * The default is `'side'`, which keeps each activity bar on the natural * side of its area (left for the left area, right for the right area). * `'top'` and `'bottom'` move both activity bars to the top or bottom of * their respective area, displaying the tabs horizontally. */ activityBarPosition?: ActivityBarPosition; } /** * Position of a side activity bar within its area. * * `'side'` keeps the activity bar on the natural side of the area * (left for the left area, right for the right area). * `'top'` and `'bottom'` move the activity bar to the top or bottom * of the side area, displaying the tabs horizontally. */ export type ActivityBarPosition = 'side' | 'top' | 'bottom'; /** * Widget position */ export interface IWidgetPosition { /** * Widget area */ area?: Area; /** * Widget opening options */ options?: DocumentRegistry.IOpenOptions; } /** * To-be-added widget and associated position */ export interface IDelayedWidget extends IWidgetPosition { widget: Widget; } /** * Mapping of widget type identifier and their user customized position */ export interface IUserLayout { /** * Widget customized position */ [k: string]: IWidgetPosition; } /** * The args for the current path change signal. */ export interface ICurrentPathChangedArgs { /** * The new value of the tree path, not including '/tree'. */ oldValue: string; /** * The old value of the tree path, not including '/tree'. */ newValue: string; } /** * A description of the application's user interface layout. */ export interface ILayout { /** * Indicates whether fetched session restore data was actually retrieved * from the state database or whether it is a fresh blank slate. * * #### Notes * This attribute is only relevant when the layout data is retrieved via a * `fetch` call. If it is set when being passed into `save`, it will be * ignored. */ readonly fresh?: boolean; /** * The main area of the user interface. */ readonly mainArea: IMainArea | null; /** * The down area of the user interface. */ readonly downArea: IDownArea | null; /** * The left area of the user interface. */ readonly leftArea: ISideArea | null; /** * The right area of the user interface. */ readonly rightArea: ISideArea | null; /** * The top area of the user interface. */ readonly topArea: ITopArea | null; /** * The relatives sizes of the areas of the user interface. */ readonly relativeSizes: number[] | null; } /** * The restorable description of the main application area. */ export interface IMainArea { /** * The current widget that has application focus. */ readonly currentWidget: Widget | null; /** * The contents of the main application dock panel. */ readonly dock: DockLayout.ILayoutConfig | null; } export interface IDownArea { /** * A flag denoting whether the down area has been collapsed. */ readonly collapsed?: boolean; /** * The current widget that has down area focus. */ readonly currentWidget: Widget | null; /** * The collection of widgets held by the panel. */ readonly widgets: Array<Widget> | null; /** * Vertical relative size of the down area * * The main area will take the rest of the height */ readonly size: number | null; } /** * The restorable description of a sidebar in the user interface. */ export interface ISideArea { /** * A flag denoting whether the sidebar has been collapsed. */ readonly collapsed: boolean; /** * The current widget that has side area focus. */ readonly currentWidget: Widget | null; /** * A flag denoting whether the side tab bar is visible. */ readonly visible: boolean; /** * The collection of widgets held by the sidebar. */ readonly widgets: Array<Widget> | null; /** * The collection of widgets states held by the sidebar. */ readonly widgetStates: { [id: string]: { /** * Vertical sizes of the widgets. */ readonly sizes: Array<number> | null; /** * Expansion states of the widgets. */ readonly expansionStates: Array<boolean> | null; }; }; } /** * The restorable description of the top area in the user interface. */ export interface ITopArea { /** * Top area visibility in simple mode. */ readonly simpleVisibility: boolean; } } /** * The restorable description of the top area in the user interface. * * @deprecated It has been moved to {@link ILabShell.ITopArea} for consistency. */ export interface ITopArea extends ILabShell.ITopArea {} /** * The application shell for JupyterLab. */ export class LabShell extends Widget implements JupyterFrontEnd.IShell { /** * Construct a new application shell. */ constructor(options?: ILabShell.IOptions) { super(); this.addClass(APPLICATION_SHELL_CLASS); this.id = 'main'; if (options?.waitForRestore === false) { this._userLayout = { 'multiple-document': {}, 'single-document': {} }; } // Skip Links const skipLinkWidget = (this._skipLinkWidget = new Private.SkipLinkWidget( this )); this._skipLinkWidget.show(); // Wrap the skip widget to customize its position and size const skipLinkWrapper = new Panel(); skipLinkWrapper.addClass('jp-skiplink-wrapper'); skipLinkWrapper.addWidget(skipLinkWidget); const headerPanel = (this._headerPanel = new BoxPanel()); const menuHandler = (this._menuHandler = new Private.PanelHandler()); menuHandler.panel.node.setAttribute('role', 'navigation'); const topHandler = (this._topHandler = new Private.PanelHandler()); topHandler.panel.node.setAttribute('role', 'banner'); const bottomPanel = (this._bottomPanel = new BoxPanel()); bottomPanel.node.setAttribute('role', 'contentinfo'); const hboxPanel = new BoxPanel(); const vsplitPanel = (this._vsplitPanel = new Private.RestorableSplitPanel()); const dockPanel = (this._dockPanel = new OptimizedDockPanelSvg({ hiddenMode: Widget.HiddenMode.Display })); MessageLoop.installMessageHook(dockPanel, this._dockChildHook); const hsplitPanel = (this._hsplitPanel = new Private.RestorableSplitPanel()); const downPanel = (this._downPanel = new TabPanelSvg({ tabsMovable: true })); const leftHandler = (this._leftHandler = new Private.SideBarHandler({ side: 'left', host: hboxPanel })); const rightHandler = (this._rightHandler = new Private.SideBarHandler({ side: 'right', host: hboxPanel })); const rootLayout = new BoxLayout(); headerPanel.id = 'jp-header-panel'; menuHandler.panel.id = 'jp-menu-panel'; topHandler.panel.id = 'jp-top-panel'; bottomPanel.id = 'jp-bottom-panel'; hboxPanel.id = 'jp-main-content-panel'; vsplitPanel.id = 'jp-main-vsplit-panel'; dockPanel.id = 'jp-main-dock-panel'; hsplitPanel.id = 'jp-main-split-panel'; downPanel.id = 'jp-down-stack'; leftHandler.sideBar.addClass(SIDEBAR_CLASS); leftHandler.sideBar.addClass('jp-mod-left'); leftHandler.sideBar.node.setAttribute('role', 'complementary'); leftHandler.stackedPanel.id = 'jp-left-stack'; leftHandler.area.addClass('jp-SideArea'); leftHandler.area.node.setAttribute('data-side', 'left'); rightHandler.sideBar.addClass(SIDEBAR_CLASS); rightHandler.sideBar.addClass('jp-mod-right'); rightHandler.sideBar.node.setAttribute('role', 'complementary'); rightHandler.stackedPanel.id = 'jp-right-stack'; rightHandler.area.addClass('jp-SideArea'); rightHandler.area.node.setAttribute('data-side', 'right'); dockPanel.node.setAttribute('role', 'main'); hboxPanel.spacing = 0; vsplitPanel.spacing = 1; dockPanel.spacing = 5; hsplitPanel.spacing = 1; headerPanel.direction = 'top-to-bottom'; vsplitPanel.orientation = 'vertical'; hboxPanel.direction = 'left-to-right'; hsplitPanel.orientation = 'horizontal'; bottomPanel.direction = 'bottom-to-top'; SplitPanel.setStretch(leftHandler.area, 0); SplitPanel.setStretch(downPanel, 0); SplitPanel.setStretch(dockPanel, 1); SplitPanel.setStretch(rightHandler.area, 0); BoxPanel.setStretch(leftHandler.sideBar, 0); BoxPanel.setStretch(hsplitPanel, 1); BoxPanel.setStretch(rightHandler.sideBar, 0); SplitPanel.setStretch(vsplitPanel, 1); hsplitPanel.addWidget(leftHandler.area); hsplitPanel.addWidget(dockPanel); hsplitPanel.addWidget(rightHandler.area); vsplitPanel.addWidget(hsplitPanel); vsplitPanel.addWidget(downPanel); hboxPanel.addWidget(leftHandler.sideBar); hboxPanel.addWidget(vsplitPanel); hboxPanel.addWidget(rightHandler.sideBar); rootLayout.direction = 'top-to-bottom'; rootLayout.spacing = 0; // TODO make this configurable? // Use relative sizing to set the width of the side panels. // This will still respect the min-size of children widget in the stacked // panel. The default sizes will be overwritten during layout restoration. vsplitPanel.setRelativeSizes([3, 1]); hsplitPanel.setRelativeSizes([1, 2.5, 1]); BoxLayout.setStretch(headerPanel, 0); BoxLayout.setStretch(menuHandler.panel, 0); BoxLayout.setStretch(topHandler.panel, 0); BoxLayout.setStretch(hboxPanel, 1); BoxLayout.setStretch(bottomPanel, 0); rootLayout.addWidget(skipLinkWrapper); rootLayout.addWidget(headerPanel); rootLayout.addWidget(topHandler.panel); rootLayout.addWidget(hboxPanel); rootLayout.addWidget(bottomPanel); // initially hiding header and bottom panel when no elements inside, this._headerPanel.hide(); this._bottomPanel.hide(); this._downPanel.hide(); this.layout = rootLayout; // Connect change listeners. this._tracker.currentChanged.connect(this._onCurrentChanged, this); this._tracker.activeChanged.connect(this._onActiveChanged, this); // Connect main layout change listener. this._dockPanel.layoutModified.connect(this._onLayoutModified, this); // Connect vsplit layout change listener this._vsplitPanel.updated.connect(this._onLayoutModified, this); // Connect down panel change listeners this._downPanel.currentChanged.connect(this._onLayoutModified, this); this._downPanel.tabBar.tabMoved.connect(this._onTabPanelChanged, this); this._downPanel.stackedPanel.widgetRemoved.connect( this._onTabPanelChanged, this ); // Catch current changed events on the side handlers. this._leftHandler.updated.connect(this._onLayoutModified, this); this._rightHandler.updated.connect(this._onLayoutModified, this); // Catch update events on the horizontal split panel this._hsplitPanel.updated.connect(this._onLayoutModified, this); // Setup single-document-mode title bar const titleHandler = (this._titleHandler = new Private.TitleHandler(this)); this.add(titleHandler, 'top', { rank: 100 }); if (this._dockPanel.mode === 'multiple-document') { this._topHandler.addWidget(this._menuHandler.panel, 100); titleHandler.hide(); } else { rootLayout.insertWidget(3, this._menuHandler.panel); } this.translator = nullTranslator; // Wire up signals to update the title panel of the simple interface mode to // follow the title of this.currentWidget this.currentChanged.connect((sender, args) => { let newValue = args.newValue; let oldValue = args.oldValue; // Stop watching the title of the previously current widget if (oldValue) { oldValue.title.changed.disconnect(this._updateTitlePanelTitle, this); if (oldValue instanceof DocumentWidget) { oldValue.context.pathChanged.disconnect( this._updateCurrentPath, this ); } } // Start watching the title of the new current widget if (newValue) { newValue.title.changed.connect(this._updateTitlePanelTitle, this); this._updateTitlePanelTitle(); if (newValue instanceof DocumentWidget) { newValue.context.pathChanged.connect(this._updateCurrentPath, this); } } this._updateCurrentPath(); }); } /** * A signal emitted when main area's active focus changes. */ get activeChanged(): ISignal<this, ILabShell.IChangedArgs> { return this._activeChanged; } /** * The active widget in the shell's main area. */ get activeWidget(): Widget | null { return this._tracker.activeWidget; } /** * Whether the add buttons for each main area tab bar are enabled. */ get addButtonEnabled(): boolean { return this._dockPanel.addButtonEnabled; } set addButtonEnabled(value: boolean) { this._dockPanel.addButtonEnabled = value; } /** * A signal emitted when the add button on a main area tab bar is clicked. */ get addRequested(): ISignal<DockPanel, TabBar<Widget>> { return this._dockPanel.addRequested; } /** * A signal emitted when main area's current focus changes. */ get currentChanged(): ISignal<this, ILabShell.IChangedArgs> { return this._currentChanged; } /** * Current document path. */ // FIXME deprecation `undefined` is to ensure backward compatibility in 4.x get currentPath(): string | null | undefined { return this._currentPath; } /** * A signal emitted when the path of the current document changes. * * This also fires when the current document itself changes. */ get currentPathChanged(): ISignal<this, ILabShell.ICurrentPathChangedArgs> { return this._currentPathChanged; } /** * The current widget in the shell's main area. */ get currentWidget(): Widget | null { return this._tracker.currentWidget; } /** * Whether the down area is collapsed. */ get downCollapsed(): boolean { return this._downPanel.isHidden; } /** * A signal emitted when the main area's layout is modified. */ get layoutModified(): ISignal<this, void> { return this._layoutModified; } /** * Whether the left area is collapsed. */ get leftCollapsed(): boolean { return !this._leftHandler.sideBar.currentTitle; } /** * Whether the left area is collapsed. */ get rightCollapsed(): boolean { return !this._rightHandler.sideBar.currentTitle; } /** * Whether JupyterLab is in presentation mode with the * `jp-mod-presentationMode` CSS class. */ get presentationMode(): boolean { return this.hasClass('jp-mod-presentationMode'); } set presentationMode(value: boolean) { this.toggleClass('jp-mod-presentationMode', value); } /** * The main dock area's user interface mode. */ get mode(): DockPanel.Mode { return this._dockPanel.mode; } set mode(mode: DockPanel.Mode) { const dock = this._dockPanel; if (mode === dock.mode) { return; } const applicationCurrentWidget = this.currentWidget; if (mode === 'single-document') { // Cache the current multi-document layout before changing the mode. this._cachedLayout = dock.saveLayout(); dock.mode = mode; // In case the active widget in the dock panel is *not* the active widget // of the application, defer to the application. if (this.currentWidget) { dock.activateWidget(this.currentWidget); } // Adjust menu and title (this.layout as BoxLayout).insertWidget(3, this._menuHandler.panel); this._titleHandler.show(); this._updateTitlePanelTitle(); if (this._topHandlerHiddenByUser) { this._topHandler.panel.hide(); } } else { // Cache a reference to every widget currently in the dock panel before // changing its mode. const widgets = Array.from(dock.widgets()); dock.mode = mode; // Restore cached layout if possible. if (this._cachedLayout) { // Remove any disposed widgets in the cached layout and restore. Private.normalizeAreaConfig(dock, this._cachedLayout.main); dock.restoreLayout(this._cachedLayout); this._cachedLayout = null; } // If layout restoration has been deferred, restore layout now. if (this._layoutRestorer.isDeferred) { this._layoutRestorer .restoreDeferred() .then(mainArea => { if (mainArea) { const { currentWidget, dock } = mainArea; if (dock) { this._dockPanel.restoreLayout(dock); } if (currentWidget) { this.activateById(currentWidget.id); } } }) .catch(reason => { console.error('Failed to restore the deferred layout.'); console.error(reason); }); } // Add any widgets created during single document mode, which have // subsequently been removed from the dock panel after the multiple document // layout has been restored. If the widget has add options cached for // the widget (i.e., if it has been placed with respect to another widget), // then take that into account. widgets.forEach(widget => { if (!widget.parent) { this._addToMainArea(widget, { ...this._mainOptionsCache.get(widget), activate: false }); } }); this._mainOptionsCache.clear(); // In case the active widget in the dock panel is *not* the active widget // of the application, defer to the application. if (applicationCurrentWidget) { dock.activateWidget(applicationCurrentWidget); } // Adjust menu and title this.add(this._menuHandler.panel, 'top', { rank: 100 }); this._titleHandler.hide(); } // Set the mode data attribute on the applications shell node. this.node.dataset.shellMode = mode; this._downPanel.fit(); // Emit the mode changed signal this._modeChanged.emit(mode); } /** * A signal emitted when the shell/dock panel change modes (single/multiple document). */ get modeChanged(): ISignal<this, DockPanel.Mode> { return this._modeChanged; } /** * Promise that resolves when state is first restored, returning layout * description. */ get restored(): Promise<ILabShell.ILayout> { return this._restored.promise; } get translator(): ITranslator { return this._translator ?? nullTranslator; } set translator(value: ITranslator) { if (value !== this._translator) { this._translator = value; // Set translator for tab bars TabBarSvg.translator = value; const trans = value.load('jupyterlab'); this._menuHandler.panel.node.setAttribute( 'aria-label', trans.__('main menu') ); this._leftHandler.sideBar.node.setAttribute( 'aria-label', trans.__('main sidebar') ); this._leftHandler.sideBar.contentNode.setAttribute( 'aria-label', trans.__('main sidebar') ); this._rightHandler.sideBar.node.setAttribute( 'aria-label', trans.__('alternate sidebar') ); this._rightHandler.sideBar.contentNode.setAttribute( 'aria-label', trans.__('alternate sidebar') ); this._topHandler.panel.node.setAttribute( 'aria-label', trans.__('Top Bar') ); this._bottomPanel.node.setAttribute( 'aria-label', trans.__('Bottom Panel') ); this._dockPanel.node.setAttribute('aria-label', trans.__('Main Content')); } } /** * User customized shell layout. */ get userLayout(): { 'single-document': ILabShell.IUserLayout; 'multiple-document': ILabShell.IUserLayout; } { return JSONExt.deepCopy(this._userLayout as any); } /** * Activate a widget in its area. */ activateById(id: string): void { if (this._leftHandler.has(id)) { this._leftHandler.activate(id); return; } if (this._rightHandler.has(id)) { this._rightHandler.activate(id); return; } const tabIndex = this._downPanel.tabBar.titles.findIndex( title => title.owner.id === id ); if (tabIndex >= 0) { const wasHidden = this._downPanel.isHidden; this._downPanel.currentIndex = tabIndex; if (wasHidden) { this._showDownPanel(); this._onLayoutModified(); } this._downPanel.currentWidget?.activate(); return; } const dock = this._dockPanel; const widget = find(dock.widgets(), value => value.id === id); if (widget) { dock.activateWidget(widget); } } /** * Activate widget in specified area. * * ### Notes * The alpha version of this method only supports activating the "main" area. * * @alpha * @param area Name of area to activate */ activateArea(area: ILabShell.Area = 'main'): void { // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (area) { case 'main': { const current = this._currentTabBar(); if (!current) { return; } if (current.currentTitle) { current.currentTitle.owner.activate(); } } return; case 'left': case 'right': case 'header': case 'top': case 'menu': case 'bottom': console.debug(`Area: ${area} activation not yet implemented`); break; default: throw new Error(`Invalid area: ${area}`); } } /** * Activate the next Tab in the active TabBar. */ activateNextTab(): void { const current = this._currentTabBar(); if (!current) { return; } const ci = current.currentIndex; if (ci === -1) { return; } if (ci < current.titles.length - 1) { current.currentIndex += 1; if (current.currentTitle) { current.currentTitle.owner.activate(); } return; } if (ci === current.titles.length - 1) { const nextBar = this._adjacentBar('next'); if (nextBar) { nextBar.currentIndex = 0; if (nextBar.currentTitle) { nextBar.currentTitle.owner.activate(); } } } } /** * Activate the previous Tab in the active TabBar. */ activatePreviousTab(): void { const current = this._currentTabBar(); if (!current) { return; } const ci = current.currentIndex; if (ci === -1) { return; } if (ci > 0) { current.currentIndex -= 1; if (current.currentTitle) { current.currentTitle.owner.activate(); } return; } if (ci === 0) { const prevBar = this._adjacentBar('previous'); if (prevBar) { const len = prevBar.titles.length; prevBar.currentIndex = len - 1; if (prevBar.currentTitle) { prevBar.currentTitle.owner.activate(); } } } } /** * Activate the next TabBar. */ activateNextTabBar(): void { const nextBar = this._adjacentBar('next'); if (nextBar) { if (nextBar.currentTitle) { nextBar.currentTitle.owner.activate(); } } } /** * Activate the next TabBar. */ activatePreviousTabBar(): void { const nextBar = this._adjacentBar('previous'); if (nextBar) { if (nextBar.currentTitle) { nextBar.currentTitle.owner.activate(); } } } /** * Add a widget to the JupyterLab shell * * @param widget Widget * @param area Area * @param options Options */ add( widget: Widget, area: ILabShell.Area = 'main', options?: DocumentRegistry.IOpenOptions ): void { if (!this._userLayout) { this._delayedWidget.push({ widget, area, options }); return; } let userPosition: ILabShell.IWidgetPosition | undefined; if (options?.type && this._userLayout[this.mode][options.type]) { userPosition = this._userLayout[this.mode][options.type]; this._idTypeMap.set(widget.id, options.type); } else { userPosition = this._userLayout[this.mode][widget.id]; } if (options?.type) { this._idTypeMap.set(widget.id, options.type); widget.disposed.connect(() => { this._idTypeMap.delete(widget.id); }); } area = userPosition?.area ?? area; options = options || userPosition?.options ? { ...options, ...userPosition?.options } : undefined; if (options?.rank !== undefined) { this._sideOptionsCache.set(widget, { ...this._sideOptionsCache.get(widget), rank: options.rank }); } switch (area || 'main') { case 'bottom': return this._addToBottomArea(widget, options); case 'down': return this._addToDownArea(widget, options); case 'header': return this._addToHeaderArea(widget, options); case 'left': return this._addToLeftArea(widget, options); case 'main': return this._addToMainArea(widget, options); case 'menu': return this._addToMenuArea(widget, options); case 'right': return this._addToRightArea(widget, options); case 'top': return this._addToTopArea(widget, options); default: throw new Error(`Invalid area: ${area}`); } } /** * Move a widget to a new area and update the shell user layout. * * The widget is reparented to `area` immediately. The type used as the * user-layout key is determined from `widget.id`, falling back to * `widget.id` itself. * * #### Notes * If `mode` is undefined, both modes are updated in the user layout. * When `mode` is set, only that mode's user layout is updated, but the * live widget is still reparented regardless of mode. * * The new layout is stored in the shell user layout. Callers are * responsible for persisting it when needed. * * @param widget Widget to move * @param area New area * @param mode Mode to change * @returns The new user layout */ move( widget: Widget, area: ILabShell.Area, mode?: DockPanel.Mode ): { 'single-document': ILabShell.IUserLayout; 'multiple-document': ILabShell.IUserLayout; } { const type = this._idTypeMap.get(widget.id) ?? widget.id; const rank = this._sideOptionsCache.get(widget)?.rank; for (const m of ['single-document', 'multiple-document'].filter( c => !mode || c === mode )) { const position = this._userLayout[m as DockPanel.Mode][type]; this._userLayout[m as DockPanel.Mode][type] = { ...position, area, ...(rank !== undefined ? { options: { ...position?.options, rank } } : {}) }; } this.add(widget, area); return this._userLayout; } /** * Collapse the left area. */ collapseLeft(): void { this._leftHandler.collapse(); this._onLayoutModified(); } /** * Collapse the right area. */ collapseRight(): void { this._rightHandler.collapse(); this._onLayoutModified(); } /** * Collapse the down area. */ collapseDown(): void { if (!this._downPanel.isHidden) { this._hideDownPanel(); this._onLayoutModified(); } } /** * Dispose the shell. */ dispose(): void { if (this.isDisposed) { return; } this._layoutDebouncer.dispose(); super.dispose(); } /** * Expand the left area. * * #### Notes * This will open the most recently used tab, * or the first tab if there is no most recently used. */ expandLeft(): void { this._leftHandler.expand(); this._onLayoutModified(); } /** * Expand the right area. * * #### Notes * This will open the most recently used tab, * or the first tab if there is no most recently used. */ expandRight(): void { this._rightHandler.expand(); this._onLayoutModified(); } /** * Expand the down area. */ expandDown(): void { if (this._downPanel.stackedPanel.widgets.length === 0) { return; } this._showDownPanel(); this._downPanel.currentWidget?.activate(); this._onLayoutModified(); } /** * Close all widgets in the main and down area. */ closeAll(): void { // Make a copy of all the widget in the dock panel (using `Array.from()`) // before removing them because removing them while iterating through them // modifies the underlying data of the iterator. Array.from(this._dockPanel.widgets()).forEach(widget => widget.close()); this._downPanel.stackedPanel.widgets.forEach(widget => widget.close()); } /** * Whether an side tab bar is visible or not. * * @param side Sidebar of interest * @returns Side tab bar visibility */ isSideTabBarVisible(side: 'left' | 'right'): boolean { switch (side) { case 'left': return this._leftHandler.isVisible; case 'right': return this._rightHandler.isVisible; } } /** * Whether the top bar in simple mode is visible or not. * * @returns Top bar visibility */ isTopInSimpleModeVisible(): boolean { return !this._topHandlerHiddenByUser; } /** * True if the given area is empty. */ isEmpty(area: ILabShell.Area): boolean { switch (area) { case 'bottom': return this._bottomPanel.widgets.length === 0; case 'down': return this._downPanel.stackedPanel.widgets.length === 0; case 'header': return this._headerPanel.widgets.length === 0; case 'left': return this._leftHandler.stackedPanel.widgets.length === 0; case 'main': return this._dockPanel.isEmpty; case 'menu': return this._menuHandler.panel.widgets.length === 0; case 'right': return this._rightHandler.stackedPanel.widgets.length === 0; case 'top': return this._topHandler.panel.widgets.length === 0; default: return true; } } /** * Restore the layout state and configuration for the application shell. * * #### Notes * This should only be called once. */ async restoreLayout( mode: DockPanel.Mode, layoutRestorer: LayoutRestorer, configuration: { [m: string]: ILabShell.IUserLayout; } = {} ): Promise<void> { // Set the configuration and add widgets added before the shell was ready. this._userLayout = { 'single-document': configuration['single-document'] ?? {}, 'multiple-document': configuration['multiple-document'] ?? {} }; this._delayedWidget.forEach(({ widget, area, options }) => { this.add(widget, area, options); }); this._delayedWidget.length = 0; this._layoutRestorer = layoutRestorer; // Get the layout from the restorer const layout = await layoutRestorer.fetch(); // Reset the layout const { mainArea, downArea, leftArea, rightArea, topArea, relativeSizes } = layout; // Rehydrate the main area. if (mainArea) { const { currentWidget, dock } = mainArea; if (dock && mode === 'multiple-document') { this._dockPanel.restoreLayout(dock); } if (mode) { this.mode = mode; } if (currentWidget) { this.activateById(currentWidget.id); } } else { // This is needed when loading in an empty workspace in single doc mode if (mode) { this.mode = mode; } } if (topArea?.simpleVisibility !== undefined) { this._topHandlerHiddenByUser = !topArea.simpleVisibility; if (this.mode === 'single-document') { this._topHandler.panel.setHidden(this._topHandlerHiddenByUser); } } // Rehydrate the down area if (downArea) { const { currentWidget, widgets, size } = downArea; const collapsed = downArea.collapsed ?? !size; const widgetIds = widgets?.map(widget => widget.id) ?? []; const otherAreaWidgetIds = new Set<string>(); const collectMainWidgetIds = ( area?: ILabShell.AreaConfig | null ): void => { if (!area) { return; } if (area.type === 'tab-area') { area.widgets.forEach(widget => { otherAreaWidgetIds.add(widget.id); }); return; } area.children.forEach(collectMainWidgetIds); }; collectMainWidgetIds(mainArea?.dock?.main); leftArea?.widgets?.forEach(widget => { otherAreaWidgetIds.add(widget.id); }); rightArea?.widgets?.forEach(widget => { otherAreaWidgetIds.add(widget.id); }); // Remove absent widgets this._downPanel.tabBar.titles .filter(title => !widgetIds.includes(title.owner.id)) .forEach(title => { if (otherAreaWidgetIds.has(title.owner.id)) { title.owner.parent = null; } else { title.owner.close(); } }); // Add new widgets const titleIds = this._downPanel.tabBar.titles.map( title => title.owner.id ); widgets ?.filter(widget => !titleIds.includes(widget.id)) .map(widget => this._downPanel.addWidget(widget)); // Reorder tabs while ( !ArrayExt.shallowEqual( widgetIds, this._downPanel.tabBar.titles.map(title => title.owner.id) ) ) { this._downPanel.tabBar.titles.forEach((title, index) => { const position = widgetIds.findIndex(id => title.owner.id == id); if (position >= 0 && position != index) { this._downPanel.tabBar.insertTab(position, title); } }); } if (currentWidget) { const index = this._downPanel.stackedPanel.widgets.findIndex( widget => widget.id === currentWidget.id ); if (index >= 0) { this._downPanel.currentIndex = index; } } if (!collapsed && widgets?.length && size && size > 0.0) { this._showDownPanel(size); this._downPanel.currentWidget?.activate(); } else { this._hideDownPanel(); if (size && size > 0.0) { // Remember the saved size so a later expand restores the user's // previous height. `_hideDownPanel` seeds `_lastDownAreaSize` // from the current splitter, which at cold startup reflects the // default layout rather than the persisted value. this._lastDownAreaSize = size; } } } // Rehydrate the left area. if (leftArea) { this._leftHandler.rehydrate(leftArea); } else { if (mode === 'single-document') { this.collapseLeft(); } } // Rehydrate the right area. if (rightArea) { this._rightHandler.rehydrate(rightArea); } else { if (mode === 'single-document') { this.collapseRight(); } } // Restore the relative sizes. if (relativeSizes) { this._hsplitPanel.setRelativeSizes(relativeSizes); } if (!this._isRestored) { // Make sure all messages in the queue are finished before notifying // any extensions that are waiting for the promise that guarantees the // application state has been restored. MessageLoop.flush(); this._restored.resolve(layout); } } /** * Save the dehydrated state of the application shell. */ saveLayout(): ILabShell.ILayout { // If the application is in single document mode, use the cached layout if // available. Otherwise, default to querying the dock panel for layout. const layout = { mainArea: { currentWidget: this._tracker.currentWidget, dock: this.mode === 'single-document' ? this._cachedLayout || this._dockPanel.saveLayout() : this._dockPanel.saveLayout() }, downArea: { collapsed: this.downCollapsed, currentWidget: this._downPanel.currentWidget, widgets: Array.from(this._downPanel.stackedPanel.widgets), size: this._downPanel.stackedPanel.widgets.length === 0 ? 0 : this.downCollapsed ? this._lastDownAreaSize : this._vsplitPanel.relativeSizes()[1] }, leftArea: this._leftHandler.dehydrate(), rightArea: this._rightHandler.dehydrate(), topArea: { simpleVisibility: !this._topHandlerHiddenByUser }, relativeSizes: this._hsplitPanel.relativeSizes() }; return layout; } /** * Toggle top header visibility in simple mode * * Note: Does nothing in multi-document mode */ toggleTopInSimpleModeVisibility(): void { if (this.mode === 'single-document') { if (this._topHandler.panel.isVisible) { this._topHandlerHiddenByUser = true; this._topHandler.panel.hide(); } else { this._topHandlerHiddenByUser = false; this._topHandler.panel.show(); this._updateTitlePanelTitle(); } this._onLayoutModified(); } } /** * Toggle side tab bar visibility * * @param side Sidebar of interest */ toggleSideTabBarVisibility(side: 'right' | 'left'): void { if (side === 'right') { if (this._rightHandler.isVisible) { this._rightHandler.hide(); } else { this._rightHandler.show(); } } else { if (this._leftHandler.isVisible) { this._leftHandler.hide(); } else { this._leftHandler.show(); } } } /** * Update the shell configuration. * * @param config Shell configuration */ updateConfig(config: Partial<ILabShell.IConfig>): void { if (config.hiddenMode) { switch (config.hiddenMode) { case 'display': this._dockPanel.hiddenMode = Widget.HiddenMode.Display; break; case 'scale': this._dockPanel.hiddenMode = Widget.HiddenMode.Scale; break; case 'contentVisibility': this._dockPanel.hiddenMode = Widget.HiddenMode.ContentVisibility; break; } } if (config.dockPanelPadding !== undefined) { if (config.dockPanelPadding === false) { this._dockPanel.node.style.setProperty( '--jp-private-dock-panel-padding', '0px' ); } else { this._dockPanel.node.style.removeProperty( '--jp-private-dock-panel-padding' ); } this._dockPanel.fit(); } if (config.optimizeResize !== undefined) { this._dockPanel.optimizeResize = config.optimizeResize; } if (config.activityBarPosition !== undefined) { this._setActivityBarPosition('left', config.activityBarPosition); this._setActivityBarPosition('right', config.activityBarPosition); } } /** * Move the activity bar of a side area to a new position. * * @param side The side area to update. * @param position The new position of the activity bar. */ private _setActivityBarPosition( side: 'left' | 'right', position: ILabShell.ActivityBarPosition ): void { const handler = side === 'left' ? this._leftHandler : this._rightHandler; if (handler.position === position) { return; } handler.position = position; this._onLayoutModified(); } /** * Returns the widgets for an application area. */ widgets(area?: ILabShell.Area): IterableIterator<Widget> { switch (area ?? 'main') { case 'main': return this._dockPanel.widgets(); case 'left': return map(this._leftHandler.sideBar.titles, t => t.owner); case 'right': return map(this._rightHandler.sideBar.titles, t => t.owner); case 'header': return this._headerPanel.children(); case 'top': return this._topHandler.panel.children(); case 'menu': return this._menuHandler.panel.children(); case 'bottom': return this._bottomPanel.children(); case 'down': return this._downPanel.stackedPanel.children(); default: throw new Error(`Invalid area: ${area}`); } } /** * Handle `after-attach` messages for the application shell. */ protected onAfterAttach(msg: Message): void { this.node.dataset.shellMode = this.mode; } /** * Update the title panel title based on the title of the current widget. */ private _updateTitlePanelTitle() { let current = this.currentWidget; const inputElement = this._titleHandler.inputElement; inputElement.value = current ? TabBarSvg.titleLabel(current.title) : ''; inputElement.title = current ? current.title.caption : ''; } /** * The path of the current widget changed, fire the _currentPathChanged signal. */ private _updateCurrentPath() { let current = this.currentWidget; let newValue = ''; if (current && current instanceof DocumentWidget) { newValue = current.context.path; } this._currentPathChanged.emit({ newValue: newValue, oldValue: this._currentPath }); this._currentPath = newValue; } /** * Add a widget to the left content area. * * #### Notes * Widgets must have a unique `id` property, which will be used as the DOM id. */ private _addToLeftArea( widget: Widget, options?: DocumentRegistry.IOpenOptions ): void { if (!widget.id) { console.error('Widgets added to app shell must have unique id property.'); return; } options = options || this._sideOptionsCache.get(widget) || {}; this._sideOptionsCache.set(widget, options); const rank = 'rank' in options ? options.rank : DEFAULT_RANK; this._leftHandler.addWidget(widget, rank!); this._onLayoutModified(); } /** * Add a widget to the main content area. * * #### Notes * Widgets must have a unique `id` property, which will be used as the DOM id. * All widgets added to the main area should be disposed after removal * (disposal before removal will remove the widget automatically). * * In the options, `ref` defaults to `null`, `mode` defaults to `'tab-after'`, * and `activate` defaults to `true`. */ private _addToMainArea( widget: Widget, options?: DocumentRegistry.IOpenOptions ): void { if (!widget.id) { console.error('Widgets added to app shell must have unique id property.'); return; } options = options || {}; const dock = this._dockPanel; const mode = options.mode || 'tab-after'; let ref: Widget | null = this.currentWidget; if (options.ref) { ref = find(dock.widgets(), value => value.id === options!.ref!) || null; } const { title } = widget; // Add widget ID to tab so that we can get a handle on the tab's widget // (for context menu support) title.dataset = { ...title.dataset, id: widget.id }; if (title.icon instanceof LabIcon) { // bind an appropriate style to the icon title.icon = title.icon.bindprops({ stylesheet: 'mainAreaTab' }); } else if (typeof title.icon === 'string' || !title.icon) { // add some classes to help with displaying css background imgs title.iconClass = classes(title.iconClass, 'jp-Icon'); } dock.addWidget(widget, { mode, ref }); // The dock panel doesn't account for placement information while // in single document mode, so upon rehydrating any widgets that were // added will not be in the correct place. Cache the placement information // here so that we can later rehydrate correctly. if (dock.mode === 'single-document') { this._mainOptionsCache.set(widget, options); } if (options.activate !== false) { dock.activateWidget(widget); } } /** * Add a widget to the right content area. * * #### Notes * Widgets must have a unique `id` property, which will be used as the DOM id. */ private _addToRightArea( widget: Widget, options?: DocumentRegistry.IOpenOptions ): void { if (!widget.id) { console.error('Widgets added to app shell must have unique id property.'); return; } options = options || this._sideOptionsCache.get(widget) || {}; const rank = 'rank' in options ? options.rank : DEFAULT_RANK; this._sideOptionsCache.set(widget, options); this._rightHandler.addWidget(widget, rank!); this._onLayoutModified(); } /** * Add a widget to the top content area. * * #### Notes * Widgets must have a unique `id` property, which will be used as the DOM id. */ private _addToTopArea( widget: Widget, options?: DocumentRegistry.IOpenOptions ): void { if (!widget.id) { console.error('Widgets added to app shell must have unique id property.'); return; } options = options || {}; const rank = options.rank ?? DEFAULT_RANK; this._topHandler.addWidget(widget, rank); this._onLayoutModified(); if (this._topHandler.panel.isHidden) { this._topHandler.panel.show(); } } /** * Add a widget to the title content area. * * #### Notes * Widgets must have a unique `id` property, which will be used as the DOM id. */ private _addToMenuArea(