瀏覽代碼

Add new widget area below the dockpanel (#10201)

* Add down panel

* WIP Save layout

* Add the new panel in the documentation

* Handle style when switching mode

* Restore tabpanel

* Simplify styling

* Open contextual help in down area in simple mode

* Fix activation

* Restore down panel size

* Use restorable split

* Fix test

* Fix unit tests

* down area full width with tabbar styled as sidebar

* Revert inspector open in down panel
Frédéric Collonval 3 年之前
父節點
當前提交
ea9aa707c2

+ 1 - 0
docs/source/extension/extension_points.rst

@@ -416,6 +416,7 @@ In JupyterLab, the application shell consists of:
 -  A ``menu`` area for top-level menus, which is collapsed into the ``top`` area in multiple-document mode and put below it in single-document mode.
 -  ``left`` and ``right`` sidebar areas for collapsible content.
 -  A ``main`` work area for user activity.
+-  A ``down`` area for information content; like log console, contextual help.
 -  A ``bottom`` area for things like status bars.
 -  A ``header`` area for custom elements.
 

+ 6 - 1
packages/application/src/frontend.ts

@@ -17,7 +17,7 @@ import { Token } from '@lumino/coreutils';
 
 import { ISignal, Signal } from '@lumino/signaling';
 
-import { Widget } from '@lumino/widgets';
+import { DockPanel, Widget } from '@lumino/widgets';
 
 /**
  * The type for all JupyterFrontEnd application plugins.
@@ -283,6 +283,11 @@ export namespace JupyterFrontEnd {
      */
     readonly currentWidget: Widget | null;
 
+    /**
+     * The user interface mode.
+     */
+    readonly mode: DockPanel.Mode;
+
     /**
      * Returns an iterator for the widgets inside the application shell.
      *

+ 103 - 1
packages/application/src/layoutrestorer.ts

@@ -165,6 +165,7 @@ export class LayoutRestorer implements ILayoutRestorer {
     const blank: ILabShell.ILayout = {
       fresh: true,
       mainArea: null,
+      downArea: null,
       leftArea: null,
       rightArea: null,
       relativeSizes: null
@@ -178,7 +179,13 @@ export class LayoutRestorer implements ILayoutRestorer {
         return blank;
       }
 
-      const { main, left, right, relativeSizes } = data as Private.ILayout;
+      const {
+        main,
+        down,
+        left,
+        right,
+        relativeSizes
+      } = data as Private.ILayout;
 
       // If any data exists, then this is not a fresh session.
       const fresh = false;
@@ -186,6 +193,9 @@ export class LayoutRestorer implements ILayoutRestorer {
       // Rehydrate main area.
       const mainArea = this._rehydrateMainArea(main);
 
+      // Rehydrate down area.
+      const downArea = this._rehydrateDownArea(down);
+
       // Rehydrate left area.
       const leftArea = this._rehydrateSideArea(left);
 
@@ -195,6 +205,7 @@ export class LayoutRestorer implements ILayoutRestorer {
       return {
         fresh,
         mainArea,
+        downArea,
         leftArea,
         rightArea,
         relativeSizes: relativeSizes || null
@@ -283,6 +294,7 @@ export class LayoutRestorer implements ILayoutRestorer {
 
     const dehydrated: Private.ILayout = {};
     dehydrated.main = this._dehydrateMainArea(data.mainArea);
+    dehydrated.down = this._dehydrateDownArea(data.downArea);
     dehydrated.left = this._dehydrateSideArea(data.leftArea);
     dehydrated.right = this._dehydrateSideArea(data.rightArea);
     dehydrated.relativeSizes = data.relativeSizes;
@@ -318,6 +330,69 @@ export class LayoutRestorer implements ILayoutRestorer {
     return Private.deserializeMain(area, this._widgets);
   }
 
+  /**
+   * Dehydrate a down area description into a serializable object.
+   */
+  private _dehydrateDownArea(
+    area: ILabShell.IDownArea | null
+  ): Private.IDownArea | null {
+    if (!area) {
+      return null;
+    }
+
+    const dehydrated: Private.IDownArea = {
+      size: area.size
+    };
+
+    if (area.currentWidget) {
+      const current = Private.nameProperty.get(area.currentWidget);
+      if (current) {
+        dehydrated.current = current;
+      }
+    }
+
+    if (area.widgets) {
+      dehydrated.widgets = area.widgets
+        .map(widget => Private.nameProperty.get(widget))
+        .filter(name => !!name);
+    }
+
+    return dehydrated;
+  }
+
+  /**
+   * Reydrate a serialized side area description object.
+   *
+   * #### Notes
+   * This function consumes data that can become corrupted, so it uses type
+   * coercion to guarantee the dehydrated object is safely processed.
+   */
+  private _rehydrateDownArea(
+    area?: Private.IDownArea | null
+  ): ILabShell.IDownArea | null {
+    if (!area) {
+      return { currentWidget: null, size: 0.0, widgets: null };
+    }
+
+    const internal = this._widgets;
+    const currentWidget =
+      area.current && internal.has(`${area.current}`)
+        ? internal.get(`${area.current}`)
+        : null;
+    const widgets = !Array.isArray(area.widgets)
+      ? null
+      : area.widgets
+          .map(name =>
+            internal.has(`${name}`) ? internal.get(`${name}`) : null
+          )
+          .filter(widget => !!widget);
+    return {
+      currentWidget: currentWidget!,
+      size: area.size ?? 0.0,
+      widgets: widgets as Widget[] | null
+    };
+  }
+
   /**
    * Dehydrate a side area description into a serializable object.
    */
@@ -440,6 +515,11 @@ namespace Private {
      */
     main?: IMainArea | null;
 
+    /**
+     * The down area of the user interface
+     */
+    down?: IDownArea | null;
+
     /**
      * The left area of the user interface.
      */
@@ -541,6 +621,28 @@ namespace Private {
     sizes: Array<number>;
   }
 
+  /**
+   * The restorable description of the down area in the user interface
+   */
+  export interface IDownArea extends PartialJSONObject {
+    /**
+     * The current widget that has application focus.
+     */
+    current?: string | null;
+
+    /**
+     * Vertical relative size of the down area
+     *
+     * The main area will take the rest of the height
+     */
+    size?: number | null;
+
+    /**
+     * The widgets in the down area.
+     */
+    widgets?: Array<string> | null;
+  }
+
   /**
    * An attached property for a widget's ID in the serialized restore data.
    */

+ 231 - 63
packages/application/src/shell.ts

@@ -2,9 +2,15 @@
 // Distributed under the terms of the Modified BSD License.
 
 import { DocumentRegistry, DocumentWidget } from '@jupyterlab/docregistry';
+
 import { ITranslator, nullTranslator } from '@jupyterlab/translation';
 
-import { classes, DockPanelSvg, LabIcon } from '@jupyterlab/ui-components';
+import {
+  classes,
+  DockPanelSvg,
+  LabIcon,
+  TabPanelSvg
+} from '@jupyterlab/ui-components';
 
 import { ArrayExt, find, IIterator, iter, toArray } from '@lumino/algorithm';
 
@@ -26,6 +32,7 @@ import {
   SplitPanel,
   StackedPanel,
   TabBar,
+  TabPanel,
   Title,
   Widget
 } from '@lumino/widgets';
@@ -87,7 +94,8 @@ export namespace ILabShell {
     | 'menu'
     | 'left'
     | 'right'
-    | 'bottom';
+    | 'bottom'
+    | 'down';
 
   /**
    * The restorable description of an area within the main dock panel.
@@ -144,6 +152,11 @@ export namespace ILabShell {
      */
     readonly mainArea: IMainArea | null;
 
+    /**
+     * The down area of the user interface.
+     */
+    readonly downArea: IDownArea | null;
+
     /**
      * The left area of the user interface.
      */
@@ -175,6 +188,25 @@ export namespace ILabShell {
     readonly dock: DockLayout.ILayoutConfig | null;
   }
 
+  export interface IDownArea {
+    /**
+     * 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.
    */
@@ -220,10 +252,14 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     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 DockPanelSvg());
     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());
     const rightHandler = (this._rightHandler = new Private.SideBarHandler());
     const rootLayout = new BoxLayout();
@@ -233,8 +269,10 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     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');
@@ -265,15 +303,18 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     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.stackedPanel, 0);
+    SplitPanel.setStretch(downPanel, 0);
     SplitPanel.setStretch(dockPanel, 1);
     SplitPanel.setStretch(rightHandler.stackedPanel, 0);
 
@@ -281,12 +322,17 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     BoxPanel.setStretch(hsplitPanel, 1);
     BoxPanel.setStretch(rightHandler.sideBar, 0);
 
+    SplitPanel.setStretch(vsplitPanel, 1);
+
     hsplitPanel.addWidget(leftHandler.stackedPanel);
     hsplitPanel.addWidget(dockPanel);
     hsplitPanel.addWidget(rightHandler.stackedPanel);
 
+    vsplitPanel.addWidget(hsplitPanel);
+    vsplitPanel.addWidget(downPanel);
+
     hboxPanel.addWidget(leftHandler.sideBar);
-    hboxPanel.addWidget(hsplitPanel);
+    hboxPanel.addWidget(vsplitPanel);
     hboxPanel.addWidget(rightHandler.sideBar);
 
     rootLayout.direction = 'top-to-bottom';
@@ -294,6 +340,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     // 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);
@@ -310,6 +357,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     // initially hiding header and bottom panel when no elements inside,
     this._headerPanel.hide();
     this._bottomPanel.hide();
+    this._downPanel.hide();
 
     this.layout = rootLayout;
 
@@ -320,6 +368,17 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     // 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.sideBar.currentChanged.connect(
       this._onLayoutModified,
@@ -473,9 +532,11 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
 
     const applicationCurrentWidget = this.currentWidget;
 
+    // Toggle back to multiple document mode.
+    dock.mode = mode;
+
     if (mode === 'single-document') {
       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.
@@ -483,62 +544,53 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
         dock.activateWidget(this.currentWidget);
       }
 
-      // Set the mode data attribute on the application shell node.
-      this.node.dataset.shellMode = mode;
-
       // Adjust menu and title
-      // this.add(this._menuHandler.panel, 'top', {rank: 100});
       (this.layout as BoxLayout).insertWidget(2, this._menuHandler.panel);
       this._titleWidgetHandler.show();
       this._updateTitlePanelTitle();
+    } else {
+      // Cache a reference to every widget currently in the dock panel.
+      const widgets = toArray(dock.widgets());
+
+      // Restore the original layout.
+      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;
+      }
 
-      this._modeChanged.emit(mode);
-      return;
-    }
-
-    // Cache a reference to every widget currently in the dock panel.
-    const widgets = toArray(dock.widgets());
-
-    // Toggle back to multiple document mode.
-    dock.mode = mode;
+      // 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();
 
-    // Restore the original layout.
-    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;
-    }
-
-    // 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
-        });
+      // 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);
       }
-    });
-    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._topHandler.addWidget(this._menuHandler.panel, 100)
+      this._titleWidgetHandler.hide();
     }
 
     // Set the mode data attribute on the applications shell node.
     this.node.dataset.shellMode = mode;
 
-    // Adjust menu and title
-    this.add(this._menuHandler.panel, 'top', { rank: 100 });
-    // this._topHandler.addWidget(this._menuHandler.panel, 100)
-    this._titleWidgetHandler.hide();
-
+    this._downPanel.fit();
     // Emit the mode changed signal
     this._modeChanged.emit(mode);
   }
@@ -565,6 +617,14 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
       return;
     }
 
+    const tabIndex = this._downPanel.tabBar.titles.findIndex(
+      title => title.owner.id === id
+    );
+    if (tabIndex >= 0) {
+      this._downPanel.currentIndex = tabIndex;
+      return;
+    }
+
     const dock = this._dockPanel;
     const widget = find(dock.widgets(), value => value.id === id);
 
@@ -670,20 +730,22 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     options?: DocumentRegistry.IOpenOptions
   ): void {
     switch (area || 'main') {
-      case 'main':
-        return this._addToMainArea(widget, options);
+      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 'header':
-        return this._addToHeaderArea(widget, options);
       case 'top':
         return this._addToTopArea(widget, options);
-      case 'menu':
-        return this._addToMenuArea(widget, options);
-      case 'bottom':
-        return this._addToBottomArea(widget, options);
       default:
         throw new Error(`Invalid area: ${area}`);
     }
@@ -742,13 +804,15 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
   }
 
   /**
-   * Close all widgets in the main area.
+   * Close all widgets in the main and down area.
    */
   closeAll(): void {
     // Make a copy of all the widget in the dock panel (using `toArray()`)
     // before removing them because removing them while iterating through them
     // modifies the underlying data of the iterator.
     toArray(this._dockPanel.widgets()).forEach(widget => widget.close());
+
+    this._downPanel.stackedPanel.widgets.forEach(widget => widget.close());
   }
 
   /**
@@ -756,20 +820,22 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
    */
   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 'header':
-        return this._headerPanel.widgets.length === 0;
-      case 'top':
-        return this._topHandler.panel.widgets.length === 0;
       case 'menu':
         return this._menuHandler.panel.widgets.length === 0;
-      case 'bottom':
-        return this._bottomPanel.widgets.length === 0;
       case 'right':
         return this._rightHandler.stackedPanel.widgets.length === 0;
+      case 'top':
+        return this._topHandler.panel.widgets.length === 0;
       default:
         return true;
     }
@@ -779,7 +845,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
    * Restore the layout state for the application shell.
    */
   restoreLayout(mode: DockPanel.Mode, layout: ILabShell.ILayout): void {
-    const { mainArea, leftArea, rightArea, relativeSizes } = layout;
+    const { mainArea, downArea, leftArea, rightArea, relativeSizes } = layout;
     // Rehydrate the main area.
     if (mainArea) {
       const { currentWidget, dock } = mainArea;
@@ -800,6 +866,56 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
       }
     }
 
+    // Rehydrate the down area
+    if (downArea) {
+      const { currentWidget, widgets, size } = downArea;
+
+      const widgetIds = widgets?.map(widget => widget.id) ?? [];
+      // Remove absent widgets
+      this._downPanel.tabBar.titles
+        .filter(title => !widgetIds.includes(title.owner.id))
+        .map(title => 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) {
+          this._downPanel.currentIndex = index;
+          this._downPanel.currentWidget?.activate();
+        }
+      }
+
+      if (size && size > 0.0) {
+        this._vsplitPanel.setRelativeSizes([1.0 - size, size]);
+      } else {
+        // Close all tabs and hide the panel
+        this._downPanel.stackedPanel.widgets.forEach(widget => widget.close());
+        this._downPanel.hide();
+      }
+    }
+
     // Rehydrate the left area.
     if (leftArea) {
       this._leftHandler.rehydrate(leftArea);
@@ -846,10 +962,16 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
             ? this._cachedLayout || this._dockPanel.saveLayout()
             : this._dockPanel.saveLayout()
       },
+      downArea: {
+        currentWidget: this._downPanel.currentWidget,
+        widgets: toArray(this._downPanel.stackedPanel.widgets),
+        size: this._vsplitPanel.relativeSizes()[1]
+      },
       leftArea: this._leftHandler.dehydrate(),
       rightArea: this._rightHandler.dehydrate(),
       relativeSizes: this._hsplitPanel.relativeSizes()
     };
+
     return layout;
   }
 
@@ -1106,6 +1228,40 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     }
   }
 
+  private _addToDownArea(
+    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 { 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');
+    }
+
+    this._downPanel.addWidget(widget);
+    this._onLayoutModified();
+
+    if (this._downPanel.isHidden) {
+      this._downPanel.show();
+    }
+  }
+
   /*
    * Return the tab bar adjacent to the current TabBar or `null`.
    */
@@ -1184,6 +1340,16 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
     this._onLayoutModified();
   }
 
+  /**
+   * Handle a change on the down panel widgets
+   */
+  private _onTabPanelChanged(): void {
+    if (this._downPanel.stackedPanel.widgets.length === 0) {
+      this._downPanel.hide();
+    }
+    this._onLayoutModified();
+  }
+
   /**
    * Handle a change to the layout.
    */
@@ -1223,6 +1389,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
   >(this);
   private _modeChanged = new Signal<this, DockPanel.Mode>(this);
   private _dockPanel: DockPanel;
+  private _downPanel: TabPanel;
   private _isRestored = false;
   private _layoutModified = new Signal<this, void>(this);
   private _layoutDebouncer = new Debouncer(() => {
@@ -1234,6 +1401,7 @@ export class LabShell extends Widget implements JupyterFrontEnd.IShell {
   private _tracker = new FocusTracker<Widget>();
   private _headerPanel: Panel;
   private _hsplitPanel: Private.RestorableSplitPanel;
+  private _vsplitPanel: Private.RestorableSplitPanel;
   private _topHandler: Private.PanelHandler;
   private _menuHandler: Private.PanelHandler;
   private _skipLinkWidgetHandler: Private.SkipLinkWidgetHandler;

+ 4 - 8
packages/application/style/base.css

@@ -31,14 +31,6 @@ body {
   border-top: 4px solid red;
 }
 
-#jp-main-dock-panel {
-  padding: 5px;
-}
-
-#jp-main-dock-panel[data-mode='single-document'] {
-  padding: 0;
-}
-
 #jp-main-dock-panel[data-mode='single-document'] .jp-MainAreaWidget {
   border: none;
 }
@@ -56,6 +48,10 @@ body {
   background: var(--jp-layout-color1);
 }
 
+#jp-down-stack {
+  border-bottom: var(--jp-border-width) solid var(--jp-border-color1);
+}
+
 .jp-LabShell[data-shell-mode='single-document'] #jp-top-panel {
   border-bottom: none;
 }

+ 2 - 1
packages/application/style/dockpanel.css

@@ -11,7 +11,8 @@
 | DockPanel
 |----------------------------------------------------------------------------*/
 
-.lm-DockPanel-widget {
+.lm-DockPanel-widget,
+.lm-TabPanel-stackedPanel {
   background: var(--jp-layout-color0);
   border-left: var(--jp-border-width) solid var(--jp-border-color1);
   border-right: var(--jp-border-width) solid var(--jp-border-color1);

+ 44 - 5
packages/application/style/sidepanel.css

@@ -21,17 +21,20 @@
   font-size: var(--jp-ui-font-size1);
 }
 
-.jp-SideBar.lm-TabBar {
+.jp-SideBar.lm-TabBar,
+#jp-down-stack .lm-TabBar {
   color: var(--jp-ui-font-color1);
   background: var(--jp-layout-color2);
   font-size: var(--jp-ui-font-size1);
+  overflow: visible;
+}
+
+.jp-SideBar.lm-TabBar {
   min-width: calc(var(--jp-private-sidebar-tab-width) + var(--jp-border-width));
   max-width: calc(var(--jp-private-sidebar-tab-width) + var(--jp-border-width));
-  overflow: visible;
   display: block;
 }
 
-.jp-SideBar .lm-TabBar-content,
 .jp-SideBar .lm-TabBar-content {
   margin: 0;
   padding: 0;
@@ -58,7 +61,8 @@
   /* transform: translateY(var(--jp-border-width)); */
 }
 
-.jp-SideBar .lm-TabBar-tab:not(.lm-mod-current) {
+.jp-SideBar .lm-TabBar-tab:not(.lm-mod-current),
+#jp-down-stack .lm-TabBar-tab:not(.lm-mod-current) {
   background: var(--jp-layout-color2);
 }
 
@@ -76,7 +80,8 @@
   line-height: var(--jp-private-sidebar-tab-width);
 }
 
-.jp-SideBar .lm-TabBar-tab:hover:not(.lm-mod-current) {
+.jp-SideBar .lm-TabBar-tab:hover:not(.lm-mod-current),
+#jp-down-stack .lm-TabBar-tab:hover:not(.lm-mod-current) {
   background: var(--jp-layout-color1);
 }
 
@@ -182,6 +187,31 @@
     );
 }
 
+/* Down */
+
+/* Borders */
+
+#jp-down-stack > .lm-TabBar {
+  border-top: var(--jp-border-width) solid var(--jp-border-color0);
+  border-bottom: var(--jp-border-width) solid var(--jp-border-color0);
+}
+
+#jp-down-stack > .lm-TabBar .lm-TabBar-tab {
+  border-left: none;
+  border-right: none;
+}
+
+#jp-down-stack > .lm-TabBar .lm-TabBar-tab.lm-mod-current {
+  border: var(--jp-border-width) solid var(--jp-border-color1);
+  border-bottom: none;
+  transform: translateY(var(--jp-border-width));
+}
+
+#jp-down-stack > .lm-TabBar .lm-TabBar-tab.lm-mod-current:first-child {
+  border: none;
+  border-right: var(--jp-border-width) solid var(--jp-border-color1);
+}
+
 /* Stack panels */
 
 #jp-left-stack > .lm-Widget,
@@ -189,6 +219,11 @@
   min-width: var(--jp-sidebar-min-width);
 }
 
+.jp-LabShell[data-shell-mode='multiple-document'] #jp-left-stack > .lm-Widget,
+.jp-LabShell[data-shell-mode='multiple-document'] #jp-right-stack > .lm-Widget {
+  border-bottom: var(--jp-border-width) solid var(--jp-border-color1);
+}
+
 #jp-right-stack {
   border-left: var(--jp-border-width) solid var(--jp-border-color1);
 }
@@ -196,3 +231,7 @@
 #jp-left-stack {
   border-right: var(--jp-border-width) solid var(--jp-border-color1);
 }
+
+#jp-down-stack > .lm-TabPanel-stackedPanel {
+  border: none;
+}

+ 22 - 8
packages/application/style/tabs.css

@@ -18,14 +18,16 @@
 | Tabs in the dock panel
 |----------------------------------------------------------------------------*/
 
-.lm-DockPanel-tabBar {
+.lm-DockPanel-tabBar,
+.lm-TabPanel-tabBar {
   border-bottom: var(--jp-border-width) solid var(--jp-border-color1);
   overflow: visible;
   color: var(--jp-ui-font-color1);
   font-size: var(--jp-ui-font-size1);
 }
 
-.lm-DockPanel-tabBar[data-orientation='horizontal'] {
+.lm-DockPanel-tabBar[data-orientation='horizontal'],
+.lm-TabPanel-tabBar[data-orientation='horizontal'] {
   min-height: calc(
     var(--jp-private-horizontal-tab-height) + 2 * var(--jp-border-width)
   );
@@ -35,14 +37,17 @@
   min-width: 80px;
 }
 
-.lm-DockPanel-tabBar > .lm-TabBar-content {
+.lm-DockPanel-tabBar > .lm-TabBar-content,
+.lm-TabPanel-tabBar > .lm-TabBar-content {
   align-items: flex-end;
   min-width: 0;
   min-height: 0;
 }
 
-.lm-DockPanel-tabBar .lm-TabBar-tab {
+.lm-DockPanel-tabBar .lm-TabBar-tab,
+.lm-TabPanel-tabBar .lm-TabBar-tab {
   flex: 0 1 var(--jp-private-horizontal-tab-width);
+  align-items: center;
   min-height: calc(
     var(--jp-private-horizontal-tab-height) + var(--jp-border-width)
   );
@@ -56,11 +61,13 @@
   position: relative;
 }
 
-.lm-DockPanel-tabBar .lm-TabBar-tab:hover:not(.lm-mod-current) {
+.lm-DockPanel-tabBar .lm-TabBar-tab:hover:not(.lm-mod-current),
+.lm-TabPanel-tabBar .lm-TabBar-tab:hover:not(.lm-mod-current) {
   background: var(--jp-layout-color1);
 }
 
-.lm-DockPanel-tabBar .lm-TabBar-tab:first-child {
+.lm-DockPanel-tabBar .lm-TabBar-tab:first-child,
+.lm-TabPanel-tabBar .lm-TabBar-tab:first-child {
   margin-left: 0;
 }
 
@@ -74,6 +81,11 @@
   transform: translateY(var(--jp-border-width));
 }
 
+.lm-TabPanel-tabBar .lm-TabBar-tab.lm-mod-current {
+  background: var(--jp-layout-color1);
+  color: var(--jp-ui-font-color0);
+}
+
 /* This is the main application level current tab: only 1 exists. */
 .lm-DockPanel-tabBar .lm-TabBar-tab.jp-mod-current:before {
   position: absolute;
@@ -126,7 +138,8 @@
 }
 
 .lm-DockPanel-tabBar .lm-TabBar-tab .lm-TabBar-tabIcon,
-.lm-TabBar-tab.lm-mod-drag-image .lm-TabBar-tabIcon {
+.lm-TabBar-tab.lm-mod-drag-image .lm-TabBar-tabIcon,
+.lm-TabPanel-tabBar .lm-TabBar-tab .lm-TabBar-tabIcon {
   width: 14px;
   background-position: left center;
   background-repeat: no-repeat;
@@ -134,7 +147,8 @@
   margin-right: 4px;
 }
 
-.lm-DockPanel-tabBar .lm-TabBar-tab.lm-mod-current .lm-TabBar-tabIcon {
+.lm-DockPanel-tabBar .lm-TabBar-tab.lm-mod-current .lm-TabBar-tabIcon,
+.lm-TabPanel-tabBar .lm-TabBar-tab.lm-mod-current .lm-TabBar-tabIcon {
   margin-bottom: var(--jp-border-width);
 }
 

+ 28 - 1
packages/application/test/layoutrestorer.spec.ts

@@ -50,7 +50,7 @@ describe('apputils', () => {
     });
 
     describe('#add()', () => {
-      it('should add a widget to be tracked by the restorer', async () => {
+      it('should add a widget in the main area to be tracked by the restorer', async () => {
         const ready = new PromiseDelegate<void>();
         const restorer = new LayoutRestorer({
           connector: new StateDB(),
@@ -60,6 +60,7 @@ describe('apputils', () => {
         const currentWidget = new Widget();
         const dehydrated: ILabShell.ILayout = {
           mainArea: { currentWidget, dock: null },
+          downArea: { currentWidget: null, widgets: null, size: null },
           leftArea: { collapsed: true, currentWidget: null, widgets: null },
           rightArea: { collapsed: true, currentWidget: null, widgets: null },
           relativeSizes: null
@@ -71,6 +72,29 @@ describe('apputils', () => {
         const layout = await restorer.fetch();
         expect(layout.mainArea?.currentWidget).toBe(currentWidget);
       });
+
+      it('should add a widget in the down area to be tracked by the restorer', async () => {
+        const ready = new PromiseDelegate<void>();
+        const restorer = new LayoutRestorer({
+          connector: new StateDB(),
+          first: ready.promise,
+          registry: new CommandRegistry()
+        });
+        const currentWidget = new Widget();
+        const dehydrated: ILabShell.ILayout = {
+          mainArea: { currentWidget: null, dock: null },
+          downArea: { currentWidget, widgets: null, size: null },
+          leftArea: { collapsed: true, currentWidget: null, widgets: null },
+          rightArea: { collapsed: true, currentWidget: null, widgets: null },
+          relativeSizes: null
+        };
+        restorer.add(currentWidget, 'test-one');
+        ready.resolve(void 0);
+        await restorer.restored;
+        await restorer.save(dehydrated);
+        const layout = await restorer.fetch();
+        expect(layout.downArea?.currentWidget).toBe(currentWidget);
+      });
     });
 
     describe('#fetch()', () => {
@@ -96,6 +120,7 @@ describe('apputils', () => {
         const dehydrated: ILabShell.ILayout = {
           fresh: false,
           mainArea: { currentWidget: null, dock: null },
+          downArea: { currentWidget: null, widgets: null, size: 0 },
           leftArea: {
             currentWidget,
             collapsed: true,
@@ -154,6 +179,7 @@ describe('apputils', () => {
         });
         const dehydrated: ILabShell.ILayout = {
           mainArea: { currentWidget: null, dock: null },
+          downArea: { currentWidget: null, widgets: null, size: null },
           leftArea: { currentWidget: null, collapsed: true, widgets: null },
           rightArea: { collapsed: true, currentWidget: null, widgets: null },
           relativeSizes: null
@@ -176,6 +202,7 @@ describe('apputils', () => {
         const dehydrated: ILabShell.ILayout = {
           fresh: false,
           mainArea: { currentWidget: null, dock: null },
+          downArea: { currentWidget: null, widgets: null, size: 0 },
           leftArea: {
             currentWidget,
             collapsed: true,

+ 2 - 1
packages/logconsole-extension/src/index.tsx

@@ -198,11 +198,12 @@ function activateLogConsole(
       app.commands.notifyCommandChanged();
     });
 
-    app.shell.add(logConsoleWidget, 'main', {
+    app.shell.add(logConsoleWidget, 'down', {
       ref: options.ref,
       mode: options.insertMode
     });
     void tracker.add(logConsoleWidget);
+    app.shell.activateById(logConsoleWidget.id);
 
     logConsoleWidget.update();
     app.commands.notifyCommandChanged();

+ 17 - 1
packages/ui-components/src/icon/widgets/tabbarsvg.ts

@@ -2,7 +2,7 @@
 // Distributed under the terms of the Modified BSD License.
 
 import { hpass, VirtualElement } from '@lumino/virtualdom';
-import { DockPanel, TabBar, Widget } from '@lumino/widgets';
+import { DockPanel, TabBar, TabPanel, Widget } from '@lumino/widgets';
 
 import { closeIcon } from '../iconimports';
 import { LabIconStyle } from '../../style';
@@ -93,3 +93,19 @@ export namespace DockPanelSvg {
 
   export const defaultRenderer = new Renderer();
 }
+
+/**
+ * A widget which combines a `TabBar` and a `StackedPanel`.
+ * Tweaked to use an inline svg as the close icon
+ */
+export class TabPanelSvg extends TabPanel {
+  /**
+   * Construct a new tab panel.
+   *
+   * @param options - The options for initializing the tab panel.
+   */
+  constructor(options: TabPanel.IOptions = {}) {
+    options.renderer = options.renderer || TabBarSvg.defaultRenderer;
+    super(options);
+  }
+}