shell.ts 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { Debouncer } from '@jupyterlab/coreutils';
  4. import { DocumentRegistry } from '@jupyterlab/docregistry';
  5. import { DockPanelSvg, TabBarSvg } from '@jupyterlab/ui-components';
  6. import { ArrayExt, find, IIterator, iter, toArray } from '@phosphor/algorithm';
  7. import { PromiseDelegate, Token } from '@phosphor/coreutils';
  8. import { Message, MessageLoop, IMessageHandler } from '@phosphor/messaging';
  9. import { ISignal, Signal } from '@phosphor/signaling';
  10. import {
  11. BoxLayout,
  12. BoxPanel,
  13. DockLayout,
  14. DockPanel,
  15. FocusTracker,
  16. Panel,
  17. SplitPanel,
  18. StackedPanel,
  19. TabBar,
  20. Title,
  21. Widget
  22. } from '@phosphor/widgets';
  23. import { JupyterFrontEnd } from './frontend';
  24. /**
  25. * The class name added to AppShell instances.
  26. */
  27. const APPLICATION_SHELL_CLASS = 'jp-LabShell';
  28. /**
  29. * The class name added to side bar instances.
  30. */
  31. const SIDEBAR_CLASS = 'jp-SideBar';
  32. /**
  33. * The class name added to the current widget's title.
  34. */
  35. const CURRENT_CLASS = 'jp-mod-current';
  36. /**
  37. * The class name added to the active widget's title.
  38. */
  39. const ACTIVE_CLASS = 'jp-mod-active';
  40. /**
  41. * The default rank of items added to a sidebar.
  42. */
  43. const DEFAULT_RANK = 500;
  44. const ACTIVITY_CLASS = 'jp-Activity';
  45. /* tslint:disable */
  46. /**
  47. * The JupyterLab application shell token.
  48. */
  49. export const ILabShell = new Token<ILabShell>(
  50. '@jupyterlab/application:ILabShell'
  51. );
  52. /* tslint:enable */
  53. /**
  54. * The JupyterLab application shell interface.
  55. */
  56. export interface ILabShell extends LabShell {}
  57. /**
  58. * The namespace for `ILabShell` type information.
  59. */
  60. export namespace ILabShell {
  61. /**
  62. * The areas of the application shell where widgets can reside.
  63. */
  64. export type Area = 'main' | 'header' | 'top' | 'left' | 'right' | 'bottom';
  65. /**
  66. * The restorable description of an area within the main dock panel.
  67. */
  68. export type AreaConfig = DockLayout.AreaConfig;
  69. /**
  70. * An arguments object for the changed signals.
  71. */
  72. export type IChangedArgs = FocusTracker.IChangedArgs<Widget>;
  73. /**
  74. * A description of the application's user interface layout.
  75. */
  76. export interface ILayout {
  77. /**
  78. * Indicates whether fetched session restore data was actually retrieved
  79. * from the state database or whether it is a fresh blank slate.
  80. *
  81. * #### Notes
  82. * This attribute is only relevant when the layout data is retrieved via a
  83. * `fetch` call. If it is set when being passed into `save`, it will be
  84. * ignored.
  85. */
  86. readonly fresh?: boolean;
  87. /**
  88. * The main area of the user interface.
  89. */
  90. readonly mainArea: IMainArea | null;
  91. /**
  92. * The left area of the user interface.
  93. */
  94. readonly leftArea: ISideArea | null;
  95. /**
  96. * The right area of the user interface.
  97. */
  98. readonly rightArea: ISideArea | null;
  99. }
  100. /**
  101. * The restorable description of the main application area.
  102. */
  103. export interface IMainArea {
  104. /**
  105. * The current widget that has application focus.
  106. */
  107. readonly currentWidget: Widget | null;
  108. /**
  109. * The contents of the main application dock panel.
  110. */
  111. readonly dock: DockLayout.ILayoutConfig | null;
  112. /**
  113. * The document mode (i.e., multiple/single) of the main dock panel.
  114. */
  115. readonly mode: DockPanel.Mode | null;
  116. }
  117. /**
  118. * The restorable description of a sidebar in the user interface.
  119. */
  120. export interface ISideArea {
  121. /**
  122. * A flag denoting whether the sidebar has been collapsed.
  123. */
  124. readonly collapsed: boolean;
  125. /**
  126. * The current widget that has side area focus.
  127. */
  128. readonly currentWidget: Widget | null;
  129. /**
  130. * The collection of widgets held by the sidebar.
  131. */
  132. readonly widgets: Array<Widget> | null;
  133. }
  134. }
  135. /**
  136. * The application shell for JupyterLab.
  137. */
  138. export class LabShell extends Widget implements JupyterFrontEnd.IShell {
  139. /**
  140. * Construct a new application shell.
  141. */
  142. constructor() {
  143. super();
  144. this.addClass(APPLICATION_SHELL_CLASS);
  145. this.id = 'main';
  146. let bottomPanel = (this._bottomPanel = new BoxPanel());
  147. let topPanel = (this._topPanel = new Panel());
  148. let hboxPanel = new BoxPanel();
  149. let dockPanel = (this._dockPanel = new DockPanelSvg({
  150. kind: 'dockPanelBar'
  151. }));
  152. let headerPanel = (this._headerPanel = new Panel());
  153. MessageLoop.installMessageHook(dockPanel, this._dockChildHook);
  154. let hsplitPanel = new SplitPanel();
  155. let leftHandler = (this._leftHandler = new Private.SideBarHandler('left'));
  156. let rightHandler = (this._rightHandler = new Private.SideBarHandler(
  157. 'right'
  158. ));
  159. let rootLayout = new BoxLayout();
  160. bottomPanel.id = 'jp-bottom-panel';
  161. topPanel.id = 'jp-top-panel';
  162. hboxPanel.id = 'jp-main-content-panel';
  163. dockPanel.id = 'jp-main-dock-panel';
  164. hsplitPanel.id = 'jp-main-split-panel';
  165. headerPanel.id = 'jp-header-panel';
  166. leftHandler.sideBar.addClass(SIDEBAR_CLASS);
  167. leftHandler.sideBar.addClass('jp-mod-left');
  168. leftHandler.stackedPanel.id = 'jp-left-stack';
  169. rightHandler.sideBar.addClass(SIDEBAR_CLASS);
  170. rightHandler.sideBar.addClass('jp-mod-right');
  171. rightHandler.stackedPanel.id = 'jp-right-stack';
  172. bottomPanel.direction = 'bottom-to-top';
  173. hboxPanel.spacing = 0;
  174. dockPanel.spacing = 5;
  175. hsplitPanel.spacing = 1;
  176. hboxPanel.direction = 'left-to-right';
  177. hsplitPanel.orientation = 'horizontal';
  178. SplitPanel.setStretch(leftHandler.stackedPanel, 0);
  179. SplitPanel.setStretch(dockPanel, 1);
  180. SplitPanel.setStretch(rightHandler.stackedPanel, 0);
  181. BoxPanel.setStretch(leftHandler.sideBar, 0);
  182. BoxPanel.setStretch(hsplitPanel, 1);
  183. BoxPanel.setStretch(rightHandler.sideBar, 0);
  184. hsplitPanel.addWidget(leftHandler.stackedPanel);
  185. hsplitPanel.addWidget(dockPanel);
  186. hsplitPanel.addWidget(rightHandler.stackedPanel);
  187. hboxPanel.addWidget(leftHandler.sideBar);
  188. hboxPanel.addWidget(hsplitPanel);
  189. hboxPanel.addWidget(rightHandler.sideBar);
  190. rootLayout.direction = 'top-to-bottom';
  191. rootLayout.spacing = 0; // TODO make this configurable?
  192. // Use relative sizing to set the width of the side panels.
  193. // This will still respect the min-size of children widget in the stacked
  194. // panel.
  195. hsplitPanel.setRelativeSizes([1, 2.5, 1]);
  196. BoxLayout.setStretch(headerPanel, 0);
  197. BoxLayout.setStretch(topPanel, 0);
  198. BoxLayout.setStretch(hboxPanel, 1);
  199. BoxLayout.setStretch(bottomPanel, 0);
  200. rootLayout.addWidget(headerPanel);
  201. rootLayout.addWidget(topPanel);
  202. rootLayout.addWidget(hboxPanel);
  203. rootLayout.addWidget(bottomPanel);
  204. // initially hiding header and bottom panel when no elements inside
  205. this._headerPanel.hide();
  206. this._bottomPanel.hide();
  207. this.layout = rootLayout;
  208. // Connect change listeners.
  209. this._tracker.currentChanged.connect(this._onCurrentChanged, this);
  210. this._tracker.activeChanged.connect(this._onActiveChanged, this);
  211. // Connect main layout change listener.
  212. this._dockPanel.layoutModified.connect(this._onLayoutModified, this);
  213. // Catch current changed events on the side handlers.
  214. this._leftHandler.sideBar.currentChanged.connect(
  215. this._onLayoutModified,
  216. this
  217. );
  218. this._rightHandler.sideBar.currentChanged.connect(
  219. this._onLayoutModified,
  220. this
  221. );
  222. }
  223. /**
  224. * A signal emitted when main area's active focus changes.
  225. */
  226. get activeChanged(): ISignal<this, ILabShell.IChangedArgs> {
  227. return this._activeChanged;
  228. }
  229. /**
  230. * The active widget in the shell's main area.
  231. */
  232. get activeWidget(): Widget | null {
  233. return this._tracker.activeWidget;
  234. }
  235. /**
  236. * A signal emitted when main area's current focus changes.
  237. */
  238. get currentChanged(): ISignal<this, ILabShell.IChangedArgs> {
  239. return this._currentChanged;
  240. }
  241. /**
  242. * The current widget in the shell's main area.
  243. */
  244. get currentWidget(): Widget | null {
  245. return this._tracker.currentWidget;
  246. }
  247. /**
  248. * A signal emitted when the main area's layout is modified.
  249. */
  250. get layoutModified(): ISignal<this, void> {
  251. return this._layoutModified;
  252. }
  253. /**
  254. * Whether the left area is collapsed.
  255. */
  256. get leftCollapsed(): boolean {
  257. return !this._leftHandler.sideBar.currentTitle;
  258. }
  259. /**
  260. * Whether the left area is collapsed.
  261. */
  262. get rightCollapsed(): boolean {
  263. return !this._rightHandler.sideBar.currentTitle;
  264. }
  265. /**
  266. * Whether JupyterLab is in presentation mode with the
  267. * `jp-mod-presentationMode` CSS class.
  268. */
  269. get presentationMode(): boolean {
  270. return this.hasClass('jp-mod-presentationMode');
  271. }
  272. /**
  273. * Enable/disable presentation mode (`jp-mod-presentationMode` CSS class) with
  274. * a boolean.
  275. */
  276. set presentationMode(value: boolean) {
  277. this.toggleClass('jp-mod-presentationMode', value);
  278. }
  279. /**
  280. * The main dock area's user interface mode.
  281. */
  282. get mode(): DockPanel.Mode {
  283. return this._dockPanel.mode;
  284. }
  285. set mode(mode: DockPanel.Mode) {
  286. const dock = this._dockPanel;
  287. if (mode === dock.mode) {
  288. return;
  289. }
  290. const applicationCurrentWidget = this.currentWidget;
  291. if (mode === 'single-document') {
  292. this._cachedLayout = dock.saveLayout();
  293. dock.mode = mode;
  294. // In case the active widget in the dock panel is *not* the active widget
  295. // of the application, defer to the application.
  296. if (this.currentWidget) {
  297. dock.activateWidget(this.currentWidget);
  298. }
  299. // Set the mode data attribute on the application shell node.
  300. this.node.dataset.shellMode = mode;
  301. return;
  302. }
  303. // Cache a reference to every widget currently in the dock panel.
  304. const widgets = toArray(dock.widgets());
  305. // Toggle back to multiple document mode.
  306. dock.mode = mode;
  307. // Restore the original layout.
  308. if (this._cachedLayout) {
  309. // Remove any disposed widgets in the cached layout and restore.
  310. Private.normalizeAreaConfig(dock, this._cachedLayout.main);
  311. dock.restoreLayout(this._cachedLayout);
  312. this._cachedLayout = null;
  313. }
  314. // Add any widgets created during single document mode, which have
  315. // subsequently been removed from the dock panel after the multiple document
  316. // layout has been restored. If the widget has add options cached for
  317. // it (i.e., if it has been placed with respect to another widget),
  318. // then take that into account.
  319. widgets.forEach(widget => {
  320. if (!widget.parent) {
  321. this._addToMainArea(widget, {
  322. ...this._mainOptionsCache.get(widget),
  323. activate: false
  324. });
  325. }
  326. });
  327. this._mainOptionsCache.clear();
  328. // In case the active widget in the dock panel is *not* the active widget
  329. // of the application, defer to the application.
  330. if (applicationCurrentWidget) {
  331. dock.activateWidget(applicationCurrentWidget);
  332. }
  333. // Set the mode data attribute on the applications shell node.
  334. this.node.dataset.shellMode = mode;
  335. }
  336. /**
  337. * Promise that resolves when state is first restored, returning layout
  338. * description.
  339. */
  340. get restored(): Promise<ILabShell.ILayout> {
  341. return this._restored.promise;
  342. }
  343. /**
  344. * Activate a widget in its area.
  345. */
  346. activateById(id: string): void {
  347. if (this._leftHandler.has(id)) {
  348. this._leftHandler.activate(id);
  349. return;
  350. }
  351. if (this._rightHandler.has(id)) {
  352. this._rightHandler.activate(id);
  353. return;
  354. }
  355. const dock = this._dockPanel;
  356. const widget = find(dock.widgets(), value => value.id === id);
  357. if (widget) {
  358. dock.activateWidget(widget);
  359. }
  360. }
  361. /*
  362. * Activate the next Tab in the active TabBar.
  363. */
  364. activateNextTab(): void {
  365. let current = this._currentTabBar();
  366. if (!current) {
  367. return;
  368. }
  369. let ci = current.currentIndex;
  370. if (ci === -1) {
  371. return;
  372. }
  373. if (ci < current.titles.length - 1) {
  374. current.currentIndex += 1;
  375. if (current.currentTitle) {
  376. current.currentTitle.owner.activate();
  377. }
  378. return;
  379. }
  380. if (ci === current.titles.length - 1) {
  381. let nextBar = this._adjacentBar('next');
  382. if (nextBar) {
  383. nextBar.currentIndex = 0;
  384. if (nextBar.currentTitle) {
  385. nextBar.currentTitle.owner.activate();
  386. }
  387. }
  388. }
  389. }
  390. /*
  391. * Activate the previous Tab in the active TabBar.
  392. */
  393. activatePreviousTab(): void {
  394. let current = this._currentTabBar();
  395. if (!current) {
  396. return;
  397. }
  398. let ci = current.currentIndex;
  399. if (ci === -1) {
  400. return;
  401. }
  402. if (ci > 0) {
  403. current.currentIndex -= 1;
  404. if (current.currentTitle) {
  405. current.currentTitle.owner.activate();
  406. }
  407. return;
  408. }
  409. if (ci === 0) {
  410. let prevBar = this._adjacentBar('previous');
  411. if (prevBar) {
  412. let len = prevBar.titles.length;
  413. prevBar.currentIndex = len - 1;
  414. if (prevBar.currentTitle) {
  415. prevBar.currentTitle.owner.activate();
  416. }
  417. }
  418. }
  419. }
  420. add(
  421. widget: Widget,
  422. area: ILabShell.Area = 'main',
  423. options?: DocumentRegistry.IOpenOptions
  424. ): void {
  425. switch (area || 'main') {
  426. case 'main':
  427. return this._addToMainArea(widget, options);
  428. case 'left':
  429. return this._addToLeftArea(widget, options);
  430. case 'right':
  431. return this._addToRightArea(widget, options);
  432. case 'header':
  433. return this._addToHeaderArea(widget, options);
  434. case 'top':
  435. return this._addToTopArea(widget, options);
  436. case 'bottom':
  437. return this._addToBottomArea(widget, options);
  438. default:
  439. throw new Error(`Invalid area: ${area}`);
  440. }
  441. }
  442. /**
  443. * Collapse the left area.
  444. */
  445. collapseLeft(): void {
  446. this._leftHandler.collapse();
  447. this._onLayoutModified();
  448. }
  449. /**
  450. * Collapse the right area.
  451. */
  452. collapseRight(): void {
  453. this._rightHandler.collapse();
  454. this._onLayoutModified();
  455. }
  456. /**
  457. * Dispose the shell.
  458. */
  459. dispose(): void {
  460. if (this.isDisposed) {
  461. return;
  462. }
  463. this._layoutDebouncer.dispose();
  464. super.dispose();
  465. }
  466. /**
  467. * Expand the left area.
  468. *
  469. * #### Notes
  470. * This will open the most recently used tab,
  471. * or the first tab if there is no most recently used.
  472. */
  473. expandLeft(): void {
  474. this._leftHandler.expand();
  475. this._onLayoutModified();
  476. }
  477. /**
  478. * Expand the right area.
  479. *
  480. * #### Notes
  481. * This will open the most recently used tab,
  482. * or the first tab if there is no most recently used.
  483. */
  484. expandRight(): void {
  485. this._rightHandler.expand();
  486. this._onLayoutModified();
  487. }
  488. /**
  489. * Close all widgets in the main area.
  490. */
  491. closeAll(): void {
  492. // Make a copy of all the widget in the dock panel (using `toArray()`)
  493. // before removing them because removing them while iterating through them
  494. // modifies the underlying data of the iterator.
  495. toArray(this._dockPanel.widgets()).forEach(widget => widget.close());
  496. }
  497. /**
  498. * True if the given area is empty.
  499. */
  500. isEmpty(area: ILabShell.Area): boolean {
  501. switch (area) {
  502. case 'left':
  503. return this._leftHandler.stackedPanel.widgets.length === 0;
  504. case 'main':
  505. return this._dockPanel.isEmpty;
  506. case 'header':
  507. return this._headerPanel.widgets.length === 0;
  508. case 'top':
  509. return this._topPanel.widgets.length === 0;
  510. case 'bottom':
  511. return this._bottomPanel.widgets.length === 0;
  512. case 'right':
  513. return this._rightHandler.stackedPanel.widgets.length === 0;
  514. default:
  515. return true;
  516. }
  517. }
  518. /**
  519. * Restore the layout state for the application shell.
  520. */
  521. restoreLayout(layout: ILabShell.ILayout): void {
  522. const { mainArea, leftArea, rightArea } = layout;
  523. // Rehydrate the main area.
  524. if (mainArea) {
  525. const { currentWidget, dock, mode } = mainArea;
  526. if (dock) {
  527. this._dockPanel.restoreLayout(dock);
  528. }
  529. if (mode) {
  530. this.mode = mode;
  531. }
  532. if (currentWidget) {
  533. this.activateById(currentWidget.id);
  534. }
  535. }
  536. // Rehydrate the left area.
  537. if (leftArea) {
  538. this._leftHandler.rehydrate(leftArea);
  539. }
  540. // Rehydrate the right area.
  541. if (rightArea) {
  542. this._rightHandler.rehydrate(rightArea);
  543. }
  544. if (!this._isRestored) {
  545. // Make sure all messages in the queue are finished before notifying
  546. // any extensions that are waiting for the promise that guarantees the
  547. // application state has been restored.
  548. MessageLoop.flush();
  549. this._restored.resolve(layout);
  550. }
  551. }
  552. /**
  553. * Save the dehydrated state of the application shell.
  554. */
  555. saveLayout(): ILabShell.ILayout {
  556. // If the application is in single document mode, use the cached layout if
  557. // available. Otherwise, default to querying the dock panel for layout.
  558. return {
  559. mainArea: {
  560. currentWidget: this._tracker.currentWidget,
  561. dock:
  562. this.mode === 'single-document'
  563. ? this._cachedLayout || this._dockPanel.saveLayout()
  564. : this._dockPanel.saveLayout(),
  565. mode: this._dockPanel.mode
  566. },
  567. leftArea: this._leftHandler.dehydrate(),
  568. rightArea: this._rightHandler.dehydrate()
  569. };
  570. }
  571. /**
  572. * Returns the widgets for an application area.
  573. */
  574. widgets(area?: ILabShell.Area): IIterator<Widget> {
  575. switch (area || 'main') {
  576. case 'main':
  577. return this._dockPanel.widgets();
  578. case 'left':
  579. return iter(this._leftHandler.sideBar.titles.map(t => t.owner));
  580. case 'right':
  581. return iter(this._rightHandler.sideBar.titles.map(t => t.owner));
  582. case 'header':
  583. return this._headerPanel.children();
  584. case 'top':
  585. return this._topPanel.children();
  586. case 'bottom':
  587. return this._bottomPanel.children();
  588. default:
  589. throw new Error(`Invalid area: ${area}`);
  590. }
  591. }
  592. /**
  593. * Handle `after-attach` messages for the application shell.
  594. */
  595. protected onAfterAttach(msg: Message): void {
  596. this.node.dataset.shellMode = this.mode;
  597. }
  598. /**
  599. * Add a widget to the left content area.
  600. *
  601. * #### Notes
  602. * Widgets must have a unique `id` property, which will be used as the DOM id.
  603. */
  604. private _addToLeftArea(
  605. widget: Widget,
  606. options?: DocumentRegistry.IOpenOptions
  607. ): void {
  608. if (!widget.id) {
  609. console.error('Widgets added to app shell must have unique id property.');
  610. return;
  611. }
  612. options = options || this._sideOptionsCache.get(widget) || {};
  613. this._sideOptionsCache.set(widget, options);
  614. let rank = 'rank' in options ? options.rank : DEFAULT_RANK;
  615. this._leftHandler.addWidget(widget, rank!);
  616. this._onLayoutModified();
  617. }
  618. /**
  619. * Add a widget to the main content area.
  620. *
  621. * #### Notes
  622. * Widgets must have a unique `id` property, which will be used as the DOM id.
  623. * All widgets added to the main area should be disposed after removal
  624. * (disposal before removal will remove the widget automatically).
  625. *
  626. * In the options, `ref` defaults to `null`, `mode` defaults to `'tab-after'`,
  627. * and `activate` defaults to `true`.
  628. */
  629. private _addToMainArea(
  630. widget: Widget,
  631. options?: DocumentRegistry.IOpenOptions
  632. ): void {
  633. if (!widget.id) {
  634. console.error('Widgets added to app shell must have unique id property.');
  635. return;
  636. }
  637. options = options || {};
  638. const dock = this._dockPanel;
  639. const mode = options.mode || 'tab-after';
  640. let ref: Widget | null = this.currentWidget;
  641. if (options.ref) {
  642. ref = find(dock.widgets(), value => value.id === options.ref!) || null;
  643. }
  644. // Add widget ID to tab so that we can get a handle on the tab's widget
  645. // (for context menu support)
  646. widget.title.dataset = { ...widget.title.dataset, id: widget.id };
  647. dock.addWidget(widget, { mode, ref });
  648. // The dock panel doesn't account for placement information while
  649. // in single document mode, so upon rehydrating any widgets that were
  650. // added will not be in the correct place. Cache the placement information
  651. // here so that we can later rehydrate correctly.
  652. if (dock.mode === 'single-document') {
  653. this._mainOptionsCache.set(widget, options);
  654. }
  655. if (options.activate !== false) {
  656. dock.activateWidget(widget);
  657. }
  658. }
  659. /**
  660. * Add a widget to the right content area.
  661. *
  662. * #### Notes
  663. * Widgets must have a unique `id` property, which will be used as the DOM id.
  664. */
  665. private _addToRightArea(
  666. widget: Widget,
  667. options?: DocumentRegistry.IOpenOptions
  668. ): void {
  669. if (!widget.id) {
  670. console.error('Widgets added to app shell must have unique id property.');
  671. return;
  672. }
  673. options = options || this._sideOptionsCache.get(widget) || {};
  674. const rank = 'rank' in options ? options.rank : DEFAULT_RANK;
  675. this._sideOptionsCache.set(widget, options);
  676. this._rightHandler.addWidget(widget, rank!);
  677. this._onLayoutModified();
  678. }
  679. /**
  680. * Add a widget to the top content area.
  681. *
  682. * #### Notes
  683. * Widgets must have a unique `id` property, which will be used as the DOM id.
  684. */
  685. private _addToTopArea(
  686. widget: Widget,
  687. options?: DocumentRegistry.IOpenOptions
  688. ): void {
  689. if (!widget.id) {
  690. console.error('Widgets added to app shell must have unique id property.');
  691. return;
  692. }
  693. // Temporary: widgets are added to the panel in order of insertion.
  694. this._topPanel.addWidget(widget);
  695. this._onLayoutModified();
  696. }
  697. /**
  698. * Add a widget to the header content area.
  699. *
  700. * #### Notes
  701. * Widgets must have a unique `id` property, which will be used as the DOM id.
  702. */
  703. private _addToHeaderArea(
  704. widget: Widget,
  705. options?: DocumentRegistry.IOpenOptions
  706. ): void {
  707. if (!widget.id) {
  708. console.error('Widgets added to app shell must have unique id property.');
  709. return;
  710. }
  711. // Temporary: widgets are added to the panel in order of insertion.
  712. this._headerPanel.addWidget(widget);
  713. this._onLayoutModified();
  714. if (this._headerPanel.isHidden) {
  715. this._headerPanel.show();
  716. }
  717. }
  718. /**
  719. * Add a widget to the bottom content area.
  720. *
  721. * #### Notes
  722. * Widgets must have a unique `id` property, which will be used as the DOM id.
  723. */
  724. private _addToBottomArea(
  725. widget: Widget,
  726. options?: DocumentRegistry.IOpenOptions
  727. ): void {
  728. if (!widget.id) {
  729. console.error('Widgets added to app shell must have unique id property.');
  730. return;
  731. }
  732. // Temporary: widgets are added to the panel in order of insertion.
  733. this._bottomPanel.addWidget(widget);
  734. this._onLayoutModified();
  735. if (this._bottomPanel.isHidden) {
  736. this._bottomPanel.show();
  737. }
  738. }
  739. /*
  740. * Return the tab bar adjacent to the current TabBar or `null`.
  741. */
  742. private _adjacentBar(direction: 'next' | 'previous'): TabBar<Widget> | null {
  743. const current = this._currentTabBar();
  744. if (!current) {
  745. return null;
  746. }
  747. const bars = toArray(this._dockPanel.tabBars());
  748. const len = bars.length;
  749. const index = bars.indexOf(current);
  750. if (direction === 'previous') {
  751. return index > 0 ? bars[index - 1] : index === 0 ? bars[len - 1] : null;
  752. }
  753. // Otherwise, direction is 'next'.
  754. return index < len - 1
  755. ? bars[index + 1]
  756. : index === len - 1
  757. ? bars[0]
  758. : null;
  759. }
  760. /*
  761. * Return the TabBar that has the currently active Widget or null.
  762. */
  763. private _currentTabBar(): TabBar<Widget> | null {
  764. const current = this._tracker.currentWidget;
  765. if (!current) {
  766. return null;
  767. }
  768. const title = current.title;
  769. const bars = this._dockPanel.tabBars();
  770. return find(bars, bar => bar.titles.indexOf(title) > -1) || null;
  771. }
  772. /**
  773. * Handle a change to the dock area active widget.
  774. */
  775. private _onActiveChanged(
  776. sender: any,
  777. args: FocusTracker.IChangedArgs<Widget>
  778. ): void {
  779. if (args.newValue) {
  780. args.newValue.title.className += ` ${ACTIVE_CLASS}`;
  781. }
  782. if (args.oldValue) {
  783. args.oldValue.title.className = args.oldValue.title.className.replace(
  784. ACTIVE_CLASS,
  785. ''
  786. );
  787. }
  788. this._activeChanged.emit(args);
  789. }
  790. /**
  791. * Handle a change to the dock area current widget.
  792. */
  793. private _onCurrentChanged(
  794. sender: any,
  795. args: FocusTracker.IChangedArgs<Widget>
  796. ): void {
  797. if (args.newValue) {
  798. args.newValue.title.className += ` ${CURRENT_CLASS}`;
  799. }
  800. if (args.oldValue) {
  801. args.oldValue.title.className = args.oldValue.title.className.replace(
  802. CURRENT_CLASS,
  803. ''
  804. );
  805. }
  806. this._currentChanged.emit(args);
  807. this._onLayoutModified();
  808. }
  809. /**
  810. * Handle a change to the layout.
  811. */
  812. private _onLayoutModified(): void {
  813. void this._layoutDebouncer.invoke();
  814. }
  815. /**
  816. * A message hook for child add/remove messages on the main area dock panel.
  817. */
  818. private _dockChildHook = (
  819. handler: IMessageHandler,
  820. msg: Message
  821. ): boolean => {
  822. switch (msg.type) {
  823. case 'child-added':
  824. (msg as Widget.ChildMessage).child.addClass(ACTIVITY_CLASS);
  825. this._tracker.add((msg as Widget.ChildMessage).child);
  826. break;
  827. case 'child-removed':
  828. (msg as Widget.ChildMessage).child.removeClass(ACTIVITY_CLASS);
  829. this._tracker.remove((msg as Widget.ChildMessage).child);
  830. break;
  831. default:
  832. break;
  833. }
  834. return true;
  835. };
  836. private _activeChanged = new Signal<this, ILabShell.IChangedArgs>(this);
  837. private _cachedLayout: DockLayout.ILayoutConfig | null = null;
  838. private _currentChanged = new Signal<this, ILabShell.IChangedArgs>(this);
  839. private _dockPanel: DockPanel;
  840. private _isRestored = false;
  841. private _layoutModified = new Signal<this, void>(this);
  842. private _layoutDebouncer = new Debouncer(() => {
  843. this._layoutModified.emit(undefined);
  844. }, 0);
  845. private _leftHandler: Private.SideBarHandler;
  846. private _restored = new PromiseDelegate<ILabShell.ILayout>();
  847. private _rightHandler: Private.SideBarHandler;
  848. private _tracker = new FocusTracker<Widget>();
  849. private _headerPanel: Panel;
  850. private _topPanel: Panel;
  851. private _bottomPanel: Panel;
  852. private _mainOptionsCache = new Map<Widget, DocumentRegistry.IOpenOptions>();
  853. private _sideOptionsCache = new Map<Widget, DocumentRegistry.IOpenOptions>();
  854. }
  855. namespace Private {
  856. /**
  857. * An object which holds a widget and its sort rank.
  858. */
  859. export interface IRankItem {
  860. /**
  861. * The widget for the item.
  862. */
  863. widget: Widget;
  864. /**
  865. * The sort rank of the widget.
  866. */
  867. rank: number;
  868. }
  869. /**
  870. * A less-than comparison function for side bar rank items.
  871. */
  872. export function itemCmp(first: IRankItem, second: IRankItem): number {
  873. return first.rank - second.rank;
  874. }
  875. /**
  876. * Removes widgets that have been disposed from an area config, mutates area.
  877. */
  878. export function normalizeAreaConfig(
  879. parent: DockPanel,
  880. area?: DockLayout.AreaConfig | null
  881. ): void {
  882. if (!area) {
  883. return;
  884. }
  885. if (area.type === 'tab-area') {
  886. area.widgets = area.widgets.filter(
  887. widget => !widget.isDisposed && widget.parent === parent
  888. ) as Widget[];
  889. return;
  890. }
  891. area.children.forEach(child => {
  892. normalizeAreaConfig(parent, child);
  893. });
  894. }
  895. /**
  896. * A class which manages a side bar and related stacked panel.
  897. */
  898. export class SideBarHandler {
  899. /**
  900. * Construct a new side bar handler.
  901. */
  902. constructor(side: string) {
  903. this._sideBar = new TabBarSvg<Widget>({
  904. kind: 'sideBar',
  905. insertBehavior: 'none',
  906. removeBehavior: 'none',
  907. allowDeselect: true
  908. });
  909. this._stackedPanel = new StackedPanel();
  910. this._sideBar.hide();
  911. this._stackedPanel.hide();
  912. this._lastCurrent = null;
  913. this._sideBar.currentChanged.connect(this._onCurrentChanged, this);
  914. this._sideBar.tabActivateRequested.connect(
  915. this._onTabActivateRequested,
  916. this
  917. );
  918. this._stackedPanel.widgetRemoved.connect(this._onWidgetRemoved, this);
  919. }
  920. /**
  921. * Get the tab bar managed by the handler.
  922. */
  923. get sideBar(): TabBar<Widget> {
  924. return this._sideBar;
  925. }
  926. /**
  927. * Get the stacked panel managed by the handler
  928. */
  929. get stackedPanel(): StackedPanel {
  930. return this._stackedPanel;
  931. }
  932. /**
  933. * Expand the sidebar.
  934. *
  935. * #### Notes
  936. * This will open the most recently used tab, or the first tab
  937. * if there is no most recently used.
  938. */
  939. expand(): void {
  940. const previous =
  941. this._lastCurrent || (this._items.length > 0 && this._items[0].widget);
  942. if (previous) {
  943. this.activate(previous.id);
  944. }
  945. }
  946. /**
  947. * Activate a widget residing in the side bar by ID.
  948. *
  949. * @param id - The widget's unique ID.
  950. */
  951. activate(id: string): void {
  952. let widget = this._findWidgetByID(id);
  953. if (widget) {
  954. this._sideBar.currentTitle = widget.title;
  955. widget.activate();
  956. }
  957. }
  958. /**
  959. * Test whether the sidebar has the given widget by id.
  960. */
  961. has(id: string): boolean {
  962. return this._findWidgetByID(id) !== null;
  963. }
  964. /**
  965. * Collapse the sidebar so no items are expanded.
  966. */
  967. collapse(): void {
  968. this._sideBar.currentTitle = null;
  969. }
  970. /**
  971. * Add a widget and its title to the stacked panel and side bar.
  972. *
  973. * If the widget is already added, it will be moved.
  974. */
  975. addWidget(widget: Widget, rank: number): void {
  976. widget.parent = null;
  977. widget.hide();
  978. let item = { widget, rank };
  979. let index = this._findInsertIndex(item);
  980. ArrayExt.insert(this._items, index, item);
  981. this._stackedPanel.insertWidget(index, widget);
  982. const title = this._sideBar.insertTab(index, widget.title);
  983. // Store the parent id in the title dataset
  984. // in order to dispatch click events to the right widget.
  985. title.dataset = { id: widget.id };
  986. this._refreshVisibility();
  987. }
  988. /**
  989. * Dehydrate the side bar data.
  990. */
  991. dehydrate(): ILabShell.ISideArea {
  992. let collapsed = this._sideBar.currentTitle === null;
  993. let widgets = toArray(this._stackedPanel.widgets);
  994. let currentWidget = widgets[this._sideBar.currentIndex];
  995. return { collapsed, currentWidget, widgets };
  996. }
  997. /**
  998. * Rehydrate the side bar.
  999. */
  1000. rehydrate(data: ILabShell.ISideArea): void {
  1001. if (data.currentWidget) {
  1002. this.activate(data.currentWidget.id);
  1003. } else if (data.collapsed) {
  1004. this.collapse();
  1005. }
  1006. }
  1007. /**
  1008. * Find the insertion index for a rank item.
  1009. */
  1010. private _findInsertIndex(item: Private.IRankItem): number {
  1011. return ArrayExt.upperBound(this._items, item, Private.itemCmp);
  1012. }
  1013. /**
  1014. * Find the index of the item with the given widget, or `-1`.
  1015. */
  1016. private _findWidgetIndex(widget: Widget): number {
  1017. return ArrayExt.findFirstIndex(this._items, i => i.widget === widget);
  1018. }
  1019. /**
  1020. * Find the widget which owns the given title, or `null`.
  1021. */
  1022. private _findWidgetByTitle(title: Title<Widget>): Widget | null {
  1023. let item = find(this._items, value => value.widget.title === title);
  1024. return item ? item.widget : null;
  1025. }
  1026. /**
  1027. * Find the widget with the given id, or `null`.
  1028. */
  1029. private _findWidgetByID(id: string): Widget | null {
  1030. let item = find(this._items, value => value.widget.id === id);
  1031. return item ? item.widget : null;
  1032. }
  1033. /**
  1034. * Refresh the visibility of the side bar and stacked panel.
  1035. */
  1036. private _refreshVisibility(): void {
  1037. this._sideBar.setHidden(this._sideBar.titles.length === 0);
  1038. this._stackedPanel.setHidden(this._sideBar.currentTitle === null);
  1039. }
  1040. /**
  1041. * Handle the `currentChanged` signal from the sidebar.
  1042. */
  1043. private _onCurrentChanged(
  1044. sender: TabBar<Widget>,
  1045. args: TabBar.ICurrentChangedArgs<Widget>
  1046. ): void {
  1047. const oldWidget = args.previousTitle
  1048. ? this._findWidgetByTitle(args.previousTitle)
  1049. : null;
  1050. const newWidget = args.currentTitle
  1051. ? this._findWidgetByTitle(args.currentTitle)
  1052. : null;
  1053. if (oldWidget) {
  1054. oldWidget.hide();
  1055. }
  1056. if (newWidget) {
  1057. newWidget.show();
  1058. }
  1059. this._lastCurrent = newWidget || oldWidget;
  1060. this._refreshVisibility();
  1061. }
  1062. /**
  1063. * Handle a `tabActivateRequest` signal from the sidebar.
  1064. */
  1065. private _onTabActivateRequested(
  1066. sender: TabBar<Widget>,
  1067. args: TabBar.ITabActivateRequestedArgs<Widget>
  1068. ): void {
  1069. args.title.owner.activate();
  1070. }
  1071. /*
  1072. * Handle the `widgetRemoved` signal from the stacked panel.
  1073. */
  1074. private _onWidgetRemoved(sender: StackedPanel, widget: Widget): void {
  1075. if (widget === this._lastCurrent) {
  1076. this._lastCurrent = null;
  1077. }
  1078. ArrayExt.removeAt(this._items, this._findWidgetIndex(widget));
  1079. this._sideBar.removeTab(widget.title);
  1080. this._refreshVisibility();
  1081. }
  1082. private _items = new Array<Private.IRankItem>();
  1083. private _sideBar: TabBar<Widget>;
  1084. private _stackedPanel: StackedPanel;
  1085. private _lastCurrent: Widget | null;
  1086. }
  1087. }