From 0ad2e1b7b08abbae613571311d71c81026eb19cd Mon Sep 17 00:00:00 2001 From: Yahia <125813966+Yahiewi@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:06:42 +0200 Subject: [PATCH 1/2] Add Select UI dropdown in settings (#52) Co-authored-by: Duc Trung Le --- README.md | 10 ++++++ demo/jupyter-lite.json | 19 ++++++++--- src/document/factory.tsx | 9 +++++- src/document/plugin.ts | 61 +++++++++++++++++++++++++----------- src/extension_plugins.ts | 4 ++- src/token.ts | 14 ++++++++- src/tool.ts | 11 +++++-- src/topbar/index.tsx | 16 ++++++++-- src/topbar/menuComponent.tsx | 8 ++++- src/topbar/settingDialog.tsx | 43 ++++++++++++++++++++++++- 10 files changed, 162 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e6e1d31..a4cd2db 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ The following options are available: }, ``` +- `uiSwitcherOptions`: List of UI options shown in the settings dropdown, allowing users to switch between different interfaces. Each entry has an `id` (the URL segment of the target app) and a `label` (the display name). Defaults to JupyterLab, Notebook, and Specta as a fallback. + +```json + "uiSwitcherOptions": [ + { "id": "lab", "label": "JupyterLab" }, + { "id": "notebooks", "label": "Notebook" }, + { "id": "specta", "label": "Specta" } + ] +``` + - `perFileConfig`: an object with key is the file path and value is the above configuration, it's used to have different layout/top bar config for each files, for example: ```json diff --git a/demo/jupyter-lite.json b/demo/jupyter-lite.json index ecfc610..501e4bc 100644 --- a/demo/jupyter-lite.json +++ b/demo/jupyter-lite.json @@ -6,6 +6,20 @@ "topBar": { "title": "Specta Tree" }, + "uiSwitcherOptions": [ + { + "id": "lab", + "label": "JupyterLab" + }, + { + "id": "notebooks", + "label": "Notebook" + }, + { + "id": "specta", + "label": "Specta" + } + ], "defaultLayout": "default", "perFileConfig": { "blog.ipynb": { @@ -25,9 +39,6 @@ "hideHeader": false } }, - "disabledExtensions": [ - "@jupyterlab/notebook-extension:open-with-no-kernel", - "@jupyterlite/application-extension:opener" - ] + "disabledExtensions": ["@jupyterlab/notebook-extension:open-with-no-kernel"] } } diff --git a/src/document/factory.tsx b/src/document/factory.tsx index 9a6e3fb..cb6a1ac 100644 --- a/src/document/factory.tsx +++ b/src/document/factory.tsx @@ -8,7 +8,8 @@ import { SpectaWidgetFactory } from '../specta_widget_factory'; import { ISpectaLayoutRegistry, ISpectaShell, - ISpectaTopbarWidget + ISpectaTopbarWidget, + ISpectaUiSwitcher } from '../token'; import { isSpectaApp, readSpectaConfig } from '../tool'; import { MenuComponent } from '../topbar/menuComponent'; @@ -23,6 +24,7 @@ interface IOptions extends DocumentRegistry.IWidgetFactoryOptions { shell: ISpectaShell; spectaLayoutRegistry: ISpectaLayoutRegistry; spectaTopbar: ISpectaTopbarWidget; + uiSwitcher?: ISpectaUiSwitcher | null; } export class NotebookGridWidgetFactory extends ABCWidgetFactory< @@ -35,6 +37,7 @@ export class NotebookGridWidgetFactory extends ABCWidgetFactory< this._shell = options.shell; this._themeManager = options.themeManager; this._spectaTopbar = options.spectaTopbar; + this._uiSwitcher = options.uiSwitcher; } protected createNewWidget( @@ -56,6 +59,9 @@ export class NotebookGridWidgetFactory extends ABCWidgetFactory< ); if (!isSpecta) { @@ -97,4 +103,5 @@ export class NotebookGridWidgetFactory extends ABCWidgetFactory< private _shell: ISpectaShell; private _themeManager?: IThemeManager; private _spectaTopbar: ISpectaTopbarWidget; + private _uiSwitcher?: ISpectaUiSwitcher | null; } diff --git a/src/document/plugin.ts b/src/document/plugin.ts index 6d242f1..c92a376 100644 --- a/src/document/plugin.ts +++ b/src/document/plugin.ts @@ -23,15 +23,16 @@ import { ISpectaShell, ISpectaTopbarWidget, ISpectaTopbarWidgetToken, + ISpectaUiSwitcher, + ISpectaUiSwitcherToken, ISpectaUrlFactory, - ISpectaUrlFactoryToken + ISpectaUrlFactoryToken, + IUiOption } from '../token'; import { - configLabLayout, createFileBrowser, hideAppLoadingIndicator, isSpectaApp, - readSpectaConfig, registerDocumentFactory, getSpectaDocInfo, openDocument @@ -46,7 +47,8 @@ const activate = ( spectaLayoutRegistry: ISpectaLayoutRegistry, themeManager: IThemeManager, spectaTopbar: ISpectaTopbarWidget, - kernelSpecManager: KernelSpec.IManager + kernelSpecManager: KernelSpec.IManager, + uiSwitcher: ISpectaUiSwitcher | null ): IWidgetTracker => { const namespace = 'specta'; const spectaTracker = new WidgetTracker({ namespace }); @@ -62,7 +64,8 @@ const activate = ( spectaLayoutRegistry, themeManager, spectaTopbar, - kernelSpecManager + kernelSpecManager, + uiSwitcher }); return spectaTracker; @@ -84,6 +87,7 @@ export const spectaDocument: JupyterFrontEndPlugin< ISpectaTopbarWidgetToken, IKernelSpecManager ], + optional: [ISpectaUiSwitcherToken], activate, provides: ISpectaDocTracker }; @@ -93,14 +97,23 @@ export const spectaUrlFactory: JupyterFrontEndPlugin = { autoStart: true, provides: ISpectaUrlFactoryToken, activate: () => { - const urlFactory = (path: string) => { + const urlFactory = (path: string, ui = 'specta'): string => { const baseUrl = PageConfig.getBaseUrl(); let appUrl = PageConfig.getOption('appUrl'); if (!appUrl.endsWith('/')) { appUrl = `${appUrl}/`; } - const url = new URL(URLExt.join(baseUrl, appUrl)); - url.searchParams.set('path', path); + // Replace last segment of app URL with target UI + // (e.g. /foo/specta/ to /foo/lab/) + const parts = appUrl.split('/').filter(Boolean); + if (parts.length > 0) { + parts[parts.length - 1] = ui; + } else { + parts.push(ui); + } + const url = new URL(URLExt.join(baseUrl, '/' + parts.join('/') + '/')); + // spectaOpener in lab mode reads 'specta-path'; specta app reads 'path' + url.searchParams.set(ui === 'lab' ? 'specta-path' : 'path', path); const queries = PageConfig.getOption('query').split('&').filter(Boolean); queries.forEach(query => { const [key, value] = query.split('='); @@ -112,6 +125,28 @@ export const spectaUrlFactory: JupyterFrontEndPlugin = { } }; +export const spectaUiSwitcher: JupyterFrontEndPlugin = { + id: 'specta/application-extension:uiSwitcher', + autoStart: true, + requires: [ISpectaUrlFactoryToken], + provides: ISpectaUiSwitcherToken, + activate: (_app, urlFactory: ISpectaUrlFactory) => { + const rawConfig = PageConfig.getOption('spectaConfig'); + const globalConfig = rawConfig ? JSON.parse(rawConfig) : {}; + const uis: IUiOption[] = globalConfig.uiSwitcherOptions ?? [ + { id: 'lab', label: 'JupyterLab' }, + { id: 'notebooks', label: 'Notebook' }, + { id: 'specta', label: 'Specta' } + ]; + return { + uis, + switchTo: (path: string, ui: string) => { + window.open(urlFactory(path, ui), '_blank'); + } + }; + } +}; + export const spectaOpener: JupyterFrontEndPlugin = { id: 'specta/application-extension:opener', autoStart: true, @@ -140,18 +175,8 @@ export const spectaOpener: JupyterFrontEndPlugin = { return; } app.restored.then(async () => { - const labShell = app.shell; - const { isSpectaDoc, factory } = getSpectaDocInfo(path, app); - if (isSpectaDoc) { - const commands = app.commands; - const spectaConfig = readSpectaConfig({}); - await configLabLayout({ - config: spectaConfig.labConfig, - labShell, - commands - }); openDocument(path, factory, docManager, app.shell); } }); diff --git a/src/extension_plugins.ts b/src/extension_plugins.ts index 8347af1..529fbad 100644 --- a/src/extension_plugins.ts +++ b/src/extension_plugins.ts @@ -1,6 +1,7 @@ import { spectaDocument, spectaOpener, + spectaUiSwitcher, spectaUrlFactory } from './document/plugin'; import { spectaLayoutRegistry } from './layout'; @@ -17,5 +18,6 @@ export default [ topbarPlugin, appMeta, cellMeta, - spectaUrlFactory + spectaUrlFactory, + spectaUiSwitcher ]; diff --git a/src/token.ts b/src/token.ts index 4aed248..6b1abc6 100644 --- a/src/token.ts +++ b/src/token.ts @@ -51,6 +51,7 @@ export interface ISpectaAppConfig { slidesTheme?: string; loadingName?: string; executionDelay?: number; + uiSwitcherOptions?: IUiOption[]; labConfig?: { setSingleMode?: boolean; hideLeftPanel?: boolean; @@ -65,7 +66,15 @@ export interface ISpectaCellConfig { showOutput?: boolean; outputSize?: 'Small' | 'Big' | 'Full'; } -export type ISpectaUrlFactory = (path: string) => string; +export interface IUiOption { + id: string; + label: string; +} +export type ISpectaUrlFactory = (path: string, ui?: string) => string; +export interface ISpectaUiSwitcher { + uis: IUiOption[]; + switchTo: (path: string, ui: string) => void; +} export const ISpectaLayoutRegistry = new Token( 'specta:ISpectaLayoutRegistry' ); @@ -77,6 +86,9 @@ export const ISpectaDocTracker = new Token>( export const ISpectaUrlFactoryToken = new Token( 'specta:ISpectaUrlFactoryToken' ); +export const ISpectaUiSwitcherToken = new Token( + 'specta:ISpectaUiSwitcherToken' +); export interface ISpectaTopbarWidget { addTopbarWidget?: ( diff --git a/src/tool.ts b/src/tool.ts index 33a7c7e..b4d3e0a 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -19,6 +19,7 @@ import { ISpectaLayoutRegistry, ISpectaShell, ISpectaTopbarWidget, + ISpectaUiSwitcher, ISpectaUrlFactory } from './token'; @@ -57,6 +58,7 @@ export function registerDocumentFactory(options: { themeManager?: IThemeManager; spectaTopbar: ISpectaTopbarWidget; kernelSpecManager: KernelSpec.IManager; + uiSwitcher?: ISpectaUiSwitcher | null; }) { const { factoryName, @@ -69,7 +71,8 @@ export function registerDocumentFactory(options: { spectaLayoutRegistry, themeManager, spectaTopbar, - kernelSpecManager + kernelSpecManager, + uiSwitcher } = options; const spectaWidgetFactory = new SpectaWidgetFactory({ @@ -90,7 +93,8 @@ export function registerDocumentFactory(options: { spectaWidgetFactory, themeManager, spectaLayoutRegistry, - spectaTopbar + spectaTopbar, + uiSwitcher }); // Registering the widget factory @@ -134,7 +138,8 @@ export function registerDocumentFactory(options: { spectaWidgetFactory, themeManager, spectaLayoutRegistry, - spectaTopbar + spectaTopbar, + uiSwitcher }); app.docRegistry.addWidgetFactory(plainbWidgetFactory); diff --git a/src/topbar/index.tsx b/src/topbar/index.tsx index 7a3ca51..558ce54 100644 --- a/src/topbar/index.tsx +++ b/src/topbar/index.tsx @@ -10,7 +10,9 @@ import { ISpectaLayoutRegistry, ISpectaShell, ISpectaTopbarWidget, - ISpectaTopbarWidgetToken + ISpectaTopbarWidgetToken, + ISpectaUiSwitcher, + ISpectaUiSwitcherToken } from '../token'; import { isSpectaApp, readSpectaConfig, PLAINB_PREFIX } from '../tool'; import { MenuComponent } from './menuComponent'; @@ -25,11 +27,13 @@ export const topbarPlugin: JupyterFrontEndPlugin< description: 'Specta topbar extension', autoStart: true, requires: [IThemeManager, ISpectaLayoutRegistry], + optional: [ISpectaUiSwitcherToken], provides: ISpectaTopbarWidgetToken, activate: ( app: JupyterFrontEnd, themeManager: IThemeManager, - layoutRegistry: ISpectaLayoutRegistry + layoutRegistry: ISpectaLayoutRegistry, + uiSwitcher: ISpectaUiSwitcher | null ) => { const isSpecta = isSpectaApp(); if (!isSpecta) { @@ -60,7 +64,13 @@ export const topbarPlugin: JupyterFrontEndPlugin< const title = ; widget.addReactWidget(title, 'left', 0); const menu = ( - + ); widget.addReactWidget(menu, 'right', 10000); diff --git a/src/topbar/menuComponent.tsx b/src/topbar/menuComponent.tsx index 3cb2831..2cbd52d 100644 --- a/src/topbar/menuComponent.tsx +++ b/src/topbar/menuComponent.tsx @@ -4,11 +4,14 @@ import React, { useState, useRef, useEffect } from 'react'; import { GearIcon } from '../components/icon/gear'; import { IconButton } from '../components/iconButton'; import { SettingContent } from './settingDialog'; -import { ITopbarConfig } from '../token'; +import { ISpectaUiSwitcher, ITopbarConfig } from '../token'; interface IProps { config?: ITopbarConfig; themeManager?: IThemeManager; + uiSwitcher?: ISpectaUiSwitcher | null; + currentPath?: string | null; + currentUi?: string; } export function MenuComponent(props: IProps): JSX.Element { @@ -46,6 +49,9 @@ export function MenuComponent(props: IProps): JSX.Element { )} diff --git a/src/topbar/settingDialog.tsx b/src/topbar/settingDialog.tsx index 387d30c..d37695e 100644 --- a/src/topbar/settingDialog.tsx +++ b/src/topbar/settingDialog.tsx @@ -1,12 +1,19 @@ import { IThemeManager } from '@jupyterlab/apputils'; import React, { useState, useEffect, useCallback } from 'react'; import { Divider } from '../components/divider'; -import { ISpectaLayoutRegistry, ITopbarConfig } from '../token'; +import { + ISpectaLayoutRegistry, + ISpectaUiSwitcher, + ITopbarConfig +} from '../token'; export const SettingContent = (props: { config?: ITopbarConfig; themeManager?: IThemeManager; layoutRegistry?: ISpectaLayoutRegistry; + uiSwitcher?: ISpectaUiSwitcher | null; + currentPath?: string | null; + currentUi?: string; }) => { const { themeManager, layoutRegistry } = props; const [themeOptions, setThemeOptions] = useState([ @@ -76,6 +83,16 @@ export const SettingContent = (props: { }, [layoutRegistry] ); + const { uiSwitcher, currentPath } = props; + const onUiChange = useCallback( + (e: React.ChangeEvent) => { + const ui = e.currentTarget?.value; + if (ui && uiSwitcher && currentPath) { + uiSwitcher.switchTo(currentPath, ui); + } + }, + [uiSwitcher, currentPath] + ); return (

@@ -140,6 +157,30 @@ export const SettingContent = (props: {

)} + {currentPath && uiSwitcher && uiSwitcher.uis.length > 0 && ( +
+ +
+ +
+
+ )} ); }; From ad1e6a58621d47d386911f4c1e18f13432aa9601 Mon Sep 17 00:00:00 2001 From: Yahia <125813966+Yahiewi@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:28:39 +0200 Subject: [PATCH 2/2] Improve mobile view (#54) --- src/document/factory.tsx | 38 ++++++++++++++++++++------ src/token.ts | 11 ++++++-- src/topbar/index.tsx | 6 +++- src/topbar/menuComponent.tsx | 4 ++- src/topbar/settingDialog.tsx | 53 +++++++++++++++++++++++++++++++++--- src/topbar/topbarWidget.tsx | 28 +++++++++++++++---- style/base.css | 32 +++++++++++++++++++++- 7 files changed, 149 insertions(+), 23 deletions(-) diff --git a/src/document/factory.tsx b/src/document/factory.tsx index cb6a1ac..1dea786 100644 --- a/src/document/factory.tsx +++ b/src/document/factory.tsx @@ -55,26 +55,46 @@ export class NotebookGridWidgetFactory extends ABCWidgetFactory< const isSpecta = isSpectaApp(); if (!spectaConfig.hideTopbar) { const title = ; + let topbarWidget: ISpectaTopbarWidget | undefined = undefined; + let localTopbar: TopbarWidget | undefined = undefined; + + if (!isSpecta) { + localTopbar = new TopbarWidget({ + config: spectaConfig.topBar + }); + topbarWidget = localTopbar; + } else { + topbarWidget = this._spectaTopbar; + } + const menu = ( ); - if (!isSpecta) { - // Not a specta app, add topbar to document widget - const topbar = new TopbarWidget({ - config: spectaConfig.topBar - }); - topbar.addReactWidget(title, 'left', 0); - topbar.addReactWidget(menu, 'right', 10000); - content.addWidget(topbar); + + if (!isSpecta && localTopbar) { + const titleWidget = localTopbar.addReactWidget(title, 'left', 0); + if (titleWidget) { + titleWidget.addClass('specta-topbar-title-wrapper'); + } + localTopbar.addReactWidget(menu, 'right', 10000); + content.addWidget(localTopbar); } else { if (this._spectaTopbar.addReactWidget) { - this._spectaTopbar.addReactWidget(title, 'left', 0); + const titleWidget = this._spectaTopbar.addReactWidget( + title, + 'left', + 0 + ); + if (titleWidget) { + titleWidget.addClass('specta-topbar-title-wrapper'); + } this._spectaTopbar.addReactWidget(menu, 'right', 10000); } } diff --git a/src/token.ts b/src/token.ts index 6b1abc6..2225a39 100644 --- a/src/token.ts +++ b/src/token.ts @@ -90,9 +90,14 @@ export const ISpectaUiSwitcherToken = new Token( 'specta:ISpectaUiSwitcherToken' ); +export interface ISpectaWidget { + readonly node: HTMLElement; + readonly isAttached: boolean; +} + export interface ISpectaTopbarWidget { addTopbarWidget?: ( - widget: Widget, + widget: ISpectaWidget, side: 'left' | 'right', rank: number ) => void; @@ -100,7 +105,9 @@ export interface ISpectaTopbarWidget { widget: JSX.Element, side: 'left' | 'right', rank: number - ) => void; + ) => Widget; + addSettingsWidget?: (widget: ISpectaWidget) => void; + settingsWidgets?: ISpectaWidget[]; } export const ISpectaTopbarWidgetToken = new Token( 'specta:ISpectaTopbarWidget' diff --git a/src/topbar/index.tsx b/src/topbar/index.tsx index 558ce54..5aec8b6 100644 --- a/src/topbar/index.tsx +++ b/src/topbar/index.tsx @@ -62,11 +62,15 @@ export const topbarPlugin: JupyterFrontEndPlugin< return widget; } const title = ; - widget.addReactWidget(title, 'left', 0); + const titleWidget = widget.addReactWidget(title, 'left', 0); + if (titleWidget) { + titleWidget.addClass('specta-topbar-title-wrapper'); + } const menu = ( { - const { themeManager, layoutRegistry } = props; + const { themeManager, layoutRegistry, settingsWidgets } = props; const [themeOptions, setThemeOptions] = useState([ ...(themeManager?.themes ?? []) ]); @@ -83,6 +86,42 @@ export const SettingContent = (props: { }, [layoutRegistry] ); + // Defer widget attachment to prevent 'pointerdown' violation warnings. + const frameRef = useRef(null); + + const customWidgetsRef = useCallback( + (node: HTMLDivElement | null) => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + + if (node) { + node.innerHTML = ''; + if (settingsWidgets) { + frameRef.current = requestAnimationFrame(() => { + settingsWidgets.forEach(w => { + if (w.isAttached) { + Widget.detach(w as Widget); + } + Widget.attach(w as Widget, node); + }); + frameRef.current = null; + }); + } + } else { + if (settingsWidgets) { + settingsWidgets.forEach(w => { + if (w.isAttached) { + Widget.detach(w as Widget); + } + }); + } + } + }, + [settingsWidgets] + ); + const { uiSwitcher, currentPath } = props; const onUiChange = useCallback( (e: React.ChangeEvent) => { @@ -96,7 +135,7 @@ export const SettingContent = (props: { return (

- SETTINGS + SPECTA MENU

{(props.config?.layoutToggle !== undefined @@ -181,6 +220,12 @@ export const SettingContent = (props: {
)} + {settingsWidgets && settingsWidgets.length > 0 && ( +
+ +
+
+ )}
); }; diff --git a/src/topbar/topbarWidget.tsx b/src/topbar/topbarWidget.tsx index 5a85365..f2380a7 100644 --- a/src/topbar/topbarWidget.tsx +++ b/src/topbar/topbarWidget.tsx @@ -1,7 +1,7 @@ import { ReactWidget } from '@jupyterlab/apputils'; import { Panel, Widget } from '@lumino/widgets'; -import { ITopbarConfig } from '../token'; +import { ITopbarConfig, ISpectaWidget } from '../token'; import { RankedPanel } from './rankedPanel'; export class TopbarWidget extends Panel { @@ -26,20 +26,38 @@ export class TopbarWidget extends Panel { this.addWidget(this._rightSide); } - addReactWidget(el: JSX.Element, side: 'left' | 'right', rank: number): void { + addReactWidget( + el: JSX.Element, + side: 'left' | 'right', + rank: number + ): Widget { const widget = ReactWidget.create(el); this.addTopbarWidget(widget, side, rank); + return widget; } - addTopbarWidget(widget: Widget, side: 'left' | 'right', rank: number): void { + addTopbarWidget( + widget: ISpectaWidget, + side: 'left' | 'right', + rank: number + ): void { if (side === 'left') { - this._leftSide.addWidget(widget, rank); + this._leftSide.addWidget(widget as Widget, rank); } else { - this._rightSide.addWidget(widget, rank); + this._rightSide.addWidget(widget as Widget, rank); } } + addSettingsWidget(widget: ISpectaWidget): void { + this._settingsWidgets.push(widget); + } + + get settingsWidgets(): ISpectaWidget[] { + return this._settingsWidgets; + } + private _leftSide = new RankedPanel(); private _rightSide = new RankedPanel(); + private _settingsWidgets: ISpectaWidget[] = []; private _config: ITopbarConfig; } diff --git a/style/base.css b/style/base.css index 8201f85..183ca31 100644 --- a/style/base.css +++ b/style/base.css @@ -81,6 +81,8 @@ .specta-topbar-element { width: 100%; display: flex; + min-width: 0; + flex: 1; } .specta-topbar { @@ -100,10 +102,22 @@ align-items: center; gap: 0.5rem; height: 80%; + min-width: 0; + flex: 1; +} + +.specta-topbar-title-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + height: 100%; + min-width: 0; + flex: 1; } .specta-topbar-icon-container { height: 100%; + flex-shrink: 0; } .specta-topbar-right { @@ -116,10 +130,16 @@ } .specta-topbar-title { - line-height: 36px; + display: block; + line-height: 28px; font-size: 1.25rem; font-family: 'Quicksand', sans-serif; font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + min-width: 0; + flex: 1; } .specta-topbar-theme { @@ -203,3 +223,13 @@ .invisible { visibility: hidden; } + +.specta-settings-custom-section { + display: none; +} + +@media (max-width: 768px) { + .specta-settings-custom-section { + display: block; + } +}