From a95a7489c008026a8dd7678986edb01307d586b6 Mon Sep 17 00:00:00 2001 From: Jason Weill Date: Wed, 23 Mar 2022 16:53:23 -0700 Subject: [PATCH] Incorporates files from retrolab PR 275 --- packages/application-extension/src/index.ts | 179 ++++++- packages/application/src/shell.ts | 472 ++++++++++++++++-- packages/application/style/base.css | 3 + packages/application/style/sidepanel.css | 37 ++ packages/application/test/shell.spec.ts | 135 ++++- .../top-hidden-darwin.png | Bin 0 -> 30085 bytes 6 files changed, 767 insertions(+), 59 deletions(-) create mode 100644 packages/application/style/sidepanel.css create mode 100644 ui-tests/test/settings.spec.ts-snapshots/top-hidden-darwin.png diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 40f3b2d00d..e0b9e356af 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -43,7 +43,17 @@ import { PromiseDelegate } from '@lumino/coreutils'; import { DisposableDelegate, DisposableSet } from '@lumino/disposable'; -import { Widget } from '@lumino/widgets'; +import { Menu, Widget } from '@lumino/widgets'; + +/** + * The default notebook factory. + */ +const NOTEBOOK_FACTORY = 'Notebook'; + +/** + * The editor factory. + */ +const EDITOR_FACTORY = 'Editor'; /** * A regular expression to match path to notebooks and documents @@ -64,6 +74,11 @@ namespace CommandIDs { */ export const toggleTop = 'application:toggle-top'; + /** + * Toggle sidebar visibility + */ + export const togglePanel = 'application:toggle-panel'; + /** * Toggle the Zen mode */ @@ -90,6 +105,13 @@ namespace CommandIDs { export const resolveTree = 'application:resolve-tree'; } +/** + * Are the left and right panels available on the current page? + */ +const sidePanelsEnabled: () => boolean = () => { + return PageConfig.getOption('notebookPage') === 'notebooks'; +}; + /** * Check if the application is dirty before closing the browser tab. */ @@ -151,10 +173,12 @@ const opener: JupyterFrontEndPlugin = { id: '@jupyter-notebook/application-extension:opener', autoStart: true, requires: [IRouter, IDocumentManager], + optional: [ISettingRegistry], activate: ( app: JupyterFrontEnd, router: IRouter, - docManager: IDocumentManager + docManager: IDocumentManager, + settingRegistry: ISettingRegistry | null ): void => { const { commands } = app; @@ -169,12 +193,21 @@ const opener: JupyterFrontEndPlugin = { } const file = decodeURIComponent(path); - const urlParams = new URLSearchParams(parsed.search); - const factory = urlParams.get('factory') ?? 'default'; + const ext = PathExt.extname(file); app.restored.then(async () => { - docManager.open(file, factory, undefined, { - ref: '_noref' - }); + // TODO: get factory from file type instead? + if (ext === '.ipynb') { + // TODO: fix upstream? + await settingRegistry?.load('@jupyterlab/notebook-extension:panel'); + await Promise.resolve(); + docManager.open(file, NOTEBOOK_FACTORY, undefined, { + ref: '_noref' + }); + } else { + docManager.open(file, EDITOR_FACTORY, undefined, { + ref: '_noref' + }); + } }); } }); @@ -196,7 +229,7 @@ const menus: JupyterFrontEndPlugin = { // always disable the Tabs menu menu.tabsMenu.dispose(); - const page = PageConfig.getOption('notebookPage'); + const page = PageConfig.getOption('retroPage'); switch (page) { case 'consoles': case 'terminals': @@ -564,6 +597,135 @@ const topVisibility: JupyterFrontEndPlugin = { autoStart: true }; +/** + * Plugin to toggle the left or right sidebar's visibility. + */ +const sidebarVisibility: JupyterFrontEndPlugin = { + id: '@jupyter-notebook/application-extension:sidebar', + requires: [INotebookShell, ITranslator], + optional: [IMainMenu, ISettingRegistry], + activate: ( + app: JupyterFrontEnd, + notebookShell: INotebookShell, + translator: ITranslator, + menu: IMainMenu | null, + settingRegistry: ISettingRegistry | null + ) => { + if (!sidePanelsEnabled()) { + return; + } + + const trans = translator.load('notebook'); + + /* Arguments for togglePanel command: + * side, left or right area + * title, widget title to show in the menu + * id, widget ID to activate in the sidebar + */ + app.commands.addCommand(CommandIDs.togglePanel, { + label: args => args['title'] as string, + caption: args => { + // We do not substitute the parameter into the string because the parameter is not + // localized (e.g., it is always 'left') even though the string is localized. + if (args['side'] === 'left') { + return trans.__( + 'Show %1 in the left sidebar', + args['title'] as string + ); + } else if (args['side'] === 'right') { + return trans.__( + 'Show %1 in the right sidebar', + args['title'] as string + ); + } + return trans.__('Show %1 in the sidebar', args['title'] as string); + }, + execute: args => { + switch (args['side'] as string) { + case 'left': + if (notebookShell.leftCollapsed) { + notebookShell.activateById(args['id'] as string); + notebookShell.expandLeft(); + } else { + notebookShell.collapseLeft(); + if (notebookShell.currentWidget) { + notebookShell.activateById(notebookShell.currentWidget.id); + } + } + break; + case 'right': + if (notebookShell.rightCollapsed) { + notebookShell.activateById(args['id'] as string); + notebookShell.expandRight(); + } else { + notebookShell.collapseRight(); + if (notebookShell.currentWidget) { + notebookShell.activateById(notebookShell.currentWidget.id); + } + } + break; + } + }, + isToggled: args => { + if (notebookShell.leftCollapsed) { + return false; + } + const currentWidget = notebookShell.leftHandler.current; + if (!currentWidget) { + return false; + } + + return currentWidget.id === (args['id'] as string); + } + }); + + const leftSidebarMenu = new Menu({ commands: app.commands }); + leftSidebarMenu.title.label = trans.__('Show Left Sidebar'); + + const rightSidebarMenu = new Menu({ commands: app.commands }); + rightSidebarMenu.title.label = trans.__('Show Right Sidebar'); + + app.restored.then(() => { + const leftWidgets = notebookShell.widgetsList('left'); + leftWidgets.forEach(widget => { + leftSidebarMenu.addItem({ + command: CommandIDs.togglePanel, + args: { + side: 'left', + title: widget.title.caption, + id: widget.id + } + }); + }); + + const rightWidgets = notebookShell.widgetsList('right'); + rightWidgets.forEach(widget => { + rightSidebarMenu.addItem({ + command: CommandIDs.togglePanel, + args: { + side: 'right', + title: widget.title.caption, + id: widget.id + } + }); + }); + + const menuItemsToAdd: Menu.IItemOptions[] = []; + if (leftWidgets.length > 0) { + menuItemsToAdd.push({ type: 'submenu', submenu: leftSidebarMenu }); + } + if (rightWidgets.length > 0) { + menuItemsToAdd.push({ type: 'submenu', submenu: rightSidebarMenu }); + } + + if (menu && menuItemsToAdd) { + menu.viewMenu.addGroup(menuItemsToAdd, 2); + } + }); + }, + autoStart: true +}; + /** * The default tree route resolver plugin. */ @@ -722,6 +884,7 @@ const plugins: JupyterFrontEndPlugin[] = [ router, sessionDialogs, shell, + sidebarVisibility, spacer, status, tabTitle, diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 3ece75171c..119d014f80 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -2,18 +2,23 @@ // Distributed under the terms of the Modified BSD License. import { JupyterFrontEnd } from '@jupyterlab/application'; - +import { PageConfig } from '@jupyterlab/coreutils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { ArrayExt, find, IIterator, iter } from '@lumino/algorithm'; - import { Token } from '@lumino/coreutils'; - import { Message, MessageLoop, IMessageHandler } from '@lumino/messaging'; - +import { Debouncer } from '@lumino/polling'; import { ISignal, Signal } from '@lumino/signaling'; -import { Panel, Widget, BoxLayout } from '@lumino/widgets'; +import { + BoxLayout, + Layout, + Panel, + SplitPanel, + StackedPanel, + Widget +} from '@lumino/widgets'; /** * The Jupyter Notebook application shell token. @@ -40,39 +45,99 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { super(); this.id = 'main'; - const rootLayout = new BoxLayout(); - this._topHandler = new Private.PanelHandler(); this._menuHandler = new Private.PanelHandler(); + this._leftHandler = new Private.SideBarHandler(); + this._rightHandler = new Private.SideBarHandler(); this._main = new Panel(); + const topWrapper = (this._topWrapper = new Panel()); + const menuWrapper = (this._menuWrapper = new Panel()); this._topHandler.panel.id = 'top-panel'; this._menuHandler.panel.id = 'menu-panel'; this._main.id = 'main-panel'; + this._spacer = new Widget(); + this._spacer.id = 'spacer-widget'; + // create wrappers around the top and menu areas - const topWrapper = (this._topWrapper = new Panel()); topWrapper.id = 'top-panel-wrapper'; topWrapper.addWidget(this._topHandler.panel); - const menuWrapper = (this._menuWrapper = new Panel()); menuWrapper.id = 'menu-panel-wrapper'; menuWrapper.addWidget(this._menuHandler.panel); - BoxLayout.setStretch(topWrapper, 0); - BoxLayout.setStretch(menuWrapper, 0); + BoxLayout.setStretch(this._topWrapper, 0); + BoxLayout.setStretch(this._menuWrapper, 0); + + if (this.sidePanelsVisible()) { + this.layout = this.initLayoutWithSidePanels(); + } else { + this.layout = this.initLayoutWithoutSidePanels(); + } + } + + initLayoutWithoutSidePanels(): Layout { + const rootLayout = new BoxLayout(); + BoxLayout.setStretch(this._main, 1); this._spacer = new Widget(); this._spacer.id = 'spacer-widget'; rootLayout.spacing = 0; - rootLayout.addWidget(topWrapper); - rootLayout.addWidget(menuWrapper); + rootLayout.addWidget(this._topWrapper); + rootLayout.addWidget(this._menuWrapper); rootLayout.addWidget(this._spacer); rootLayout.addWidget(this._main); - this.layout = rootLayout; + return rootLayout; + } + + initLayoutWithSidePanels(): Layout { + const rootLayout = new BoxLayout(); + const leftHandler = this._leftHandler; + const rightHandler = this._rightHandler; + const mainPanel = this._main; + + this.leftPanel.id = 'jp-left-stack'; + this.rightPanel.id = 'jp-right-stack'; + + // Hide the side panels by default. + leftHandler.hide(); + rightHandler.hide(); + + // TODO: Consider storing this as an attribute this._hsplitPanel if saving/restoring layout needed + const hsplitPanel = new SplitPanel(); + hsplitPanel.id = 'main-split-panel'; + hsplitPanel.spacing = 1; + + // Catch current changed events on the side handlers. + leftHandler.updated.connect(this._onLayoutModified, this); + rightHandler.updated.connect(this._onLayoutModified, this); + + BoxLayout.setStretch(hsplitPanel, 1); + + SplitPanel.setStretch(leftHandler.stackedPanel, 0); + SplitPanel.setStretch(rightHandler.stackedPanel, 0); + SplitPanel.setStretch(mainPanel, 1); + + hsplitPanel.addWidget(leftHandler.stackedPanel); + hsplitPanel.addWidget(mainPanel); + hsplitPanel.addWidget(rightHandler.stackedPanel); + + // 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. + hsplitPanel.setRelativeSizes([1, 2.5, 1]); + + rootLayout.spacing = 0; + rootLayout.addWidget(this._topWrapper); + rootLayout.addWidget(this._menuWrapper); + rootLayout.addWidget(this._spacer); + rootLayout.addWidget(hsplitPanel); + + return rootLayout; } /** @@ -103,13 +168,62 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { return this._menuWrapper; } + /** + * Get the left area handler + */ + get leftHandler(): Private.SideBarHandler { + return this._leftHandler; + } + + /** + * Get the right area handler + */ + get rightHandler(): Private.SideBarHandler { + return this._rightHandler; + } + + /** + * Shortcut to get the left area handler's stacked panel + */ + get leftPanel(): StackedPanel { + return this._leftHandler.stackedPanel; + } + + /** + * Shortcut to get the right area handler's stacked panel + */ + get rightPanel(): StackedPanel { + return this._rightHandler.stackedPanel; + } + + /** + * Is the left sidebar visible? + */ + get leftCollapsed(): boolean { + return !(this._leftHandler.isVisible && this.leftPanel.isVisible); + } + + /** + * Is the right sidebar visible? + */ + get rightCollapsed(): boolean { + return !(this._rightHandler.isVisible && this.rightPanel.isVisible); + } + /** * Activate a widget in its area. */ activateById(id: string): void { - const widget = find(this.widgets('main'), w => w.id === id); - if (widget) { - widget.activate(); + // Search all areas that can have widgets for this widget, starting with main. + for (const area of ['main', 'top', 'left', 'right', 'menu']) { + if ((area === 'left' || area === 'right') && !this.sidePanelsVisible()) { + continue; + } + + const widget = find(this.widgets(area), w => w.id === id); + if (widget) { + widget.activate(); + } } } @@ -126,24 +240,37 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { */ add( widget: Widget, - area?: Shell.Area, + area?: string, options?: DocumentRegistry.IOpenOptions ): void { const rank = options?.rank ?? DEFAULT_RANK; - if (area === 'top') { - return this._topHandler.addWidget(widget, rank); - } - if (area === 'menu') { - return this._menuHandler.addWidget(widget, rank); - } - if (area === 'main' || area === undefined) { - if (this._main.widgets.length > 0) { - // do not add the widget if there is already one - return; - } - this._main.addWidget(widget); - this._main.update(); - this._currentChanged.emit(void 0); + switch (area) { + case 'top': + return this._topHandler.addWidget(widget, rank); + case 'menu': + return this._menuHandler.addWidget(widget, rank); + case 'main': + case undefined: + if (this._main.widgets.length > 0) { + // do not add the widget if there is already one + return; + } + this._main.addWidget(widget); + this._main.update(); + this._currentChanged.emit(void 0); + break; + case 'left': + if (this.sidePanelsVisible()) { + return this._leftHandler.addWidget(widget, rank); + } + throw new Error(`${area} area is not available on this page`); + case 'right': + if (this.sidePanelsVisible()) { + return this._rightHandler.addWidget(widget, rank); + } + throw new Error(`${area} area is not available on this page`); + default: + throw new Error(`Cannot add widget to area: ${area}`); } } @@ -164,27 +291,121 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { } /** - * Return the list of widgets for the given area. - * - * @param area The area + * Expand the left panel to show the sidebar with its widget. */ - widgets(area: Shell.Area): IIterator { + expandLeft(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Left panel is not available on this page'); + } + this.leftPanel.show(); + this._leftHandler.expand(); // Show the current widget, if any + this._onLayoutModified(); + } + + /** + * Collapse the left panel + */ + collapseLeft(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Left panel is not available on this page'); + } + this._leftHandler.collapse(); + this.leftPanel.hide(); + this._onLayoutModified(); + } + + /** + * Expand the right panel to show the sidebar with its widget. + */ + expandRight(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Right panel is not available on this page'); + } + this.rightPanel.show(); + this._rightHandler.expand(); // Show the current widget, if any + this._onLayoutModified(); + } + + /** + * Collapse the right panel + */ + collapseRight(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Right panel is not available on this page'); + } + this._rightHandler.collapse(); + this.rightPanel.hide(); + this._onLayoutModified(); + } + + widgetsList(area?: string): readonly Widget[] { switch (area ?? 'main') { case 'top': - return iter(this._topHandler.panel.widgets); + return this._topHandler.panel.widgets; case 'menu': - return iter(this._menuHandler.panel.widgets); + return this._menuHandler.panel.widgets; case 'main': - return iter(this._main.widgets); + return this._main.widgets; + case 'left': + if (this.sidePanelsVisible()) { + return this._leftHandler.stackedPanel.widgets; + } + throw new Error(`Invalid area: ${area}`); + case 'right': + if (this.sidePanelsVisible()) { + return this._rightHandler.stackedPanel.widgets; + } + throw new Error(`Invalid area: ${area}`); default: throw new Error(`Invalid area: ${area}`); } } + /** + * Return the list of widgets for the given area. + * + * @param area The area + */ + widgets(area?: string): IIterator { + return iter(this.widgetsList(area)); + } + + /** + * Is a particular area empty (no widgets)? + * + * @param area Named area in the application + * @returns true if area has no widgets, false if at least one widget is present + */ + isEmpty(area: Shell.Area): boolean { + return this.widgetsList(area).length === 0; + } + + /** + * Can the shell display a left or right panel? + * + * @returns True if the left and right side panels could be shown, false otherwise + */ + sidePanelsVisible(): boolean { + return PageConfig.getOption('notebookPage') === 'notebooks'; + } + + /** + * Handle a change to the layout. + */ + private _onLayoutModified(): void { + void this._layoutDebouncer.invoke(); + } + + private _layoutModified = new Signal(this); + private _layoutDebouncer = new Debouncer(() => { + this._layoutModified.emit(undefined); + }, 0); private _topWrapper: Panel; private _topHandler: Private.PanelHandler; private _menuWrapper: Panel; private _menuHandler: Private.PanelHandler; + private _leftHandler: Private.SideBarHandler; + private _rightHandler: Private.SideBarHandler; private _spacer: Widget; private _main: Panel; private _currentChanged = new Signal(this); @@ -197,7 +418,7 @@ export namespace Shell { /** * The areas of the application shell where widgets can reside. */ - export type Area = 'main' | 'top' | 'menu'; + export type Area = 'main' | 'top' | 'left' | 'right' | 'menu'; } /** @@ -289,4 +510,175 @@ namespace Private { private _items = new Array(); private _panel = new Panel(); } + + /** + * A class which manages a side bar that can show at most one widget at a time. + */ + export class SideBarHandler { + /** + * Construct a new side bar handler. + */ + constructor() { + this._stackedPanel = new StackedPanel(); + this._stackedPanel.hide(); + this._current = null; + this._lastCurrent = null; + this._stackedPanel.widgetRemoved.connect(this._onWidgetRemoved, this); + } + + get current(): Widget | null { + return ( + this._current || + this._lastCurrent || + (this._items.length > 0 ? this._items[0].widget : null) + ); + } + + /** + * Whether the panel is visible + */ + get isVisible(): boolean { + return this._stackedPanel.isVisible; + } + + /** + * Get the stacked panel managed by the handler + */ + get stackedPanel(): StackedPanel { + return this._stackedPanel; + } + + /** + * Signal fires when the stacked panel changes + */ + get updated(): ISignal { + return this._updated; + } + + /** + * Expand the sidebar. + * + * #### Notes + * This will open the most recently used widget, or the first widget + * if there is no most recently used. + */ + expand(): void { + const visibleWidget = this.current; + if (visibleWidget) { + this._current = visibleWidget; + this.activate(visibleWidget.id); + } + } + + /** + * Activate a widget residing in the stacked panel by ID. + * + * @param id - The widget's unique ID. + */ + activate(id: string): void { + const widget = this._findWidgetByID(id); + if (widget) { + this._current = widget; + widget.show(); + widget.activate(); + } + } + + /** + * Test whether the sidebar has the given widget by id. + */ + has(id: string): boolean { + return this._findWidgetByID(id) !== null; + } + + /** + * Collapse the sidebar so no items are expanded. + */ + collapse(): void { + this._current = null; + } + + /** + * Add a widget and its title to the stacked panel. + * + * If the widget is already added, it will be moved. + */ + addWidget(widget: Widget, rank: number): void { + widget.parent = null; + widget.hide(); + const item = { widget, rank }; + const index = this._findInsertIndex(item); + ArrayExt.insert(this._items, index, item); + this._stackedPanel.insertWidget(index, widget); + + // TODO: Update menu to include widget in appropriate position + + this._refreshVisibility(); + } + + /** + * Hide the side panel + */ + hide(): void { + this._isHiddenByUser = true; + this._refreshVisibility(); + } + + /** + * Show the side panel + */ + show(): void { + this._isHiddenByUser = false; + this._refreshVisibility(); + } + + /** + * Find the insertion index for a rank item. + */ + private _findInsertIndex(item: Private.IRankItem): number { + return ArrayExt.upperBound(this._items, item, Private.itemCmp); + } + + /** + * Find the index of the item with the given widget, or `-1`. + */ + private _findWidgetIndex(widget: Widget): number { + return ArrayExt.findFirstIndex(this._items, i => i.widget === widget); + } + + /** + * Find the widget with the given id, or `null`. + */ + private _findWidgetByID(id: string): Widget | null { + const item = find(this._items, value => value.widget.id === id); + return item ? item.widget : null; + } + + /** + * Refresh the visibility of the stacked panel. + */ + private _refreshVisibility(): void { + this._stackedPanel.setHidden(this._isHiddenByUser); + this._updated.emit(); + } + + /* + * Handle the `widgetRemoved` signal from the panel. + */ + private _onWidgetRemoved(sender: StackedPanel, widget: Widget): void { + if (widget === this._lastCurrent) { + this._lastCurrent = null; + } + ArrayExt.removeAt(this._items, this._findWidgetIndex(widget)); + // TODO: Remove the widget from the menu + this._refreshVisibility(); + } + + private _isHiddenByUser = false; + private _items = new Array(); + private _stackedPanel: StackedPanel; + private _current: Widget | null; + private _lastCurrent: Widget | null; + private _updated: Signal = new Signal(this); + } } diff --git a/packages/application/style/base.css b/packages/application/style/base.css index 35378129f5..ab649101d3 100644 --- a/packages/application/style/base.css +++ b/packages/application/style/base.css @@ -80,3 +80,6 @@ body[data-notebook='notebooks'] #main-panel { body[data-notebook='notebooks'] #spacer-widget { min-height: unset; } + +/* Sibling imports */ +@import './sidepanel.css'; diff --git a/packages/application/style/sidepanel.css b/packages/application/style/sidepanel.css new file mode 100644 index 0000000000..58c56d63cc --- /dev/null +++ b/packages/application/style/sidepanel.css @@ -0,0 +1,37 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +| +| Adapted from JupyterLab's packages/application/style/sidepanel.css. +|----------------------------------------------------------------------------*/ + +/*----------------------------------------------------------------------------- +| Variables +|----------------------------------------------------------------------------*/ + +:root { + --jp-private-sidebar-tab-width: 32px; +} + +/*----------------------------------------------------------------------------- +| SideBar +|----------------------------------------------------------------------------*/ + +/* Left */ + +/* Right */ + +/* Stack panels */ + +#jp-left-stack > .lm-Widget, +#jp-right-stack > .lm-Widget { + min-width: var(--jp-sidebar-min-width); +} + +#jp-right-stack { + border-left: var(--jp-border-width) solid var(--jp-border-color1); +} + +#jp-left-stack { + border-right: var(--jp-border-width) solid var(--jp-border-color1); +} diff --git a/packages/application/test/shell.spec.ts b/packages/application/test/shell.spec.ts index 9f8cc5cd25..87af0341c3 100644 --- a/packages/application/test/shell.spec.ts +++ b/packages/application/test/shell.spec.ts @@ -1,23 +1,33 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { NotebookShell, INotebookShell } from '@jupyter-notebook/application'; +import { + INotebookShell, + NotebookShell, + Shell +} from '@jupyter-notebook/application'; import { JupyterFrontEnd } from '@jupyterlab/application'; import { toArray } from '@lumino/algorithm'; - import { Widget } from '@lumino/widgets'; -describe('Shell', () => { +describe('Shell for notebooks', () => { let shell: INotebookShell; + let sidePanelsVisibleSpy: jest.SpyInstance; beforeEach(() => { shell = new NotebookShell(); + sidePanelsVisibleSpy = jest + .spyOn(shell, 'sidePanelsVisible') + .mockImplementation(() => { + return true; + }); Widget.attach(shell, document.body); }); afterEach(() => { + sidePanelsVisibleSpy.mockRestore(); shell.dispose(); }); @@ -25,10 +35,16 @@ describe('Shell', () => { it('should create a LabShell instance', () => { expect(shell).toBeInstanceOf(NotebookShell); }); + + it('should make all areas empty initially', () => { + ['main', 'top', 'left', 'right', 'menu'].forEach(area => + expect(shell.isEmpty(area as Shell.Area)).toBe(true) + ); + }); }); describe('#widgets()', () => { - it('should add widgets to existing areas', () => { + it('should add widgets to main area', () => { const widget = new Widget(); shell.add(widget, 'main'); const widgets = toArray(shell.widgets('main')); @@ -38,8 +54,8 @@ describe('Shell', () => { it('should throw an exception if the area does not exist', () => { const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; expect(() => { - jupyterFrontEndShell.widgets('left'); - }).toThrow('Invalid area: left'); + jupyterFrontEndShell.widgets('fake'); + }).toThrow('Invalid area: fake'); }); }); @@ -62,16 +78,14 @@ describe('Shell', () => { const widget = new Widget(); widget.id = 'foo'; shell.add(widget, 'top'); - const widgets = toArray(shell.widgets('top')); - expect(widgets.length).toBeGreaterThan(0); + expect(shell.isEmpty('top')).toBe(false); }); it('should accept options', () => { const widget = new Widget(); widget.id = 'foo'; shell.add(widget, 'top', { rank: 10 }); - const widgets = toArray(shell.widgets('top')); - expect(widgets.length).toBeGreaterThan(0); + expect(shell.isEmpty('top')).toBe(false); }); }); @@ -80,8 +94,107 @@ describe('Shell', () => { const widget = new Widget(); widget.id = 'foo'; shell.add(widget, 'main'); + expect(shell.isEmpty('main')).toBe(false); + }); + }); + + describe('#add(widget, "left")', () => { + it('should add a widget to the left area', () => { + const widget = new Widget(); + widget.id = 'foo'; + shell.add(widget, 'left'); + expect(shell.isEmpty('left')).toBe(false); + }); + }); + + describe('#add(widget, "right")', () => { + it('should add a widget to the right area', () => { + const widget = new Widget(); + widget.id = 'foo'; + shell.add(widget, 'right'); + expect(shell.isEmpty('right')).toBe(false); + }); + }); +}); + +describe('Shell for tree view', () => { + let shell: INotebookShell; + let sidePanelsVisibleSpy: jest.SpyInstance; + + beforeEach(() => { + shell = new NotebookShell(); + sidePanelsVisibleSpy = jest + .spyOn(shell, 'sidePanelsVisible') + .mockImplementation(() => { + return false; + }); + Widget.attach(shell, document.body); + }); + + afterEach(() => { + sidePanelsVisibleSpy.mockRestore(); + shell.dispose(); + }); + + describe('#constructor()', () => { + it('should create a LabShell instance', () => { + expect(shell).toBeInstanceOf(NotebookShell); + }); + + it('should make all areas empty initially', () => { + ['main', 'top', 'menu'].forEach(area => + expect(shell.isEmpty(area as Shell.Area)).toBe(true) + ); + }); + }); + + describe('#widgets()', () => { + it('should add widgets to existing areas', () => { + const widget = new Widget(); + shell.add(widget, 'main'); const widgets = toArray(shell.widgets('main')); - expect(widgets.length).toBeGreaterThan(0); + expect(widgets).toEqual([widget]); + }); + + it('should throw an exception if a fake area does not exist', () => { + const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; + expect(() => { + jupyterFrontEndShell.widgets('fake'); + }).toThrow('Invalid area: fake'); + }); + + it('should throw an exception if the left area does not exist', () => { + const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; + expect(() => { + jupyterFrontEndShell.widgets('left'); + }).toThrow('Invalid area: left'); + }); + + it('should throw an exception if the right area does not exist', () => { + const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; + expect(() => { + jupyterFrontEndShell.widgets('right'); + }).toThrow('Invalid area: right'); + }); + }); + + describe('#add(widget, "left")', () => { + it('should fail to add a widget to the left area', () => { + const widget = new Widget(); + widget.id = 'foo'; + expect(() => { + shell.add(widget, 'left'); + }).toThrow('left area is not available on this page'); + }); + }); + + describe('#add(widget, "right")', () => { + it('should fail to add a widget to the right area', () => { + const widget = new Widget(); + widget.id = 'foo'; + expect(() => { + shell.add(widget, 'right'); + }).toThrow('right area is not available on this page'); }); }); }); diff --git a/ui-tests/test/settings.spec.ts-snapshots/top-hidden-darwin.png b/ui-tests/test/settings.spec.ts-snapshots/top-hidden-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..34ed79f43d697a714d68c598877948646b0aac88 GIT binary patch literal 30085 zcmc$`1yq*X_bvQl2Z|mOK`;PCKoO**Y(YSjM!KaDq$KU26r>R>P)TW!GyrKu>6Vi2 zu5WJR{Qln^-~W#L-EqgbjB$>Lyzl!wd#}CLTyxI#+`n-C?7B5O)=(&vb>d>DWGIy7 z_;$&cRV(mExl^M8{-CmwIeUT1ct76Reg~_byDIK9Ta^#2fl^&t>j2$sDB(b3EbK{`lJot}a^MzTBEZ6Nklx z8ojtd{Vw}K&4KFT4yX7m^|;C2aYxbSxXD?|+QznN$Dk7ZMB0sizgYdF&t*{U`THf~ zxS8_c-k-0>I%_DWPyP8SI=tNV?{Dqt2v~CL?>juQl%#t4_a$Ni5tIvmU-FV(ma_lv zYh*l?%L5MUoH68kp4Qit6)gMe{FRA5<+dvwMO%z2BlFneGCx#CDH>Kq(+2aI>(9@O zU;X*znOyzWjT?_=T^qL)a>kFAEnogM#Z)DGx|6OdTKQ&Jm_~f^ApwEF{`%CmA3sjy zJ=l`iw|3pSj!*YD@e2qH_a&BBq+QW5`u_T&i7D#?w{3?KToxDD2Q%E<)~2MSgf}L! z+YB~&#Kdr%KYua&9QG@j847=eyiwm>nDU%a*$;RZ)u8sF->G@7B7C+9P^!>ds4Yej% z<-G2^Z5m9htoIMdM{cjblyv$1Sw^SF9cgdha!&Ns%J!H>sb?MCDQHtNJoW9B^ypyD z;u1}+K+ZtPd=VEH7lvy;cQZH+9d}DE&(FV8t?uB_`tj~)gH^d+EuV1}d!*c(58vO& zj;>=nS3J>QKRh}4O6%htN{E2f3Rk^izt-Mfl_06&AGiYDx+KG~{`zgMiwl!EnNvU9 z94BfGn4A1?`v{HG<@Vz}@@L$)zwiF`%CI_ir$JfBZZ0lwW(mJ9*-o>LVT+ZD8XLE5 zD_DCdQ!~W8A?-@Z6G4C6x=K(SMWM|>mJUQ50K0n!P{zjilQ%^5X zR$4euT6F#~GsWH0lV$(@d%kRnGU=9jeup&7zV=>{iMf6jbz5+$TWPMWoqjQZ2h& z)T)yj?~y;qXV#$ERrZ95g=I&5(FX(N-|_sCoh`USg<12*r~A>$i7W+9@87?7-+p)r z4Gqm58pg7P`B{lH>!GeN7cR|Qija`dc0n8VuV26BXUF9%1SzW9FLxDjnz86Cp>1-W zHJzCpH2=ygH1qRC2>+EEIt8Az^}J>c_kx4z5X$?QnU`y5Xq-54f+8j^t~Jn*J~cJf z($@B|uQpL!TzvQL-BkGJHlb(Fo=wfnSpNL7oATk~$KmmD<^uDT9{scE^E z;eF=j=U1;=#}g`BF*$)feWQ{Zd$HTd)U=e&Jo9=^PR^AQ8qv99&DlXkn`D*tJz62j4S%l#%TTIw{}Sa4?@-Q~*2 zEql*;w*Sa;+iG6_M)p-rs(IKjzje>%hYT`8^JCwVjH^A@(ebwQ_m>v=vUzmn<>&9v zs(lsad=4?Wom-!J?Y4tSmuGhzxqK{`*Rsp2BGc7#>Tsx__Zx9xhR8K zlMHmcCM`EN=@1$%;XXq_ z4t|dWK<=p6k7F+r)2G)J?1c`3CnpCQSFc{J)&BVrx$2=Ii%5kSBf01m%a^wf4w7`Q zly!{wSjz7F-`j3!%Qkt^Rl~kx%Zp3vTVhh1v{7Et8FEbP zc@HQ=f95V1TAKU%;tO(V8McEdcDEb-HJrsYHJ=VU;4TAsO?T9%nv;||H9M=r6*x3D zR$^TfnUNv5g-zilQhr)ShDwqXMOtKlci*utiNS^GfX`ID#_w5b8K3|U5T-I`XoBC5+;jKG&DxMx+>XtH^HQBVZV(Gh2 zpHB7l_g^1tjk~*k=N_z?qM~9?Rg6TKh!Y9K!(OeQAMH(Cx0jLEB;YV(`&+G#b_hgL zq3<`<(`II6O%E3^DyLgAqfj#=ICxF#$YCAGnpDqTTyS7;=sW!GYWI@d_Cg;N3YxC+ zuvcb{@8n`tX>)&$x}Ub}sf;?CY*cBPsc$w|pKM&cYVBGb#6C*rm*=O~E?Kg~-Iep; z!S!M^H&E`?O-4D-oIX8VA#q5r)@HO*s;%or=3roDq`X_ThRaSImbMJ-wtNqq_RSKr zzcERBne4#Q+|;IPZpf?Ka|HSM+wJGBFI{=>!Gj;yS2A#>RQzVBjXUA@27L6FuIw= zl{4Wl+}zyaMMqEUX?$m`gRCkUDioNO#{bT`H^2B$j>)n*Kba_n7?Um6R?;xE#}_ax zT{DqwP?cmT`(n?wiX1kZ{yM#{yg2Rn`urrOVWn$XSy|eP~fR}r2I|Ap7 zd@X8x!mX3cdPs;sZ#TU}X70TTz4ibrby)ZFhP@Polwvm9$E~Lf8|p>phL&3Qe1C>A zs-&#^_1VdlERq4_FsIq{?*MjuF7Cr38Fb{$g)WYwdFOKBNy^Y~wEA0?9EkG2J?pJ$2*t>-|-+YQ+Qz^xAVB zc<{h@qX+OT?P#I!*q4(63_5yxq)ypQHrfg}&+!9ymV^lS0N*X`b`u>NXbhen?V^*n zM~OuB4g;RK@a)8GZ9|7U1q=@E$aFhCnqre5`1owXA3rL>CHm@;-87u1Zzm-sX`7x^ z2Ryr*lOqZM)YkI;CNS--OUXuuah#DGSFKy8H#O9HIy|A`s^68rdPZv+bj`(^FF$Hf>sZ45xh6`t>X*)8D_#0opFht%_0cSo7^h zq(+X2m+-{)k9TPd0N35=_zp^>r5aTpSV6r#_x9RtLqkJP)w8bUTl9I$)l3Srv%BMm z{{VAfor-`J9=2W3`hyo88k4D>`ufUy=J4U-%SC40gzHS*vt9@Bf@-+FdNzo*V5fwFjcUfB$~#3onr+ zE%*0@YL5N<>b_HOE3!nB!=P}JUE8`g5r?VPyHt3`y+@90c5raW_hKv;9L&@yY4o4w z=^W1vlw@fF%5Adm+F4Oiak)8LcrbI+Gk&r4E@Q+w-vd|s>$YIQCV;$gLQTIeq>X21 z`WEVbC@VWyJ~>}CdKKsUS65qJpY*hRPrs$8zx+p! zYU{`z-5Xx2&s`m>#=Z5#X9**t0|0C_b#;EnibwoHLQ0UxENpBJ_D8g{ad3DbyKZ~! zB_Vs_#1hngxoD-Yd%D=V9R$(Z0j#ujcIwLw>-k-YQ;d{jRas@o8=P)G%3@<RjIMOOwgo zPucwYRU0?577Wy#KK8f6J~go7f2~C?LErQ`seae*cf|kmKCxikQQM#8x&EieU+GhL z3!DD&<5JnnmtPA2Bqt>ukz|qeyZP@YwwHNd{AqxEh1+_2@82Ka@?V$%u`^qq zdieTQQT@H*jjVAdN4_imeS?>CGSgwdyW)HI{s+1sk+pwC!$A)9|6FDF`LGj%o~tQa z#_s=ljhNwQ4*z$uH{NsYq;zhXVcqa|X&!lBHE!`22s==$r5Q*~F<&=nxn}l1Y=)R& z@u~%`gvf;L%ly(QoK|oB>&pe^5B~iq8E@QXN=vy3uYdZ6x01_UX?Oa`4!z?1m&`bK zsgv_is>NW{uD8**UG-QfnLIy>yPGpl{7Zyvl*q6S&B?qoqrZN!NM@B1%aYu=6FDI# zazB);RZ=*r@^=M_B2=ux8ZE#MGBL zJbR7BnFB%TH@V7J{)c_?(+izE^lHP~B*kS1Ww*FKbkf@JH$WdHL`QcihbvG8C>k^b z1w0U|y)reVbv{g_6y=EWTK3g?j~zVE(Q$+~aQ%MGql$|U*^3b>mzXu*&`_22Rh>Uz zLfQV$Ki)BW(db>P@Bin~Sj!?D8*uY`E02>qj{lwpQx{?v7 zaD`2lN>l%K|7+mn1fXb>dM8K6?L0=0!-JSD73&fWXb3|QTA-S4dAp<7pZoHcU8v}a zCpb3!eiv4{qDw#hl@axB6TGrEI=oLU|LG?DLe)Af5q1J zkhbyK0U$*89Xt>4p{@P>YJJszS3znGWv%9yr^i9ufE41imuK<^*FBT~#B z-GF*@d8yLVS0G3}<>v!xhy!0M0~)Mfzy5T#&G7)q2pJV;wcls%B@-)!{VBm3?{BU& zs9yke_V|SHsUk1iCi}ZoOIK*}2G=GVZ}SowStU9@w&~-Yb!;id@6yshlZbAvpZ=3o zi@YDGvmFZpEgNv-|~)?l{Lw%F^I4l4h*D={`$_^xTiX<9H6HV z4dFE2{y_uZpPgqGr_!WiW@6f~ZJU7*j<)NVmM02)t*5BN1iP@q#7kT)y^!5QfZuO` zD+g86j+vOSlLf)0bfDD{0=gv>0uqhySn)rFvcOddrV^5p>|atsLW9GAi}Bh5-22L7 zSFauc?#*8Sh&*xfBzm8ZCcK+=>?i_*S5Uy{QZhF^N_Gn8YsoHNUO&Lm3N(^caT?M9 zpmx(8{vKAh@7%FOoH&*z0%VeI3xp{CK?5z}9>kh)b?laz@jr#+@f}O;D*`)R#Ma61%HYx-4em=g~*x2RjRCZ`y=I~CR^77(AGX(LOBdIv8X~;)2T7h+^ z5K7m1wx17V8ci-1!NBqSKB=Hnh=v_s zio!XS^wktJuU@ZS89WP>4QShTNWH{ITEuW};OEbPu3KdGS#va(Rx-_ zmkAt~pHafS(@7?^-p6mQ7;dy}-tuz_-)%{op?QiIQg7#UMZceLrR~gCcBNb}-L7MA zIYu|MRBX4Y$MTYuMN5CXQ1=HGj>IWm>}&V84nH_yMcKMZR8$laKq-j$Q*v^f3B_~q z#Tk>jr2F%;liyxn-0QqJZ);Q)Esnm5@-{h{qeH>1JsM$8m|twTs@D~3YrRkRHi8O_ z6{~)I;n@=NtnsZ9e(bxMn3lOZ45Z)HaGCdQ%&^^rzQ$oNYo%+g$-xSq2il;>$;a#q zbMywq@dVWRkG?)t?dom6Q+m2uN92;XN6^o9OgSX#mnvo0a4ak=xHF0}xPsXH^5QH_ zi9R=Bo1C4UDROb@Pt0@X{m_v6o3x!?^9(hHtQ*p?6^ai4-Sx3bXF47uxz~|74P=wO zL>B_^K12Pf%_B$+Z^3`+oX-VC2YO+JLE3nid$Sa9U%5eNzd&KX5XYq?LWz?m2hDEw z)Q`0h=br@iCm#B<#R`ES9nZx+X7O=f!;n~OA_OEu2uUEwmoJ%q{P=Ot%Zu+uf?y?% zEpUH+K8tZv5D0h_ypib0e^?J5KYrZK!NK2#dkqs!l|tNAW8w7blYXm@`uCBmv#)KcQIto4hUXKF%d`iU|65R0hZz!aB+AA zcsKI(=tIQ@&5e(Qj$HoYihZe@SIy{ja^k zf8g)E|HES5fB!MO;f=1|S53Pm8mNVpUU${-rg@!vv2}Y+bJ~@gJjPWZ%cb15awO1a zfTSJBS#;jPW2F0NLO;d4Sp-b)``qhG$+o313fj0Md`aEf{8LCVP91a}bGZ)0jM}$a zOK+}Nht_l;rS3^B@;2$9z!1I8D>Gb;R$O%wE3I89lfghEc9F9$NLUOa-7aIYuAD$Sa@nX*d!OF zP+G%4M^_AD?Pqh2C|I(s#-lL@)iXbXfd&cw9t7g~Cr7vRj*tAMFhI4$$FqjU5i$!Z z&9f82gk=PUtXHd0F}a93Q=4gT6+7zyCK3>n{hE|3%F9jz0|O{@vh7xnKN(#7mFZvd zH^@`9lzz#fdTTtO^0dvz%Y-M?hmZ-NUeO)rgQ`ndMvXM_qm-|i-H&1cYQhmDGSj|& zqhsHs>FR0d`E!xuSaor?Vx)0{Dg|z3fFR> zQOReX8Yw2qDW;H3S-@lE<{rZ-F*I51eia7bcPNEoF*DW;+FKSSfPEIl+}9Tl0#Gqm*I;{XqwCqc(=x{kX5amO``h=qKE}*{l$= z)8g5)XA_aeD9AReupi&PZQ8eQ9|ieBg`KJeFz~fT&VuQ3SHC&-JGX8@SWPf7u1o4R z4NKkccTjB&;S1081p$JbI(v5Mg=8c7hK2@rC+sXgKmTK;-&JE^g{?egRd;LHs4VxI zt9|3<1`i!|jXh_dKYtz|?7)i@oWdOJ9|r_h0elYK=fck}22tm^l!Yk{H}EDLHnC6@ z0xeCCxH11F`D;Sa^09Q!(ut*I}PV#x?R6aX-*RI1?aR@Ra z2u4)bt*%7xg%Uan%*|;#pt@_`>kCeFE0n&c$8YjORQ9t%`$+@U(b35XS24agu6Ak} zQPM<2M2IX0!qg`ug!-kT7}#llB6s6=2`vnClbFm6L3{VfJDdPD^crWi0Q*%LLqkKA z8Pe^4+~3E4<;OClsG{+JkQsD&+@KgC&aTP@6eH53=;9nV1s%;;_qV&qP}_QV0Kkb@ zWl$el?z4Pc}V5*L(?333{6RwHqr+^HOVCWh$?GrzTd4T<@cqzVVp?PuE<;-dD zY})zmv_!J~R8X*VC}(lr3wupBsFCkrInpi;cvx-_=A=K?n`uAF8C*&^vkNOh)KE}G zURU}X%Iu@S%^k*dLU;aI6+;I(alc7wXRl3U=Saq)RmLC?HM#U{2URJWsB>dIm2PbX zUP~>8T8<-5bR0!DpHg?Kd?n4OUmoh=@4pSHL>qevt&7{Rf+;gIljJ-iLHX>H&c_BL z@^JbMDNWaUIS3k{%uMtbLn8c%4*@eqnht1=ODrHivq* zYBT8#1Vq-;^J{+PJvK39jvt-j(YzuiCZ<{Rm=&VuVY3E)Fnh%bdd1uM%tGQhtQ>x2 z#7~w3b(A3+Vet2a9tVqNh08UW#SN0AQ4YaTDbwySybD?ovq7zepmfu@JYE1Cr^iux z_;AB6ry4cHpk-zRa6G&gB7ogGaFEnaAZ?IN06eKuy!LXOKRs*~(WZeobElTtgDoe2 zBP{`fXlS%t6nJ6h3L1uxj%0cP%UiHY41JfCK5X412U%owE_m3F==B0{K4c>#wIHiP z#rtx35xO!hkI`mmn(a{8$(n&}Ys*9mBB1~pb!4RY(6-^7VthCPoh5-jy)T}kT7Ck{ zJ&glxIj19lJwk!u?BXBgQ5lL0T}ltq4YOw$i=Wlm^0-m|GySp&$N}HIJROx6);OWz+51Z6*?%*{MgB<{MmmdX$(C?=Q z+EgGmaN6`K0Smc}&CLxgHVg>b+Sh2cg&MOucn%E{Z*mc_{ zNCzT6_p905%)Q(-wH1|_cz-}1?5od)X#52oGYaZ`NXW=`N3dBRfmJcOK=u^5c|QL)s%f4fXlTEC*X&^Cn4X0;Jyp31NbNdHi@Y5#z3n{d{Ch zNiZm900-r9_RK@?;D&)HK#;9H$(`Y{d(S%CmA&}9`)Oh#Hd=`}>ZcS! zl3XOz_mGf~G>cZK5ZxM&h;R<30l7S0>*EUa6Kk)Y7mK^#j^%^nfhgIj<{Co6!lWmc zS!^;wUZ7?a2?5PBjP$T=TFAa}F}=~&|BwdojR<5^#$z)>gV@CFzF(nAyacFSZg%qc zj1f34_-*zVH{3#>(*Q*p$rUM$D3sSG2TX50N)6)FS^|A4zo6hQoDh}+4FVKwe?Gd$ z6G+t-78Xz8%Q z*00weYI$GVIa$o%{+mHBPVeocBrySbp$3%VSSCoWap|w)0i04IZY#-zktWKkJg&1a10q^YU)vihi`VKI*b26ePG4dldDAl0+MHOaQc?OS0l)kSPTe z#*o=J{`1cV5M?3ZPB2Mf-MDv0n?*0Z)BKt0xU0G z+JU0Z!q2~b-@aGuY?u7EJrT5xfbHSf#O=o;GoG0{X`AN-o@!C)~|6<7jRyJGXVQBWz)hYFEQOHy2S zbsQt;ug_LxSrsh<17#}aS$RG9WINrjA#Tp!dw;(PpIWMKS2`Pu4QW$$A_E)iaQa!7GT>n zyleIVwm4_T*UJKjgPw$hOyP3fH4ngDAaTJ(P-MD&6M73}v@D*oOE;f6Kk4E;`J_;H z4`LrZ<~KAzdzFGeKw3Iq((ZXZ9EGxD&BY#Dg)|l%@Pu zlL{B6hj3FCnanF}ZWfg|hPuiHL!!IA%5Pr#K~F5>5);eU?#8+0at@-~eNJ}lc>;x- zmWg>@!}qbT?-ru`vnfPt!te*KeI%dGyk&6E3+iHV@me4(r1$Qc_!k$YQo&UT-CfkT z!A)|ZOn}UBH^m{&XZ&&a@L_ZV#F)C*)$;1qcEmY6jbEb_4*_!IgD1T2<<%7xm#y8B zdtEihxsksaVS=9-AdGI^y7eW|KpLd0CtQ^%Ae}Jof}p$$WA8?=etms?1gt`!X-6Cp z_X3d4kAVU7@YWP0)Ldjc0jtg-m}nX%v{xZ9-@dl_6Iyyy5Lv(M(zqOys`qFsf1;v5 zJ`ftpT}tRNv?(2eLdj1@GUC|v-sip>GgA+w3JX0TGp?}$w+%}|SU4l{Cb0-AC0*VK z6X&hFcj5nr@EZ4r1OyjlBX_FVg>6FwhAxH!jKHkavPA zZuQ`6B3L`sDO+W5ac&@E&6Wd0NJX&XsVND_u=X{MAdo z%!p#6nC@se>#JC$t(HYa;SpKO++(dFbAo1Tj}Vf(UQ?z$5@J;iFYueX)406Oeiba7 z(p$~z7h{N%5a%$12&yvAZ-+pb{oetTB8CqQ-3k#C~LInx3b$Q&tRgNOY(3gza= za_)wsj{OSDpRDAxs^_|M!lt_-uV!MZ&8w?U!}V~<;TJP+(85us%Ut|qw^YRR7tUEZ z1F823Z7KH_ec?W2-ZCFco!Pv_m1j2Yt^T6#lCUT=CSajIynY%9))`qu09l@ENftI{ zxF;%ZNq42%?RgV;DISq#8oU5n?_(fSU|22Tibm)6K1A4|YRdgxM#d=e(Zw8RC!O)$ zlKGH}$LrxFB;^umrVO14Ol&bA((xPfs^#__MT_GXfy|ZDEOy6bP3#DBo?#{BYql?O zY_K^ecRql$+JwEqfqEdcj6Y{qG-oyz9z}RWo6-gu?OLedaa=RUxP!Jjr!ku=P*Qp8 z&G>|bqH%7&Kwn?qjr!SeZ;&k@Hdx@1P{$GRtxz_Q%s6%=busBMd~m0*r&iwJU`<{A zfW5S|lq6Y&ICT%`ASw2gP5=C}ikcd&^uBtL-{h=Q1Xyb;L)97+Ub;p7|1Wa-&=BLJ z>HE%&+cG+{`9*ndMy)-2!T$RaNza3|o*C~fmK{8FuxlojTTCRuH`3gcV}u7Ek_Ii? zmu*|jySMw%E8cT=e+gGr?ZT9lq~xc%x;mxDKS1HAf+DH5j|F9HarJ7+Va*4gp82)4 z0tL$t#%knv)|y1X0kqZbbb4`A&@#(V^B$Y_Y7OSRipk2N9POb+)~?&@d*0Q_l=I9B zi`X&;jw+fh;j}%r^G-6`C5yG)mDVt^q-Jf9Bv#;`KQF(i`>BAoMipW~^?W+cX)BR` zAY;^T_sCQbT^Uwb?JuPkId2fLbBdhXStC zM&+ZpM9*wVg_FDzbo)=AKDBgqDzHCmhTJkjJ;`+^!C={qhYa7hRL?mwGwAST8%R7*kK*WR9^ zMeB`L-10}&ce|=Y6UAwrZZeu(Go9N7s=+N&6L7;2OHUevx8g zV&X1v(CylJpF+9U(?e_8Aj0Hf>0VPxgtk3pR=juc&1@|HY~(Inl-sx!bck;-2$#Iq z=6d4)A=oMGND~+$>v|dqP2BVY{~$-?obt_B7|XJd5(-P%ag+8)|Xvs%4AaRfc8| zsAkiaEqc+^eFForEz*Sfcp1z^Gf=Q?_g zKS5Ko@q}=fv+ewQ^&qaTVypvQV#9C8#+Kb~9T-qIsxBit_|i!A*M+hMWgMU+P10Sg zliI-QvqL`W*b5^+6|qwfn&Aj0TEwp}%e(uhYb*ct3BCOE|8gY#-%uI;<0<){sw)5O zWD>C&ZNB^T>C+0c8-Jvldkxor>n~ds4=z2nKg`_k?>}q~8@VJGu{-zoV&MP(Tk}u8 z_45B(t=b13fo}ySL0g>nD zyUx~$vYn33t=6P0rBRpbBHFWm{`m*YAUGrMkPu0D0w6gY^h*gS)sI!rI_KypbnxK8 zmoHzo^z~5_8WX1=9~nx!nr)zZ@1MJpX+XpE2@_5dpppni#@Quf2Oxn#F`PVhELV+j zNz*u03vd_gclw^Q1TR`xaDpO-A@L@p@V9U?kRP5qw+89H1Lj1CHhFtY1?+})fyAH? zVivZ6q0f80G>wgm%S9I@QBz*agg-bPyW9fPU=V@I0OC1p&i@Kdh;QEB4<8-_r~&R_ z-nA>YW?*2z^VO?;Xmj>K(Z>Pt*JhJwR%4_rL2FY1ew2Q)5yK!aBO>(c85kL*#l%*D z*xhPe0IJjwnjK&d3#e!eSD-9E4tJFQMJM8a6}NBShT~NX7nIO%Wo7jdt#-5XY|Q3M z3Mu<3%MQrBJ{Eq?*RsL3^}}*+Z*S=65%C=GCt})!@>Z{SD>~ehP^Ip9dg_C+Qq6V@ zgst)`zz0~7We~MiVtZ6G?H-}=|B`NHKt?-4tl$|zMzw%6kAwh{Vl60#4|#dZeF`6k zOCf#tHD>sO9{jDSuY@}|?6A-j7KpHn@I0!jsTnrD;{o6+8XsqbzzzGCwt>M}Y@lIN zrU2kG;VH?83>=i114N0&9$x?(%iaa)G+&nsw96^r9wfyh##JSB00a$H3H532pZ zHp{({xecT(lVM|u_^QdY=Y|g40%VGI@9SHwli-DG(yjJAdi3aflp+mkFp@lFq|f-z z=A{b)ty8sMdg}XVZ$m$sGt?t?6~$Q9!VwHMQB|zn`)*;+!bJyaL;E1xe)hY z?8Q!5rPv9n@=yxFTms$sK=YnwuQBMvdLev7Z zJ60D108B@YeCNlEiWOuby6c+y!^6X`-(ILb*Dc*+C8>+-P7km zh(BywKt0CwI?Bi{X2W$~Fq@ z!+6)5si~>MnLxoUJv|T5LTPDf!N$`uGXqzNNk}i+(K%Q{j+iwB;Ipi-UPR1=E(Om0 z86Y&|`VKn=H1e2@xs8z}GzV?Cpbjh*_O$KiPcaZz?!7fNHE%&Cfg98k!Yob-!jL+# zwgj62HWl4C17#Nr%N=3@#wZtbf$V=*sv`{jJlX_iPR((>iFHbLJg%VCdyckel%*H$$88JF>dkyivef}&)92*!$gD-wJ3IPJ7YX=4N zD4o?mckbSmMMZp3N_aOUD>#mP(Z0}balXBA=T0d*JAU*PyO0zp6>QsE7iZ!)&qc=l zx$brXj`C4yts1)8+CIWXNg1fyaKX)W^CMsRYt{IzhXDbXqmN}Ehd@psS_v+KiJ4h> z3>#CGhecGPnna{Qlc-!pp~AdSF~klrNy#I3sJl{%4!@LsO#8bmLBdnMbCM4cgh50! z%n-=-%LIclS@p8~Pyqb3P%3LPY!5vYIhFSIzg{7q(f9p(mth$U4f$Tly%PYD@M@L3}U;;xMY#YVS-k#RYyy2QU-U3xmDN%nN1!O6=0)N@u(Kv83z0dbg z1I2%UVvKAZ@3QD@Vroi?5^?Q77g&BYu27fjL^X(#&f4m~04h*VZeb_Cg)3%Y8Yko| zY>X#dxRqvWX6(j-&4)(j43H>pt&Mo%rSF*i-{#&U=AwG4` zE^J|zU9)D*hp%5Zf~tW7NerhFZWLgp9q?TXJ#AU8?H92oxBhDHhb=5Vh1yh#&Rln@ zIzQ=h(T>h+8CQKJ9o7k@3q7ANw3W?oJW^8%y`5ye|8uIdGh#*#-CT04P#$pzWuWu@ zO2$uLzs4s8|HWZ@PCunwc>mDV;;$FLvJyD)zrs|Om?i9gS5n6FzjxyNw-{L|U2Qo( z83_2}567Fy^c3k)es*;!q7V2}zBjUNITdfssBHp8L$|ZB}nrv;oz9) zg4V{1sxn_TojREI*zm>n=by_%E*z4QU@eu|louoPd=+ib@;7&Nzpmo2Dx}_4u|a}c z;M%$3Sy(m)&*$bpQCfgK^vj0|o=L4(8u@%(Ev) z@q~ui)K#v4mE{;WuWZSg`~jOw8E{*5#m|N0Cst(wfv^P}tzQ6J?2Ec5D!MpbZUu#@ z3zMFgqIxx|XZ@K#c`C(XOmD77F{cQ^quTTW7)Ps~H?Ja6Qfy+(M<;U@o$@_**0&S& zQy4m%ePXYjXu;%hyS{ib9Gk;_De0$Ea}{h4At2AYgk#!3u$SRdLSfyG3%?k%lS55n zFfYTa{#}~lp^Ux+h|t|j8#`Ba!^l_WO0|>=<=0wxGlkOPq~J)&9IsM=l>HNS>B#pk zTprZRQmU2o4?&;BRFuc1xfIq3iW(OSbcOgp)Dg06v{p1r z@9?9)`sMg^-XZ5lOhi7htX8uPkBv2pF}DSvvj6$)ZjdVGyrbu)%U#TDb`Ew;iR_1! zVvfziv|$?!CgU7D+4qIoX;)#*=`BC+7T=MRU47dxHhfasls;9yvc}#S%f^LwpwXS} z{Qy4(%nC2(3S8z2-Z9|e8$%+-8L=yeoQ;iAnlYxzZ9meEd*05uD`PO*!KXKb*Ypy= zN#)N3uz^Ryrb`SIT%NMo^~R)f=@mWBgcW+wUQNm#4@XF}X#Jp{Dqz|EdD3*2V>)bc zI;^`0|cHAT0R&86@ zH0Us{(JJKf1uuHkmt3uG*qlB8=N{RdSF{9OK0wV<2%muuti=()acmrdQ!O_~F-O+9 zQgrG#+!-p<3!w6>u;>wykq0nOdQESl1-1&yye*0^VQ}k)FDQH1!(11vYaoxYLN=~= zpSb7Ip4a+})5bwY8RYaNq*m6a~5Dz$|&O@sTf;cWDUV#pP zEDhe^O=c%%Rg0nEg@I5H*T`|UANzWit}zFc{E=(URUHe$nBx_fS$$O8vi;H7PQ{!V zd5~}N(@j!*jgLW9zk)7CHsmI)Ep!!0h!EcpS|L{}#EHYln+fBCMv+lbbnaS!jN}(z z=#cxdf=A~SOh^JSV2pxAYBC35Z);TbsS> zj-bb5FUS(Yr~Rd+iySYa=wcZJ;4Y*S+Imzo*A=D|7Lc}5_^Efhm&=@vo*MpIy4Op{ zn`ydOW5IiVqW%!bAh%MF-OA1!@`{SDuvOdCu6{kRvjA>cvNizml6d`!_4ENi?VWF4 zg+`Xl2u{@KV08S;nYh81-LuaY($rykdV`ZT!9g9H-Wi0cG^V91sKu}WBcR}u(sBkT z!0{{OTi|xPY;$JS^lC|>su157j+@7Lg6+Zdc5il-6cb<1`nq?Dbl(~V8-QT>YQiw$ z9FY+HrmZmqBM|McMCT39^S9o*+FQkeQ*`xaSg?@2<$9c5@)Hp3nhUczix~-h*x-`% zuF&Af$VeD=1aqcK&1KZo)a-j*D+Q#*M_O*uC@c(9Q7HR^DK+;%^zx0*ahM`wQRW5_n5s3g+h z0e)2ojOdF&5@+L{!lk&hr?q(&$7T!!!9Jmv+GrzzUmN}5b|^N<17sPKR<-K z`w*0Xb7*fZzh68H4PIT`)YKFjNHDx7uM$F+uie^?6ry5C513zerC_H&%t`vNICbMw zNqW*RSTIAXRy&wAR-uYWN*^r~^Ybs(qK2}m`MH%SW}E}*xP zghec*r>BR!A4WWsiNA%&3E~Qa>L!NNprjn|gq2Y4GXA}MO?Eo!(7MlEH7PsfGF>b* z@_BDMbQY^pp6YgUmsyoqs4gZM{VBd@om{3qGV15LQhBI1mp0Oj7vLD1m(n;7-3+m= zQkL@6Y5i~;HC@}L&hS-(O}(kFL&TeTr&M6A2&g-?hOeG*p_(}2V3yGugj`J5hg~>y z9dFGpEN&6VQG}C!J18!-@V1ncfy(?NlY*1)dSi>LvnP@pEQ>kdB{wZYL1ZdjTre!d z-CIJUHvqw2j;GEfG}_dM<74b$PL!3E^?}ExBX=oH$rIbAz?16*m|i`xxuh4-HJLq` zg&fMPsAetyYd0MFlLG~9&A>R<{jg4d4i1lH@$gz48|h&$N}MT1Z^TgUzwY|-z1}D) zrUFw1CfK1`eDBQ!jms)~Ec{SMV%WTice_gSjKX@%TM*v?c1%1$sH87*w2Z7KTkp(N zfvEa)j*3bAX_8Ay0uH};KS7FR-_f9Y8%*^kc1HxWh^N%*2cJo;4FF7FhRIXfI|Qp) z3(cJy3_|8`KAmsw&?7HLCIA(QGLhN7BF2tVk3{D?7qbu_{3I^E!PP=mE4}p&-ALo0 zGanBRPd5%}ndstNA7UyU_9b!~By7a}a?aMlm_o8e1iDGKehr;21GRx>mS7WaU<4i# zw9Y8X1`Bi3Bw1vSJ{fFZt1)$=W4tTW!3=t)_Y<2s)@oHNsRcay=oQM|G(OY6%~LRz z%c^^}dC_?QOCU5i^f7Au?EF$1p@*iajl86w!ACbzDD9l^-r*gOb$?~X(1*2&#RoH( z#Nl`g8g!n_5WqZjP}V|9jkcSwzC=IX1`khC^X?d|Ov5d(C^LAhKl<}2naEO3x_ohA zraGrH)M21dKiJd{j-F23QW@d`Iq?ZoH{<}4LYdysPa*|HphyTnI<@KyG>E-QtOj*1(@MqKq{3& z_8mdukAN)f+dJOIxMi`ZWA#bb7 zku*PoJsRx0p44KD#R^`Beaa#}F#v+KmtS~{Adhxfh0UtE6_}njz81%8EzCDQI~btj zS$KTq&c{pw@OD|@paK5H<~56!?rbKhjZ_Tpj>&#+QYko<(Sp`fpj<6KD!CQiG+VeR3Lgv2SIEs$k^ffn*l+SR2T9FKg%5QP*hb)C&&SNiDcE#_{N5#hY zTx|)c(NX6zX@^HWs7_zAE|X0VqTuuk!;f^?vYCqFYPrfaN~<8wP(y6A;Y!cq!HFN!COi8!6*>vO@ss+g_3u zxzRU}w8&UAAIzn9PTj8cHvN@P8^yi*Q|b>kkpjHQiOI8`~)3A#<7J+ zFbNfV(@US^TpO>JauPB(qQ<#!x;ZMNqEg>t$2)E}4#brB?Yv+II-Bx{V6z~%gYWjk z-8*%lU&mX2@NT|HjkY}FsF|TlvuV9)YAKLSO^XAF^imXMf1n>>f+1Yyt6e(SG^Wpi zcdl;9l2JKo78M!pkR0A?FYh9nG&cJxy$!^4!;1P~Ji^EmxBw5r5m3}{vUWyOOK|k! zT$d;t_ZW-`0r2>s!(**QG9yP@*_;H#T+DKXF2+O(#g&F{kScA5epyH$8Bt^O^&qu` zU=$!=B)vq?%2xsbbGIH;4S?;IbWiY19mgX#!btPGh}v0!3EhGsX)5lr1+%p?3j@n9j^|8KVWhJDDsjM`SLP;-nfsz zNn^yr9co%{Fp>H|J@!c6Y+j+fwjPz(t9i>xc}1etmEsYM-ax)bV5%*~GgU6yXIesW zAkX^Xf|m|iW9K>%i)ZTiFfLgCNG1*xb#XS!8m}T{A=)Ohh1e>M+hK+0tZuQ6vO1J<2bT@}UEr~so%;KY-H zMVighZ0Ee*jPO}4shG0E8pD^cN*R`;rF>xPM0_1Ml^32dp+_tsS*vKWYAh}0H3Ew@ zYIb%oi+&I+d>5f@NJ~fTnP8fwy%>ZK4A$1-A%f(IYQBLV#D~ZqcdtG?wp|Z+i(jsuo6AvfN!H0RZn*wU<|V_1@d-`5B_zDEcQsv~mXE zOCMvc6&Y;lAq|kf!N}AB7)72~wxiE{zB+8KeIIE%$fkEAOh{*26#Yp)9=^DjSiNZ+ z@GyecaNX*9j-_fYr2;?h?Ilf+9-g02IyXH!3ZI=2BJvzWJ1*zBDOkX{fq2MRc0wUb zuw2vHSm#ciETC`!xHm041v&%zP-b zsaze{925Z$|AYrEuZa=?l6@F073hslw9KViDd(5v5Ix9XHR4ijD=~RewpaP*gn5u!pt|n zp=N={hEPY4?Abwbo*=y9iowC;$R1J7r}3Owcqj0$uSc$LXOhhB;Y@Nm}I3^kVgg(G61ZclUrLW*r|KPeIodD-&(pH*-EHo3SaXQlJxqW=3NTS-3&6mOfq9Ql9k>L~{;I8p&3* zPd4|^50aWfcp{__6@hBDNEcm)yxHz;Ia8;}M9vG(qX)!CdNmf08a%O9SQzooIl>>x z@#GZ>Q#+vw`WH=?Q7AiX5femDCt#iQQ%R5j;Zqccq4L0{?LCY6D!w`6O*DBqD~Vxu z6s?YBvU5ayb#-NB2UOV4NZ%@R(=$GEW+b6=iA4WD?Okb9Q)e2!O0}-Vy((7JMs!4Q zBTyAXovC6#tG1$|GGdKQk!384f+WIm8AcJ8Ah-bH!lDpBg9(Nu;4s<(11MrJf`p2o z><~qWgk+uPZrRS`;C^zy+@3>blhc>2EdNtU>;6(0eBExjwm;+_?32;mlaT9`ap=~X1+B{I? z4D7vfiSNr(2owPkQ@eEYe3Bvk zTR3}Q4Nd;k5c*wba$ynwgyr&dzzTV4$o&~786&*>?>_g#MU6LM5?7xWp{!jH-?kR_ z3X$sZl8K}75qVEC{5zgiVMqx?aPGG#K%2D>WA$gyHg@?z9RPkYg!u~pUQ{=f!o3i& z(2DRi_GB@+b&(P;>SId*7D$OrfS@afqM&{ybJ*a)=<029&ja|+#injw9cn{=^L!Zb z+wHH?EpE;tB%$x^;iD_r-7g1ScLh+0AK5}P4zony=<)gM&{;g>YjcYz@CAzVQv#cB zx@Y$yR9g2@5FQMR66;Eja5M(7xEJ>~UPmjF6)Iv6ya7r^C6gs&a8DeN-j$s)27}NP z^W(iq0myS(1xWf&1Ggv=(rDZ7>L^49KH@~v{)u-9CsF~&gsj5P_EnuGNwvEgp*oxe zX7pgoBu7v!bg7<#=slA=)8Qhb8bxmjZu|vl+@v8$B{^l|Oqp1yWh)(hCAQ^H_vDXM z0mzZ4L@JVa+a<0yZZHlw3No&v1b8PQGjj=SZ&ys-aMECrWQZNrb)tpA8$}))0$Nsl z>T(A2`GrtzkIU}fC&jaaaWn^9d(40+G#Z~lyPOSbO&p z8jKueNroRXYqp@)MEx^iHwdMbAarGc(1%!D(#k=etg^ec(cTUOOGwDZ)h6Fc86$P6F!HwpMYAM_kfa|}zHPM3 z+mF>{uLnTm1D_Y6Eq!5-UZ(^0D}rm6j^qbk<eZ65Dzh7=DK8B z(Z>PEQoQeL8m>RLQzI`GT5`QUEyN3|y2#JjV#y69Hut4x%Yw4u}amMJxh~BQ$b*P z{M`D)7tj8(4R!=QCP<yF3H^-#SQQ)H zsQ)hlR{3cYO9k-q zP}ZW!-(aYEYtPY~W?j$j;E4*wcQ6QiP`!~#g7>^4i;QidffTz83e??4li#=1Q|ffJ z2x(P01?0uF{hFRlOiTXqN+`L1gHPruGfu(_t%ul__V;T`A(|`%5Awk5P0kLZ^#(tx zgBHApKS60g$b#knL4Fj9ZRm8*fP-~)j0l_4R<*Z!a3O2M_ zBKS@TjIVT6J*ifPtmqgppZf-zW=sjvbxgAH+IFZ7J3L2_h~o|8BLVeo+I&;}DFlT9N$|LVOaK8i8nIw<8=v#){NMpM zI7$wb_6XHsNE>TJ*l?3gd>`Ohf?dBI1mai^X=dZB9ndG$g)kpvWg4kG#W*x5lsU&j z3fPMd<~yrnIXRk~eYwBv0)~Jznsu+qP@c$%!1nEYbes_WBt>bsb;)0j`@ZJ%tgIuN;?H*^?6cqYSP^8(4?UV<~HLD(wfa~29FASBnh}@dm?4o2vO_m64thjkLMA_V^-Dg|oyK zpPAzJ+ZET_#uCmzQHQw!%~O_06BMb5O-#XQ4Um@VURYy0)o7$aTHT*~Q4`!(6G4xk zd^N5F?3-i*IXYB)RJ+cy-f-XXWD&jhkqa|0= z4B>&OB>Gtj=-s}rSp58yQ+3z)u!#$1#P&AxNhGg>;8;W=pRSp2M^IYnzt!~n6g;r| z8rzbt<-N^W;6#N%XhR1zDMnYigjXP!%YMs_cab-{8pW)X|7aB-^S!Ag^OFczAQz{BA{@NuXYj4+524%PLvsOv}9 z)O&z$yZZ?_ZiH~VH%aacHnGYQq!(Medpq}rTurqo_6tXUSIVQIPT&*nR#pnr%D0>9 zRU2^luR`4qkW@IEGpHLE<6Q9vI$Xb|7~Lu^#sjLujaM*LJ5MgtV6G>^A9pdPD94l` ztB8S50uYodI@fJ^eR)VQ*3aeckMU++?isp}rs-l4&h|jK_$Q9mj_L#AzlOX&T+V+5 z+xt)c4|z`ifb|d9Zziq%OY%3W2mPz6e^vFbD!_n$mDTzeM*qU-Ul{*S3nSQ|F8M6Z zdc<93oK6;K_`Gg+!<^}Cx67g5buKU(Jnu-Pv5H|vy%*)*i}5?&iSUQt;f?+`F<<%z z;eTro!bSvF%3PN9Swp(Q@&xumCY8N!2QHG?V=dnmHZ1tXiZy?nLcfdIxo$2Kn8ZFg ziP0BESa_5VsyV-kIDDS(LlfmskBqXHo)*~B!R@BeQOdY`?8brZe$&65Kn@mTw|Jk} zZH@Z|M&NG|PcO_@y^dsCGK!1#tv$Cp=#`pK_zyx&;NAv1Cc|@US5L22KUx1qxc#Re x#ZqPFQ?E_