diff --git a/docs/md/how_to/javascript/save_restore.md b/docs/md/how_to/javascript/save_restore.md index e9f21c5ce3..2868e33257 100644 --- a/docs/md/how_to/javascript/save_restore.md +++ b/docs/md/how_to/javascript/save_restore.md @@ -97,3 +97,23 @@ Chrome developer console: // Copy to clipboard copy(await document.querySelector("perspective-viewer").save()); ``` + +## Multi-panel viewers + +`save()` and `restore()` operate on a _single_ panel — the _active_ one by +default, or a specific panel via their optional `{ panel }` selector (e.g. +`await elem.save({ panel: "my-panel" })`). If `restore()`'s `panel` names no +existing panel, a new panel is created with that id. + +A `` may host multiple panels. To serialize or restore the +_whole element_ — every panel plus the layout and cross-filter state — use +`saveWorkspace()` and `restoreWorkspace()` instead: + +```javascript +const workspace_token = await elem.saveWorkspace(); +await elem.restoreWorkspace(workspace_token); +``` + +A `saveWorkspace()` token is a `WorkspaceConfig` (`{ version, layout, panels, +... }`), not a `ViewerConfig` — passing it to the single-panel `restore()` will +_not_ restore the layout (its `panels`/`layout` keys are ignored). diff --git a/examples/blocks/src/dataset/index.html b/examples/blocks/src/dataset/index.html index e25678cda8..2e832b4930 100644 --- a/examples/blocks/src/dataset/index.html +++ b/examples/blocks/src/dataset/index.html @@ -179,7 +179,7 @@ const make_del_click_callback = (state) => async () => { if (state.table) { for (const name of window.psp_workspace.getPanelNames()) { - const cfg = await window.psp_workspace.savePanel(name); + const cfg = await window.psp_workspace.save({ panel: name }); if (cfg.table === "superstore") { window.psp_workspace.removePanel(name); } @@ -220,7 +220,7 @@ })(); await window.psp_workspace.load(client); - await window.psp_workspace.restore(await fetch("layout.json").then((x) => x.json())); + await window.psp_workspace.restoreWorkspace(await fetch("layout.json").then((x) => x.json())); diff --git a/examples/blocks/src/evictions/index.html b/examples/blocks/src/evictions/index.html index fa0cd1c864..a72b0458a0 100644 --- a/examples/blocks/src/evictions/index.html +++ b/examples/blocks/src/evictions/index.html @@ -61,7 +61,7 @@ const layout = await layout_json.json(); el.load(await evictions); - el.restore(layout); + el.restoreWorkspace(layout); } load(); diff --git a/examples/blocks/src/movies/index.html b/examples/blocks/src/movies/index.html index 52149ecf05..b187c83e6f 100644 --- a/examples/blocks/src/movies/index.html +++ b/examples/blocks/src/movies/index.html @@ -68,7 +68,7 @@ let req = await fetch("layout.json"); let layout = await req.json(); await window.workspace.load(worker); - await window.workspace.restore(layout); + await window.workspace.restoreWorkspace(layout); })(); diff --git a/examples/blocks/src/nypd/index.js b/examples/blocks/src/nypd/index.js index 295ffea73f..21def90667 100644 --- a/examples/blocks/src/nypd/index.js +++ b/examples/blocks/src/nypd/index.js @@ -97,7 +97,7 @@ if (LAYOUTS == undefined) { const layout_names = Object.keys(LAYOUTS); let selected_layout = LAYOUTS[layout_names[0]]; -await window.workspace.restore(selected_layout); +await window.workspace.restoreWorkspace(selected_layout); function set_layout_options() { const layout_names = Object.keys(LAYOUTS); @@ -117,7 +117,7 @@ window.layouts.addEventListener("change", async () => { return; } - await window.workspace.restore(LAYOUTS[window.layouts.value]); + await window.workspace.restoreWorkspace(LAYOUTS[window.layouts.value]); window.name_input.value = window.layouts.value; }); @@ -150,7 +150,7 @@ window.reset.addEventListener("click", () => { }); window.save.addEventListener("click", async () => { - const token = await window.workspace.save(); + const token = await window.workspace.saveWorkspace(); const new_name = window.name_input.value; LAYOUTS[new_name] = token; set_layout_options(); diff --git a/examples/blocks/src/olympics/index.html b/examples/blocks/src/olympics/index.html index 7dd14fe3b6..18517cc0ca 100644 --- a/examples/blocks/src/olympics/index.html +++ b/examples/blocks/src/olympics/index.html @@ -33,7 +33,7 @@ const arrow = await response.arrayBuffer(); await worker.table(arrow, { name: "olympics" }); window.workspace.load(worker); - await window.workspace.restore({ + await window.workspace.restoreWorkspace({ layout: { type: "tab-layout", tabs: ["PERSPECTIVE_GENERATED_ID_1", "PERSPECTIVE_GENERATED_ID_0"], diff --git a/examples/blocks/src/superstore/index.html b/examples/blocks/src/superstore/index.html index c158cc201b..354b89e78b 100644 --- a/examples/blocks/src/superstore/index.html +++ b/examples/blocks/src/superstore/index.html @@ -42,7 +42,7 @@ await worker.table(buffer, { name: "superstore" }); window.workspace.load(worker); const layout = await get_layout(); - await window.workspace.restore(layout); + await window.workspace.restoreWorkspace(layout); diff --git a/packages/react/src/utils.tsx b/packages/react/src/utils.tsx index 2d81653dc1..db4c20ee7c 100644 --- a/packages/react/src/utils.tsx +++ b/packages/react/src/utils.tsx @@ -16,11 +16,12 @@ export function usePspListener( el: HTMLElement | undefined | null, event: string, cb?: (x: A) => void, + map: (e: CustomEvent) => A = (e) => e.detail, ) { React.useEffect(() => { if (!cb || !el) return; const ctx = new AbortController(); - const callback = (e: Event) => cb((e as CustomEvent).detail); + const callback = (e: Event) => cb(map(e as CustomEvent)); el?.addEventListener(event, callback, { signal: ctx.signal }); return () => ctx.abort(); }, [el, cb]); diff --git a/packages/react/src/viewer.tsx b/packages/react/src/viewer.tsx index dc9631555a..9010ecbd25 100644 --- a/packages/react/src/viewer.tsx +++ b/packages/react/src/viewer.tsx @@ -26,22 +26,42 @@ function PerspectiveViewerImpl(props: PerspectiveViewerProps) { }, [viewer]); React.useEffect(() => { - if (props.client) { - viewer?.load(props.client); - } else { - viewer?.eject(); + if (!viewer) { + return; } - }, [viewer, props.client]); - React.useEffect(() => { - if (props.client && props.config) { - viewer?.restore(props.config); - } + let cancelled = false; + (async () => { + if (!props.client) { + await viewer.eject(); + return; + } + + await viewer.load(props.client); + if (cancelled || !props.config) { + return; + } + + if ("panels" in props.config) { + await viewer.restoreWorkspace(props.config); + } else { + await viewer.restore(props.config); + } + })(); + + return () => { + cancelled = true; + }; }, [viewer, props.client, JSON.stringify(props.config)]); usePspListener(viewer, "perspective-click", props.onClick); usePspListener(viewer, "perspective-select", props.onSelect); - usePspListener(viewer, "perspective-config-update", props.onConfigUpdate); + usePspListener( + viewer, + "perspective-config-update", + props.onConfigUpdate, + (e) => e.detail.getConfig(), + ); return ( | psp.Table | Promise; - config?: pspViewer.ViewerConfigUpdate; + config?: pspViewer.ViewerConfigUpdate | pspViewer.WorkspaceConfigUpdate; onConfigUpdate?: (config: pspViewer.ViewerConfigUpdate) => void; onClick?: (data: pspViewer.PerspectiveClickEventDetail) => void; onSelect?: (data: pspViewer.PerspectiveSelectEventDetail) => void; diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index 3ec34a6f5e..38aa0f6c48 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -356,11 +356,11 @@ export class HTMLPerspectiveViewerWebGLPluginElement // getClient()/getTable() resolve element-wide in a multi-panel viewer), // and to tag the dispatched interaction events with their source panel. const panel = this.getAttribute("slot") ?? undefined; - const client = await viewer.getClientPanel(undefined, panel); + const client = await viewer.getClient({ panel }); const viewer_class = customElements.get("perspective-viewer"); const clientWasm = viewer_class.get_wasm_module(); const clientWorkerURL = viewer_class.get_worker_url(); - const table = await viewer?.getTablePanel?.(undefined, panel); + const table = await viewer?.getTable?.({ panel }); const tableName: string | undefined = table ? await table.get_name() : undefined; @@ -586,7 +586,7 @@ export class HTMLPerspectiveViewerWebGLPluginElement return; } - const viewerConfig = await viewer.getViewConfigPanel(panel); + const viewerConfig = await viewer.getViewConfig({ panel }); await renderer.loadAndRender({ viewerConfig: { group_by: viewerConfig?.group_by ?? [], diff --git a/packages/viewer-charts/test/ts/context-leak.spec.ts b/packages/viewer-charts/test/ts/context-leak.spec.ts index 5c5b3ba34c..7b69206ae6 100644 --- a/packages/viewer-charts/test/ts/context-leak.spec.ts +++ b/packages/viewer-charts/test/ts/context-leak.spec.ts @@ -46,11 +46,10 @@ async function toggleSecondViewer(page: Page, cfg: object): Promise { await page.evaluate( async ({ cfg, tableName }) => { const worker = (window as any).__TEST_WORKER__; - const table = await worker.open_table(tableName); const v = document.createElement("perspective-viewer"); document.body.appendChild(v); - await v.load(table); - await v.restore(cfg); + await v.load(worker); + await v.restore({ ...(cfg as any), table: tableName }); }, { cfg, tableName: TABLE_NAME }, ); @@ -69,11 +68,10 @@ async function toggleSecondViewerRacingInit( await page.evaluate( async ({ cfg, tableName }) => { const worker = (window as any).__TEST_WORKER__; - const table = await worker.open_table(tableName); const v = document.createElement("perspective-viewer"); document.body.appendChild(v); - await v.load(table); - v.restore(cfg).catch(() => {}); + await v.load(worker); + v.restore({ ...(cfg as any), table: tableName }).catch(() => {}); await new Promise((resolve) => setTimeout(() => { v.remove(); diff --git a/packages/viewer-charts/test/ts/tab-stack-activation.spec.ts b/packages/viewer-charts/test/ts/tab-stack-activation.spec.ts index be3f4cccd4..8610d68a15 100644 --- a/packages/viewer-charts/test/ts/tab-stack-activation.spec.ts +++ b/packages/viewer-charts/test/ts/tab-stack-activation.spec.ts @@ -30,7 +30,7 @@ async function setupStack(page: Page): Promise { await page.evaluate( async ({ table }) => { const viewer = document.querySelector("perspective-viewer") as any; - await viewer.restore({ + await viewer.restoreWorkspace({ layout: { type: "tab-layout", tabs: ["one", "two"], @@ -179,7 +179,7 @@ test.describe("Tab-stack chart activation", () => { const active = await page.evaluate(async () => { const viewer = document.querySelector("perspective-viewer") as any; const active = viewer.getActivePanel(); - await viewer.restorePanel({ plugin: "Y Line" }, active); + await viewer.restore({ plugin: "Y Line" }, { panel: active }); await viewer.flush(); return active; }); @@ -190,7 +190,7 @@ test.describe("Tab-stack chart activation", () => { await hookDispatchCounters(page); await page.evaluate(async (active) => { const viewer = document.querySelector("perspective-viewer") as any; - await viewer.restorePanel({ plugin: "Y Line" }, active); + await viewer.restore({ plugin: "Y Line" }, { panel: active }); await viewer.flush(); }, active); diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts index 6ee9d56270..39c6baa52f 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -10,7 +10,12 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { PRIVATE_PLUGIN_SYMBOL } from "../model/index.js"; +import { + PRIVATE_PLUGIN_SYMBOL, + readThemeStyle, + save_column_size_overrides, + restore_column_size_overrides, +} from "../model/index.js"; import { activate } from "../plugin/activate.js"; import { restore } from "../plugin/restore.js"; import { save } from "../plugin/save.js"; @@ -270,7 +275,18 @@ export class HTMLPerspectiveViewerDatagridPluginElement } } - restyle() {} + restyle(): void { + if (!this.model || !this.isConnected) { + return; + } + + Object.assign(this.model, readThemeStyle(this.regular_table)); + if (this._initialized) { + const old_sizes = save_column_size_overrides.call(this); + this.regular_table.resetAutoSize(); + restore_column_size_overrides.call(this, old_sizes); + } + } delete(): void { this.disconnectedCallback(); diff --git a/packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts b/packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts index f659ce451d..39d8edf1b1 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts @@ -79,7 +79,6 @@ export class HTMLPerspectiveViewerDatagridToolbarElement extends HTMLElement { plugin._edit_button.addEventListener("click", () => { toggle_edit_mode.call(plugin); plugin.regular_table.draw(); - viewer.dispatchEvent(new Event("perspective-config-update")); }); } } diff --git a/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts index 56b28e9232..6cb312960d 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts @@ -54,10 +54,9 @@ export function createMousedownListener( const meta = regularTable.getMeta(target); const column_name = meta?.column_header?.[model._config.split_by.length]; - await viewer.toggleColumnSettingsPanel( - `${column_name}`, - model._panel, - ); + await viewer.toggleColumnSettings(`${column_name}`, { + panel: model._panel, + }); } else if (target.classList.contains("psp-sort-enabled")) { sortHandler(model, regularTable, viewer, mouseEvent, target); } diff --git a/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts b/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts index 8fbc778e6d..4c62ae2b96 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts @@ -355,7 +355,7 @@ function set_psp_selection( viewport.end_row = y1 + 1; } - viewer.setSelectionPanel(viewport, datagrid.model!._panel); + viewer.setSelection(viewport, { panel: datagrid.model!._panel }); } type CellPredicate = (meta: CellMetadataBody, area: SelectionArea) => boolean; @@ -406,7 +406,7 @@ export const applyMouseAreaSelections = ( } else { ( datagrid.parentElement as HTMLPerspectiveViewerElement - ).setSelectionPanel(undefined, datagrid.model!._panel); + ).setSelection(undefined, { panel: datagrid.model!._panel }); const tds = table.querySelectorAll("tbody td"); for (const td of tds) { td.classList.remove(className); diff --git a/packages/viewer-datagrid/src/ts/event_handlers/sort.ts b/packages/viewer-datagrid/src/ts/event_handlers/sort.ts index a9cd8f01ed..c1321481d1 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/sort.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/sort.ts @@ -51,7 +51,7 @@ export async function sortHandler( const abs = event.shiftKey; const sort = sort_method(model, `${column_name}`, abs); - await viewer.restorePanel({ sort }, model._panel); + await viewer.restore({ sort }, { panel: model._panel }); } export function append_sort( diff --git a/packages/viewer-datagrid/src/ts/model/create.ts b/packages/viewer-datagrid/src/ts/model/create.ts index 4e23be41cb..75eef5d11d 100644 --- a/packages/viewer-datagrid/src/ts/model/create.ts +++ b/packages/viewer-datagrid/src/ts/model/create.ts @@ -71,6 +71,58 @@ function get_rule(regular: HTMLElement, tag: string, def: string): string { } } +export type ThemeStyle = Pick< + DatagridModel, + | "_theme" + | "_plugin_background" + | "_color" + | "_pos_fg_color" + | "_neg_fg_color" + | "_pos_bg_color" + | "_neg_bg_color" +>; + +/** + * Read the theme-derived style values off `regular`'s computed style, the + * single source for the color/theme fields cached on `DatagridModel`. + */ +export function readThemeStyle(regular: HTMLElement): ThemeStyle { + const _theme = get_rule(regular, "--psp-theme-name", ""); + const _plugin_background = parseColor( + get_rule(regular, "--psp--background-color", "#FFFFFF"), + ); + + const _pos_fg_color = make_color_record( + get_rule(regular, "--psp-datagrid--pos-cell--color", "#338DCD"), + ); + + const _neg_fg_color = make_color_record( + get_rule(regular, "--psp-datagrid--neg-cell--color", "#FF5942"), + ); + + const _pos_bg_color = make_color_record( + blend(_pos_fg_color[0], _plugin_background), + ); + + const _neg_bg_color = make_color_record( + blend(_neg_fg_color[0], _plugin_background), + ); + + const _color = make_color_record( + get_rule(regular, "--psp-active--color", "#ff0000"), + ); + + return { + _theme, + _plugin_background, + _color, + _pos_fg_color, + _neg_fg_color, + _pos_bg_color, + _neg_bg_color, + }; +} + class ElemFactoryImpl implements ElemFactory { private _name: string; private _elements: HTMLElement[]; @@ -105,7 +157,7 @@ export async function createModel( extend: Partial = {}, ): Promise { const config = (await view.get_config()) as ViewConfig; - const theme = get_rule(regular, "--psp-theme-name", ""); + const style = readThemeStyle(regular); if (this?.model?._config) { const old = this.model._config; const group_by_changed = arraysChanged(old.group_by, config.group_by); @@ -128,7 +180,7 @@ export async function createModel( const group_rollup_mode_changed = old.group_rollup_mode !== config.group_rollup_mode; - const theme_changed = this.model._theme !== theme; + const theme_changed = this.model._theme !== style._theme; this._reset_scroll_top = group_by_changed; this._reset_scroll_left = split_by_changed; this._reset_select = @@ -154,35 +206,11 @@ export async function createModel( view.num_rows(), view.schema(), view.expression_schema(), - ( - this.parentElement as HTMLPerspectiveViewerElement - ).getEditPortPanel(_panel), + (this.parentElement as HTMLPerspectiveViewerElement).getEditPort({ + panel: _panel, + }), ]); - const _plugin_background = parseColor( - get_rule(regular, "--psp--background-color", "#FFFFFF"), - ); - - const _pos_fg_color = make_color_record( - get_rule(regular, "--psp-datagrid--pos-cell--color", "#338DCD"), - ); - - const _neg_fg_color = make_color_record( - get_rule(regular, "--psp-datagrid--neg-cell--color", "#FF5942"), - ); - - const _pos_bg_color = make_color_record( - blend(_pos_fg_color[0], _plugin_background), - ); - - const _neg_bg_color = make_color_record( - blend(_neg_fg_color[0], _plugin_background), - ); - - const _color = make_color_record( - get_rule(regular, "--psp-active--color", "#ff0000"), - ); - const _schema: Schema = { ...(schema as Schema), ...(expression_schema as Schema), @@ -219,15 +247,9 @@ export async function createModel( _num_rows: num_rows, _schema, _ids: [], - _plugin_background, - _color, - _pos_fg_color, - _neg_fg_color, - _pos_bg_color, - _neg_bg_color, + ...style, _column_paths, _column_types, - _theme: theme, _is_editable, _edit_mode, _selection_state: { diff --git a/packages/viewer-datagrid/src/ts/model/index.ts b/packages/viewer-datagrid/src/ts/model/index.ts index 5c570ca293..e6802c7f82 100644 --- a/packages/viewer-datagrid/src/ts/model/index.ts +++ b/packages/viewer-datagrid/src/ts/model/index.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ export { PRIVATE_PLUGIN_SYMBOL } from "../types.js"; -export { createModel } from "./create.js"; +export { createModel, readThemeStyle } from "./create.js"; export { toggle_edit_mode, toggle_scroll_lock } from "./toolbar.js"; export { save_column_size_overrides, diff --git a/packages/viewer-datagrid/src/ts/model/toolbar.ts b/packages/viewer-datagrid/src/ts/model/toolbar.ts index 210b12d04b..49d569a90a 100644 --- a/packages/viewer-datagrid/src/ts/model/toolbar.ts +++ b/packages/viewer-datagrid/src/ts/model/toolbar.ts @@ -55,9 +55,9 @@ export function toggle_edit_mode( } const panel = this.getAttribute("slot") ?? undefined; - (this.parentElement as HTMLPerspectiveViewerElement)?.setSelectionPanel?.( + (this.parentElement as HTMLPerspectiveViewerElement)?.setSelection?.( undefined, - panel, + { panel }, ); this._edit_mode = mode; if (this.model) { @@ -70,11 +70,11 @@ export function toggle_edit_mode( } if (echo) { - (this.parentElement as HTMLPerspectiveViewerElement)?.restorePanel?.( + (this.parentElement as HTMLPerspectiveViewerElement)?.restore?.( { plugin_config: { edit_mode: mode }, }, - panel, + { panel }, ); } diff --git a/packages/viewer-datagrid/src/ts/plugin/activate.ts b/packages/viewer-datagrid/src/ts/plugin/activate.ts index e59cc9c5f2..0b82e9b8b8 100644 --- a/packages/viewer-datagrid/src/ts/plugin/activate.ts +++ b/packages/viewer-datagrid/src/ts/plugin/activate.ts @@ -64,7 +64,7 @@ export async function activate( ): Promise { const viewer = this.parentElement as HTMLPerspectiveViewerElement; const panel = this.getAttribute("slot") ?? undefined; - const table = await viewer.getTablePanel(undefined, panel); + const table = await viewer.getTable({ panel }); if (!this._initialized) { this.innerHTML = ""; diff --git a/packages/viewer-datagrid/src/ts/plugin/restore.ts b/packages/viewer-datagrid/src/ts/plugin/restore.ts index 199a807463..6379033f0d 100644 --- a/packages/viewer-datagrid/src/ts/plugin/restore.ts +++ b/packages/viewer-datagrid/src/ts/plugin/restore.ts @@ -93,7 +93,7 @@ export function restore( } // `echo = false`: this `restore()` IS the host delivering the config — - // echoing it back via `restorePanel` queued a second render run + // echoing it back via `restore` queued a second render run // (draw-then-update on every initial load carrying a `plugin_config`). if ("edit_mode" in token) { if (EDIT_MODES.indexOf(token.edit_mode!) !== -1) { diff --git a/packages/viewer-datagrid/test/js/dispatch_gating.spec.ts b/packages/viewer-datagrid/test/js/dispatch_gating.spec.ts index 9144b94cff..8d1c2ee399 100644 --- a/packages/viewer-datagrid/test/js/dispatch_gating.spec.ts +++ b/packages/viewer-datagrid/test/js/dispatch_gating.spec.ts @@ -18,7 +18,7 @@ // `plugin_config` used to render TWICE — the restore run's `draw`, then // a stray `update` ~25ms later. The datagrid's `restore()` called // `toggle_edit_mode`, which echoed -// `restorePanel({plugin_config: {edit_mode}})` back into the host — an +// `restore({plugin_config: {edit_mode}})` back into the host — an // inert restore whose run reconciled `Unchanged` and repainted anyway. // The echo is now gated to user gestures, so boot is exactly ONE `draw`. // - B2: the public no-op-restore refresh affordance is PRESERVED — @@ -258,7 +258,7 @@ test.describe("Plugin dispatch gating (PLUGIN_DRAW_INVARIANT amendment)", () => }); expect(config.plugin_config.edit_mode).toEqual("EDIT"); - // The request path's repaint: the echoed restorePanel carries a + // The request path's repaint: the echoed restore carries a // genuinely NEW mode → `Unchanged + changed → update`. const counts = await read_counts(page, "perspective-viewer"); expect(counts.update).toBeGreaterThanOrEqual(1); diff --git a/packages/viewer-datagrid/test/js/restyle.spec.ts b/packages/viewer-datagrid/test/js/restyle.spec.ts new file mode 100644 index 0000000000..1866409dad --- /dev/null +++ b/packages/viewer-datagrid/test/js/restyle.spec.ts @@ -0,0 +1,166 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Regression spec for `restyle()`: the datagrid caches theme-derived colors +// on its model at creation time, and `restyle()` used to be a no-op, so a +// theme change on a live viewer kept painting cells with the OLD theme's +// colors until something recreated the model. `restyle()` now re-reads the +// computed style into the model (and resets auto column sizes for the new +// theme's fonts, preserving user-set widths), and the host's follow-up +// `update()` repaints. + +import { expect, test } from "@perspective-dev/test"; + +async function goto_ready(page: any) { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + + // `basic-test.html` loads only `pro.css` (= "Pro Light"); "Pro Dark" + // is a separate stylesheet, without which a theme switch changes no + // CSS at all. + await new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = + "/node_modules/@perspective-dev/viewer/dist/css/pro-dark.css"; + link.onload = resolve; + link.onerror = reject; + document.head.appendChild(link); + }); + }); +} + +// The inline `color` a negative numeric cell was painted with, and the +// theme's `--psp-datagrid--neg-cell--color`, both normalized to the +// browser's canonical `rgb(...)` serialization for comparison. +async function read_neg_cell_color(page: any) { + return await page.evaluate(async () => { + const norm = (color: string) => { + const el = document.createElement("div"); + el.style.color = color; + document.body.appendChild(el); + const out = getComputedStyle(el).color; + el.remove(); + return out; + }; + + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + + const td = datagrid.regular_table.querySelector("tbody td"); + return { + cell: norm(td.style.color), + theme_var: norm( + getComputedStyle(datagrid.regular_table) + .getPropertyValue("--psp-datagrid--neg-cell--color") + .trim(), + ), + }; + }); +} + +test.describe("Datagrid restyle()", () => { + test("theme change repaints cells with the new theme's colors", async ({ + page, + }) => { + await goto_ready(page); + + // Only negative values visible, so every data cell is painted with + // the neg-cell foreground color cached on the model. + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ + theme: "Pro Light", + columns: ["Profit"], + filter: [["Profit", "<", 0]], + }); + }); + + const light = await read_neg_cell_color(page); + expect(light.cell).toEqual(light.theme_var); + + await page.evaluate(async (theme: string) => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ theme }); + }, "Pro Dark"); + + const dark = await read_neg_cell_color(page); + expect(dark.theme_var).not.toEqual(light.theme_var); + expect(dark.cell).toEqual(dark.theme_var); + + // And back again — the model refresh is not one-shot. + await page.evaluate(async (theme: string) => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ theme }); + }, "Pro Light"); + + const light2 = await read_neg_cell_color(page); + expect(light2.cell).toEqual(light.theme_var); + }); + + // Asserted at the plugin level (`regular-table` override store + + // `plugin.save()`) rather than through `viewer.save()`/`restore()`: + // the host's schema-driven `plugin_config` bucket strips the `columns` + // key on both sides (`update_plugin_config`'s `active_keys()` retain), + // so column widths do not round-trip through the public viewer config + // at all today — a pre-existing gap unrelated to `restyle()`. + test("user-set column widths survive a theme change", async ({ page }) => { + await goto_ready(page); + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ + theme: "Pro Light", + columns: ["Profit"], + }); + await viewer.flush(); + await new Promise((x) => setTimeout(x, 500)); + + // The end state of a user column-resize drag. + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + datagrid.regular_table.restoreColumnSizes({ 0: 300 }); + }); + + // A genuine theme change dispatches `restyle()`, whose + // `resetAutoSize()` must preserve the user width via the + // save/restore override bracket. + await page.evaluate(async (theme: string) => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ theme }); + await viewer.flush(); + await new Promise((x) => setTimeout(x, 500)); + }, "Pro Dark"); + + const state = await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + return { + live: datagrid.regular_table.saveColumnSizes(), + token: datagrid.save(), + model_theme: datagrid.model._theme, + }; + }); + + expect(state.live).toEqual({ "0": 300 }); + expect(state.token.columns.Profit.column_size_override).toEqual(300); + + // Proof the restyle actually ran (the bracket was exercised, not + // skipped): the model captured the new theme. + expect(state.model_theme).toEqual('"Pro Dark"'); + }); +}); diff --git a/rust/metadata/main.rs b/rust/metadata/main.rs index c3b9f208e9..2210ec7b42 100644 --- a/rust/metadata/main.rs +++ b/rust/metadata/main.rs @@ -36,13 +36,24 @@ use perspective_client::{ TableInitOptions, UpdateOptions, ViewWindow, }; use perspective_js::TypedArrayWindow; -use perspective_viewer::config::{PluginStaticConfig, ViewerConfig, ViewerConfigUpdate}; +use perspective_viewer::config::{ + ClientOptions, ExportMethod, ExportOptions, GetClientOptions, GetTableOptions, PanelOptions, + PluginStaticConfig, ViewerConfig, ViewerConfigUpdate, WorkspaceConfig, WorkspaceConfigUpdate, +}; use ts_rs::TS; pub fn generate_type_bindings_viewer() -> Result<(), Box> { let path = std::env::current_dir()?.join("../perspective-viewer/src/ts/ts-rs"); ViewerConfigUpdate::export_all_to(&path)?; ViewerConfig::::export_all_to(&path)?; + WorkspaceConfig::export_all_to(&path)?; + WorkspaceConfigUpdate::export_all_to(&path)?; + ExportMethod::export_all_to(&path)?; + PanelOptions::export_all_to(&path)?; + ClientOptions::export_all_to(&path)?; + ExportOptions::export_all_to(&path)?; + GetTableOptions::export_all_to(&path)?; + GetClientOptions::export_all_to(&path)?; PluginStaticConfig::export_all_to(&path)?; OnUpdateData::export_all_to(&path)?; Ok(()) diff --git a/rust/perspective-python/perspective/widget/viewer/validate.py b/rust/perspective-python/perspective/widget/viewer/validate.py deleted file mode 100644 index 97fa3b513f..0000000000 --- a/rust/perspective-python/perspective/widget/viewer/validate.py +++ /dev/null @@ -1,22 +0,0 @@ -# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -# ┃ Copyright (c) 2017, the Perspective Authors. ┃ -# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -# ┃ This file is part of the Perspective library, distributed under the terms ┃ -# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - -def validate_version(version): - # basic semver of form \d+\.\d+\.\d+(\+.+)? - spl = version.split(".", 2) - return ( - len(spl) == 3 - and spl[0].isdigit() - and spl[1].isdigit() - and (spl[2].split("+")[0]).isdigit() - ) diff --git a/rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py b/rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py index b8284a27cc..6864077b4f 100644 --- a/rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py +++ b/rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py @@ -10,16 +10,12 @@ # ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -from traitlets import HasTraits, TraitError, Unicode, List, Bool, Dict, validate, Enum +from traitlets import HasTraits, Unicode, List, Bool, Dict, Enum import importlib.metadata __version__ = importlib.metadata.version("perspective-python") -from .validate import ( - validate_version, -) - class PerspectiveTraitlets(HasTraits): """Define the traitlet interface with `PerspectiveJupyterWidget` on the @@ -99,4 +95,3 @@ class PerspectiveTraitlets(HasTraits): # @validate("title") # def _validate_title(self, proposal): # return validate_title(proposal.value) - diff --git a/rust/perspective-viewer/src/css/panel-tab.css b/rust/perspective-viewer/src/css/panel-tab.css index 264582bfe7..75ed681aa0 100644 --- a/rust/perspective-viewer/src/css/panel-tab.css +++ b/rust/perspective-viewer/src/css/panel-tab.css @@ -56,6 +56,28 @@ mask-position: center; } +.psp-tab-caret { + flex: 0 0 auto; + display: none; + margin-right: 10px; + pointer-events: none; +} + +.psp-tab-caret::before { + content: ">"; +} + +:host([part~="tab"].active) .psp-tab-caret { + display: block; +} + +/* A lone tab (its viewer has a single plugin child) has no stack to open — + * no caret. Must follow the `.active` rule (equal specificity) to win when + * the lone tab is also active. */ +:host([part~="tab"].single) .psp-tab-caret { + display: none; +} + .psp-tab-title { flex: 1 1 auto; pointer-events: none; @@ -96,6 +118,14 @@ outline: none; } +.psp-tab-master { + margin-right: 6px; +} + +.psp-tab-master:before { + content: var(--psp-broadcast--content, "[BROADCAST]"); +} + .psp-tab-close { flex: 0 0 auto; display: flex; @@ -109,13 +139,17 @@ color: inherit; } +.psp-tab-close:hover::before { + background-color: var(--psp--color); +} + .psp-tab-close::before { content: ""; /* TODO don't hard-code dimensions */ width: 20px; height: 20px; - background-color: var(--psp--color); + background-color: var(--psp-inactive--color); -webkit-mask-image: var(--psp-icon--close--mask-image); mask-image: var(--psp-icon--close--mask-image); -webkit-mask-size: contain; diff --git a/rust/perspective-viewer/src/css/status-bar.css b/rust/perspective-viewer/src/css/status-bar.css index 9f7fc07a71..c8c1faf859 100644 --- a/rust/perspective-viewer/src/css/status-bar.css +++ b/rust/perspective-viewer/src/css/status-bar.css @@ -20,6 +20,7 @@ :host { #status_bar.settings-closed { + box-shadow: none; padding-right: 10px; } diff --git a/rust/perspective-viewer/src/css/viewer.css b/rust/perspective-viewer/src/css/viewer.css index c42b3d2d43..bb8af40915 100644 --- a/rust/perspective-viewer/src/css/viewer.css +++ b/rust/perspective-viewer/src/css/viewer.css @@ -328,7 +328,7 @@ inset: -2px -3px -3px -3px; box-sizing: border-box; padding: 0px; - background: var(--psp-panel-gap--background, #808080); + background: var(--psp--background-color, transparent); & > .rl-panel { position: relative; @@ -341,6 +341,7 @@ & > .rl-panel::part(container) { position: relative; background: var(--psp--background-color); + /* box-shadow: 0 0 0 4.5px var(--psp--background-color); */ } & > .rl-panel::part(titlebar-track) { diff --git a/rust/perspective-viewer/src/rust/components/column_dropdown.rs b/rust/perspective-viewer/src/rust/components/column_dropdown.rs index b9fe40f57d..460810fe10 100644 --- a/rust/perspective-viewer/src/rust/components/column_dropdown.rs +++ b/rust/perspective-viewer/src/rust/components/column_dropdown.rs @@ -70,50 +70,60 @@ impl ColumnDropDownElement { callback: Callback, ) -> Option<()> { let input = target.value(); - let metadata = self.session.metadata(); - let mut values: Vec = vec![]; let small_input = input.to_lowercase(); - for col in metadata.get_table_columns()? { - if !exclude.contains(col) && col.to_lowercase().contains(&small_input) { - values.push(InPlaceColumn::Column(col.to_owned())); + let mut values: Vec = vec![]; + { + let metadata = self.session.metadata(); + for col in metadata.get_table_columns()? { + if !exclude.contains(col) && col.to_lowercase().contains(&small_input) { + values.push(InPlaceColumn::Column(col.to_owned())); + } } - } - for col in self.session.metadata().get_expression_columns() { - if !exclude.contains(col) && col.to_lowercase().contains(&small_input) { - values.push(InPlaceColumn::Column(col.to_owned())); + for col in metadata.get_expression_columns() { + if !exclude.contains(col) && col.to_lowercase().contains(&small_input) { + values.push(InPlaceColumn::Column(col.to_owned())); + } } } - clone!(self.state, self.session, self.notify); - let target_elem: HtmlElement = target.clone().into(); let width = target.get_bounding_client_rect().width(); - ApiFuture::spawn(async move { - if !exclude.contains(&input) { + let target_elem: HtmlElement = target.clone().into(); + + // Publish the synchronous matches immediately, *before* any async + // work, so the dropdown and `item_select` always reflect the latest + // keystroke rather than whichever async continuation happens to + // resolve last. + { + let mut s = self.state.borrow_mut(); + s.values = values; + s.selected = 0; + s.width = width; + s.on_select = Some(callback); + s.target = Some(target_elem); + s.no_results = s.values.is_empty(); + } + self.notify.emit(()); + + if !exclude.contains(&input) { + clone!(self.state, self.session, self.notify); + ApiFuture::spawn(async move { let is_expr = crate::queries::validate_expr(&session, &input) .await? .is_none(); - if is_expr { - values.push(InPlaceColumn::Expression(Expression::new( + if is_expr && target.value() == input { + let mut s = state.borrow_mut(); + s.values.push(InPlaceColumn::Expression(Expression::new( None, input.into(), ))); + s.no_results = false; + drop(s); + notify.emit(()); } - } - - let no_results = values.is_empty(); - { - let mut s = state.borrow_mut(); - s.values = values; - s.selected = 0; - s.width = width; - s.on_select = Some(callback); - s.target = Some(target_elem); - s.no_results = no_results; - } - notify.emit(()); - Ok(()) - }); + Ok(()) + }); + } Some(()) } diff --git a/rust/perspective-viewer/src/rust/components/main_panel.rs b/rust/perspective-viewer/src/rust/components/main_panel.rs index 19425f1893..7c0e2bc89f 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel.rs @@ -19,8 +19,11 @@ //! tab/active sync, context menu). //! - [`presize`] — the `BeforeResize` pre-size-every-plugin algorithm. //! - [`reconcile`] — the `rendered` layout reconcile + per-panel theme stamp. +//! - [`frame_theme`] — the `rendered` frame-background mirror (each +//! ``'s panel-theme background var). //! - [`render`] — the `view` (status bar, cells, tabs, menu). +mod frame_theme; mod presize; mod reconcile; mod render; @@ -93,6 +96,12 @@ pub struct MainPanelProps { /// its theme's `--psp-*` block only when it diverges from the host theme. pub panel_themes: Vec<(String, Option)>, + /// The master (filter-source) panel ids, sorted. A snapshot so a master + /// toggle re-renders MainPanel — the role set is interior-mutable on + /// `Workspace` and not otherwise observed by `eq`. Drives each tab's + /// broadcast badge. + pub panel_masters: Vec, + /// Element-level global filters (fed by master/detail selection), threaded /// to the `StatusBar` where the global-filter chips are rendered. pub global_filters: Vec, @@ -123,6 +132,7 @@ impl PartialEq for MainPanelProps { && self.panel_ids == rhs.panel_ids && self.panel_titles == rhs.panel_titles && self.panel_themes == rhs.panel_themes + && self.panel_masters == rhs.panel_masters && self.global_filters == rhs.global_filters } } @@ -188,6 +198,17 @@ pub struct MainPanel { /// `None`. Transient (regular-layout doesn't persist it); drives the /// Maximize/Restore menu label. Cleared when the panel leaves the layout. maximized: Option, + + /// Theme-name-keyed cache of backgrounds read off stamped plugin + /// elements, the mirror source for frames whose own plugin is unreadable + /// (see [`frame_theme`]). Cleared when the theme registry changes. + theme_backgrounds: HashMap, + + /// The inputs `stamp_frame_themes` last mirrored from; unchanged inputs + /// skip the pass (and its forced style recalcs) on unrelated re-renders. + /// `None` until the first *fully-resolved* pass — an unresolved frame + /// leaves it unlatched so the mirror retries each render. + stamped_frame_themes: Option, } impl Component for MainPanel { @@ -269,6 +290,8 @@ impl Component for MainPanel { hidden_tabs: HashSet::new(), context_menu: None, maximized: None, + theme_backgrounds: HashMap::new(), + stamped_frame_themes: None, } } @@ -290,6 +313,7 @@ impl Component for MainPanel { fn rendered(&mut self, ctx: &Context, _first_render: bool) { self.reconcile(ctx); + self.stamp_frame_themes(ctx); } fn view(&self, ctx: &Context) -> Html { diff --git a/rust/perspective-viewer/src/rust/components/main_panel/frame_theme.rs b/rust/perspective-viewer/src/rust/components/main_panel/frame_theme.rs new file mode 100644 index 0000000000..96b96556d0 --- /dev/null +++ b/rust/perspective-viewer/src/rust/components/main_panel/frame_theme.rs @@ -0,0 +1,192 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! `MainPanel::stamp_frame_themes`: mirror each panel's effective theme +//! *background* onto its ``. +//! +//! The frames live in the viewer's shadow DOM, so the document theme rules +//! (`perspective-viewer [theme="X"]`) can never match them — the +//! `--psp--background-color` their `::part(container)` background resolves +//! (viewer.css) inherits down from the viewer host, i.e. always the *host* +//! theme. A frame is normally covered by its slotted plugin (which paints its +//! own per-panel themed background), but while the plugin is missing — not yet +//! mounted, first draw pending, or torn down — the host-theme container shows +//! through a panel themed differently, as a visible artifact. +//! +//! There is no native cascade channel from the light-DOM plugin to the frame +//! (siblings in the flattened tree), so the value is mirrored imperatively: +//! each panel's *plugin element* — light-DOM, slotted under the panel's slot, +//! mounted eagerly at panel creation (`create_panel_model` / +//! `Renderer::mount_active_plugin`) — is where the document theme rules +//! actually resolve, so its computed `--psp--background-color` is copied onto +//! the frame's inline style, where the frame's shadow parts inherit it. The +//! plugin is the ONLY sound source element: +//! +//! - An owned hidden probe child of the viewer does NOT work: an *unslotted* +//! light-DOM child of a shadow host is outside the flattened tree, and +//! `getComputedStyle` returns all-empty for it even though the document rules +//! match it syntactically (selector matching is DOM-tree; style computation +//! is flat-tree). +//! - The `` is slotted, but stamps its own `theme` attr +//! in `PanelTab::rendered` — an unordered sibling lifecycle relative to this +//! pass (the theme-stamp-lag class of bug). +//! - The plugin's `theme` attr, by contrast, is stamped *synchronously at the +//! mutation sites* (`update_theme`, `restorePanel`, …) before the events that +//! schedule this render, so it is current when this pass reads it. +//! +//! A read is trusted ONLY when the plugin's stamped attr equals the theme +//! being mirrored: a freshly-created panel's plugin is mounted *unstamped* +//! (the attr first lands inside its first locked dispatch), and reading it +//! early would silently mirror the HOST theme's values. Trusted reads seed +//! [`MainPanel::theme_backgrounds`], the fallback for frames with no readable +//! plugin (an unregistered plugin name stays lazily unmounted; plugin-switch +//! and teardown transients). A frame with neither leaves the pass gate +//! unlatched, so it retries on subsequent renders until the dispatch stamp +//! lands (the render that follows that dispatch's `update_count` bump). +//! +//! Unlike the plugin `theme`/`active` stamps (see the NOTE in [`reconcile`]), +//! the frame is pure viewer chrome — no plugin dispatch reads it — so +//! stamping it from this async `rendered` pass cannot split a plugin draw +//! across paints, and it needs no lock. + +use wasm_bindgen::prelude::*; +use yew::prelude::*; + +use super::MainPanel; +use crate::utils::PtrEqRc; +use crate::workspace::PanelId; + +/// The custom property mirrored onto each frame: what viewer.css's +/// `.rl-panel::part(container)` background resolves. +const BACKGROUND_VAR: &str = "--psp--background-color"; + +/// The inputs the frame backgrounds were last computed from; re-mirroring is +/// skipped while these are unchanged, so the forced style recalcs of +/// `getComputedStyle` don't run on every render (e.g. the per-update +/// `update_count` renders of a streaming table). `available_themes` is part of +/// the key because theme CSS registering late changes the *computed* value +/// under an unchanged theme name. +pub(super) type FrameThemeSnapshot = ( + Vec, + Vec<(String, Option)>, + PtrEqRc>, +); + +impl MainPanel { + /// Mirror each panel's effective theme background (its own theme, else the + /// registry default — the same fallback the tab/menu use) onto its + /// ``'s inline `--psp--background-color`, read off + /// the panel's stamped plugin element (else the theme-keyed cache of prior + /// reads). A panel with no resolvable theme has the property removed, + /// falling back to the inherited host-theme value. + pub(super) fn stamp_frame_themes(&mut self, ctx: &Context) { + let Some(layout) = self.layout_ref.cast::() else { + return; + }; + + let props = ctx.props(); + let snapshot: FrameThemeSnapshot = ( + props.panel_ids.clone(), + props.panel_themes.clone(), + props.presentation_props.available_themes.clone(), + ); + + if self.stamped_frame_themes.as_ref() == Some(&snapshot) { + return; + } + + // A theme-registry change can re-value an unchanged theme name, so + // cached reads from the previous registry are unsound. + if let Some((_, _, prev_themes)) = &self.stamped_frame_themes + && prev_themes != &snapshot.2 + { + self.theme_backgrounds.clear(); + } + + let viewer = props.presentation.viewer_elem().clone(); + let default_theme = props.presentation_props.available_themes.first(); + let mut complete = true; + let children = layout.children(); + for i in 0..children.length() { + let Some(frame) = children.item(i) else { + continue; + }; + + if !frame + .tag_name() + .eq_ignore_ascii_case("regular-layout-frame") + { + continue; + } + + let Some(name) = frame.get_attribute("name") else { + continue; + }; + + let theme = props + .panel_themes + .iter() + .find(|(pid, _)| *pid == name) + .and_then(|(_, theme)| theme.clone()) + .or_else(|| default_theme.cloned()); + + let background = theme.as_ref().and_then(|theme| { + let fresh = read_plugin_background(&viewer, &name, theme); + if let Some(color) = &fresh { + self.theme_backgrounds.insert(theme.clone(), color.clone()); + } + + let value = fresh.or_else(|| self.theme_backgrounds.get(theme).cloned()); + complete &= value.is_some(); + value + }); + + let style = frame.unchecked_ref::().style(); + match background { + Some(color) => { + let _ = style.set_property(BACKGROUND_VAR, &color); + }, + None => { + let _ = style.remove_property(BACKGROUND_VAR); + }, + } + } + + // Latch only a fully-resolved pass; an unresolved frame (unmounted or + // not-yet-stamped plugin) retries each render until its dispatch + // stamp lands. + self.stamped_frame_themes = complete.then_some(snapshot); + } +} + +/// Read the computed [`BACKGROUND_VAR`] off `slot`'s plugin element — the +/// viewer light-DOM child mounted under exactly that slot name (the tab and +/// toolbar use `tab-`/`statusbar-extra-` prefixed slots). Requires the +/// plugin's `theme` attr to already equal `theme` — during a plugin switch +/// two elements briefly share the slot, and a freshly-mounted plugin is not +/// yet stamped; both are disambiguated by the attr check. +fn read_plugin_background(viewer: &web_sys::Element, slot: &str, theme: &str) -> Option { + let children = viewer.children(); + let plugin = (0..children.length()) + .filter_map(|i| children.item(i)) + .filter(|el| el.get_attribute("slot").as_deref() == Some(slot)) + .find(|el| el.get_attribute("theme").as_deref() == Some(theme))?; + + let value = web_sys::window()? + .get_computed_style(&plugin) + .ok()?? + .get_property_value(BACKGROUND_VAR) + .ok()?; + + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} diff --git a/rust/perspective-viewer/src/rust/components/main_panel/render.rs b/rust/perspective-viewer/src/rust/components/main_panel/render.rs index 2676b1c282..88acf83dad 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/render.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/render.rs @@ -155,9 +155,12 @@ impl MainPanel { // listener picks up activation). let viewer_elem = ctx.props().presentation.viewer_elem().clone(); // A lone panel can't be closed to zero, and has nowhere to be - // dragged — so it's neither closable nor draggable. + // dragged — so it's neither closable nor draggable; its tab also + // carries the `single` panel-count class (which e.g. hides the + // caret). let closable = ctx.props().panel_ids.len() > 1; let draggable = closable; + let single = !closable; // Each panel's effective theme: its own (`panel_themes` snapshot) or // the registry default (first registered theme). Stamped on the tab so // the `[theme]` document rules theme it per-panel. @@ -206,6 +209,7 @@ impl MainPanel { .and_then(|(_, t)| t.clone()); let active = active_slot.as_deref() == Some(name.as_str()); let visible = !self.hidden_tabs.contains(name.as_str()); + let is_master = ctx.props().panel_masters.contains(id); let theme = ctx .props() .panel_themes @@ -223,6 +227,8 @@ impl MainPanel { {theme} {active} {visible} + {is_master} + {single} {closable} {draggable} on_select={on_select.clone()} diff --git a/rust/perspective-viewer/src/rust/components/panel_tab.rs b/rust/perspective-viewer/src/rust/components/panel_tab.rs index 2da9d40406..6ba0d055bb 100644 --- a/rust/perspective-viewer/src/rust/components/panel_tab.rs +++ b/rust/perspective-viewer/src/rust/components/panel_tab.rs @@ -127,6 +127,16 @@ pub struct PanelTabProps { /// panel, but only one panel in the whole layout is active. pub visible: bool, + /// `true` when this panel is a master (filter-source) panel — shows the + /// broadcast badge to the left of the close button. + pub is_master: bool, + + /// `true` when this is the host viewer's ONLY panel (one plugin child). + /// Reflected on the host as a `single`/`multi` class — the same + /// panel-count CSS hook `Renderer::stamp_active` stamps on the plugin — + /// e.g. the caret is hidden on a lone tab (see panel-tab.css). + pub single: bool, + /// `false` for a lone panel (which can't be closed to zero) — hides the /// close button. pub closable: bool, @@ -232,20 +242,23 @@ impl PanelTab { true } - /// Reflect `active`/`visible` onto the host (not a vnode — so its class is - /// set imperatively). `visible` marks the front (selected) tab of every - /// stack, not just the single active panel; the shadow CSS keys off - /// `:host(.active)` / `:host(.visible)`. - fn sync_class(&self, active: bool, visible: bool) { - let class = match (active, visible) { - (true, true) => "active visible", - // Active implies front-of-stack, but guard the transient anyway. - (true, false) => "active", - (false, true) => "visible", - (false, false) => "", - }; + /// Reflect `active`/`visible`/`single` onto the host (not a vnode — so + /// its class is set imperatively). `visible` marks the front (selected) + /// tab of every stack, not just the single active panel; `single`/`multi` + /// reflect the host viewer's panel count. The shadow CSS keys off + /// `:host(.active)` / `:host(.visible)` / `:host(.single)`. + fn sync_class(&self, active: bool, visible: bool, single: bool) { + let mut class = Vec::with_capacity(3); + if active { + class.push("active"); + } + + if visible { + class.push("visible"); + } - let _ = self.host.set_attribute("class", class); + class.push(if single { "single" } else { "multi" }); + let _ = self.host.set_attribute("class", &class.join(" ")); } } @@ -447,10 +460,16 @@ impl Component for PanelTab { } }; + // The caret is always in the DOM; panel-tab.css shows it only on + // `:host(.active)`, so it flips with the tab chrome in the same style + // pass as `sync_class` (which stamps `active` imperatively, after + // render). let content = html! { <> + { title_html } + if ctx.props().is_master { } if ctx.props().closable {