From 35595355f398a682b01be10cdee0bb55576ea47a Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Mon, 10 Aug 2026 13:16:22 -0400 Subject: [PATCH 01/14] Fix sum for null updates Signed-off-by: Andrew Stein --- docs/deploy.mjs | 22 +-- .../test/js/pivot_nulls.spec.js | 138 ++++++++++++++++++ .../src/include/perspective/gnode.h | 26 +++- 3 files changed, 171 insertions(+), 15 deletions(-) diff --git a/docs/deploy.mjs b/docs/deploy.mjs index bc78605f0a..f8461a881a 100644 --- a/docs/deploy.mjs +++ b/docs/deploy.mjs @@ -19,12 +19,13 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, ".."); const DIST = path.join(__dirname, "dist"); const STAGING = path.join(REPO_ROOT, "dist-gh-pages"); -const BRANCH = "gh-pages"; +const DEPLOY_REPO = + "https://github.com/perspective-dev/perspective-dev.github.io.git"; function git(args, opts = {}) { return execFileSync("git", args, { stdio: "inherit", - cwd: REPO_ROOT, + cwd: STAGING, ...opts, }); } @@ -47,22 +48,21 @@ if (!fs.existsSync(DIST)) { } if (!fs.existsSync(STAGING)) { - git(["worktree", "add", STAGING, BRANCH]); + git(["clone", DEPLOY_REPO, STAGING], { cwd: REPO_ROOT }); } else { - git(["fetch", "origin", BRANCH]); - git(["checkout", `origin/${BRANCH}`], { cwd: STAGING }); + git(["fetch", "origin"]); + git(["reset", "--hard", "origin/HEAD"]); } -// Clear tracked + untracked content in the staging worktree, preserving -// the worktree's `.git` link. -git(["rm", "-rf", "--quiet", "--ignore-unmatch", "."], { cwd: STAGING }); -git(["clean", "-fdx"], { cwd: STAGING }); +// Clear tracked + untracked content in the staging clone, preserving `.git`. +git(["rm", "-rf", "--quiet", "--ignore-unmatch", "."]); +git(["clean", "-fdx"]); for (const entry of fs.readdirSync(DIST)) { copyRecursive(path.join(DIST, entry), path.join(STAGING, entry)); } -git(["add", "-A"], { cwd: STAGING }); +git(["add", "-A"]); -console.log(`Staged dist/ onto ${BRANCH} at ${STAGING}`); +console.log(`Staged dist/ onto ${DEPLOY_REPO} at ${STAGING}`); console.log(`Review with \`git -C ${STAGING} status\`, then commit and push.`); diff --git a/rust/perspective-js/test/js/pivot_nulls.spec.js b/rust/perspective-js/test/js/pivot_nulls.spec.js index b4379b825c..49d68f3c55 100644 --- a/rust/perspective-js/test/js/pivot_nulls.spec.js +++ b/rust/perspective-js/test/js/pivot_nulls.spec.js @@ -332,5 +332,143 @@ import perspective from "./perspective_client"; }, ]); }); + test.describe("sum aggregate with null updates (#1256)", function () { + test("sum does not accumulate when an indexed row flips between null and a value", async function () { + const table = await perspective.table( + { ticker: "string", pnl: "integer" }, + { index: "ticker" } + ); + + await table.update([ + { ticker: "IBM", pnl: 100 }, + { ticker: "AAPL", pnl: 100 }, + ]); + + const view = await table.view({ + group_by: ["ticker"], + columns: ["pnl"], + aggregates: { pnl: "sum" }, + }); + + const nulled = [ + { __ROW_PATH__: [], pnl: 100 }, + { __ROW_PATH__: ["AAPL"], pnl: 0 }, + { __ROW_PATH__: ["IBM"], pnl: 100 }, + ]; + + const restored = [ + { __ROW_PATH__: [], pnl: 200 }, + { __ROW_PATH__: ["AAPL"], pnl: 100 }, + { __ROW_PATH__: ["IBM"], pnl: 100 }, + ]; + + expect(await view.to_json()).toEqual(restored); + for (let i = 0; i < 3; i++) { + await table.update([{ ticker: "AAPL", pnl: null }]); + expect(await view.to_json()).toEqual(nulled); + await table.update([{ ticker: "AAPL", pnl: 100 }]); + expect(await view.to_json()).toEqual(restored); + } + + view.delete(); + table.delete(); + }); + + test("float sum does not accumulate when an indexed row flips between null and a value", async function () { + const table = await perspective.table( + { ticker: "string", pnl: "float" }, + { index: "ticker" } + ); + + await table.update([ + { ticker: "IBM", pnl: 100.5 }, + { ticker: "AAPL", pnl: 100.5 }, + ]); + + const view = await table.view({ + group_by: ["ticker"], + columns: ["pnl"], + aggregates: { pnl: "sum" }, + }); + + const nulled = [ + { __ROW_PATH__: [], pnl: 100.5 }, + { __ROW_PATH__: ["AAPL"], pnl: 0 }, + { __ROW_PATH__: ["IBM"], pnl: 100.5 }, + ]; + + const restored = [ + { __ROW_PATH__: [], pnl: 201 }, + { __ROW_PATH__: ["AAPL"], pnl: 100.5 }, + { __ROW_PATH__: ["IBM"], pnl: 100.5 }, + ]; + + expect(await view.to_json()).toEqual(restored); + for (let i = 0; i < 3; i++) { + await table.update([{ ticker: "AAPL", pnl: null }]); + expect(await view.to_json()).toEqual(nulled); + await table.update([{ ticker: "AAPL", pnl: 100.5 }]); + expect(await view.to_json()).toEqual(restored); + } + + view.delete(); + table.delete(); + }); + + test("sum is unchanged by a partial update which omits the column", async function () { + const table = await perspective.table( + { ticker: "string", pnl: "integer", qty: "integer" }, + { index: "ticker" } + ); + + await table.update([ + { ticker: "IBM", pnl: 100, qty: 1 }, + { ticker: "AAPL", pnl: 100, qty: 1 }, + ]); + + const view = await table.view({ + group_by: ["ticker"], + columns: ["pnl"], + aggregates: { pnl: "sum" }, + }); + + await table.update([{ ticker: "AAPL", qty: 2 }]); + expect(await view.to_json()).toEqual([ + { __ROW_PATH__: [], pnl: 200 }, + { __ROW_PATH__: ["AAPL"], pnl: 100 }, + { __ROW_PATH__: ["IBM"], pnl: 100 }, + ]); + + view.delete(); + table.delete(); + }); + + test("sum is unchanged by removing a row whose value is null", async function () { + const table = await perspective.table( + { ticker: "string", pnl: "integer" }, + { index: "ticker" } + ); + + await table.update([ + { ticker: "IBM", pnl: 100 }, + { ticker: "AAPL", pnl: null }, + ]); + + const view = await table.view({ + group_by: ["ticker"], + columns: ["pnl"], + aggregates: { pnl: "sum" }, + }); + + await table.remove(["AAPL"]); + expect(await view.to_json()).toEqual([ + { __ROW_PATH__: [], pnl: 100 }, + { __ROW_PATH__: ["IBM"], pnl: 100 }, + ]); + + view.delete(); + table.delete(); + }); + }); }); })(perspective); diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h index 6fc933ee9f..303286513d 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h @@ -628,9 +628,25 @@ t_gnode::_process_column( prev_pkey_eq ); - dcolumn->set_nth( - added_count, cur_valid ? cur_value - prev_value : DATA_T(0) - ); + // Mirrors `t_gstate::update_master_column`: an invalid cell + // is an explicit null if CLEAR (removes this row's + // contribution from additive aggregates), and a no-op if + // INVALID (column omitted from a partial update). A slot's + // raw bits are unspecified when its validity flag is false, + // so only the valid side of a transition may be read (#1256). + DATA_T delta_value; + if (cur_valid) { + delta_value = + cur_value - (prev_valid ? prev_value : DATA_T(0)); + } else if (fcolumn->is_cleared(idx) && prev_valid) { + SUPPRESS_WARNINGS_VC(4146) + delta_value = -prev_value; + RESTORE_WARNINGS_VC() + } else { + delta_value = DATA_T(0); + } + + dcolumn->set_nth(added_count, delta_value); dcolumn->set_valid(added_count, true); pcolumn->set_nth(added_count, prev_value); @@ -658,7 +674,9 @@ t_gnode::_process_column( ccolumn->set_valid(added_count, prev_valid); SUPPRESS_WARNINGS_VC(4146) - dcolumn->set_nth(added_count, -prev_value); + dcolumn->set_nth( + added_count, prev_valid ? -prev_value : DATA_T(0) + ); RESTORE_WARNINGS_VC() dcolumn->set_valid(added_count, true); From dfc54b15e518e040dbc3b6039fb6eed213cdf097 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Mon, 10 Aug 2026 13:16:32 -0400 Subject: [PATCH 02/14] Fix split-rollup-mode defaults Signed-off-by: Andrew Stein --- .../test/ts/rollup-mode-coercion.spec.ts | 113 ++++++++++++++++++ .../src/rust/components/settings_panel.rs | 1 + rust/perspective-viewer/src/rust/session.rs | 10 +- .../rust/session/column_defaults_update.rs | 23 ++-- .../src/rust/session/props.rs | 1 + 5 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts diff --git a/packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts b/packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts new file mode 100644 index 0000000000..7eccc50bb4 --- /dev/null +++ b/packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts @@ -0,0 +1,113 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Rollup-mode coercion across `restore({plugin})` swaps. + * + * A `ViewConfigUpdate` with an absent `group_rollup_mode` / + * `split_rollup_mode` KEEPS the committed mode (`ViewConfig::apply_update` + * merge semantics). The plugin-advised coercion + * (`set_update_rollup_defaults`) must therefore judge acceptance against + * the mode that will actually be in effect — the committed one — not a + * hardcoded default. Judging against the default let a plugin swap retain + * a mode the new plugin excludes: Datagrid declares `split_rollup_modes: + * ["flat", "rollup"]` while every chart declares `["flat"]`, so + * committing `split_rollup_mode: "rollup"` under Datagrid and then + * `restore({plugin: "Y Bar"})` (no rollup field) left `"rollup"` in the + * saved config and built the view with subtotal column groups the chart + * cannot render. + */ + +import type { Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import { gotoBasic, restoreChart } from "./helpers"; + +async function save(page: Page): Promise> { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + return (await viewer.save()) as unknown as Record; + }); +} + +test.describe("Rollup-mode coercion on plugin restore", () => { + test("plugin swap to a flat-only chart coerces a retained split_rollup_mode", async ({ + page, + }) => { + await gotoBasic(page); + await restoreChart(page, { + plugin: "Datagrid", + group_by: ["Region"], + split_by: ["Category"], + columns: ["Sales"], + split_rollup_mode: "rollup", + }); + + expect((await save(page)).split_rollup_mode).toBe("rollup"); + await restoreChart(page, { plugin: "Y Bar" }); + const config = await save(page); + expect(config.plugin).toBe("Y Bar"); + expect(config.split_rollup_mode).toBe("flat"); + }); + + test("plugin swap to a flat-only chart coerces a retained group_rollup_mode", async ({ + page, + }) => { + await gotoBasic(page); + await restoreChart(page, { + plugin: "Datagrid", + group_by: ["Region"], + columns: ["Sales"], + group_rollup_mode: "rollup", + }); + + expect((await save(page)).group_rollup_mode).toBe("rollup"); + await restoreChart(page, { plugin: "Y Bar" }); + const config = await save(page); + expect(config.plugin).toBe("Y Bar"); + expect(config.group_rollup_mode).toBe("flat"); + }); + + test("same-plugin partial restore preserves a supported split_rollup_mode", async ({ + page, + }) => { + await gotoBasic(page); + await restoreChart(page, { + plugin: "Datagrid", + group_by: ["Region"], + split_by: ["Category"], + columns: ["Sales"], + split_rollup_mode: "rollup", + }); + + await restoreChart(page, { columns: ["Profit"] }); + const config = await save(page); + expect(config.plugin).toBe("Datagrid"); + expect(config.split_rollup_mode).toBe("rollup"); + }); + + test("explicitly restoring an unsupported split_rollup_mode with the swap is coerced", async ({ + page, + }) => { + await gotoBasic(page); + await restoreChart(page, { + plugin: "Y Bar", + group_by: ["Region"], + split_by: ["Category"], + columns: ["Sales"], + split_rollup_mode: "rollup", + }); + + const config = await save(page); + expect(config.plugin).toBe("Y Bar"); + expect(config.split_rollup_mode).toBe("flat"); + }); +}); diff --git a/rust/perspective-viewer/src/rust/components/settings_panel.rs b/rust/perspective-viewer/src/rust/components/settings_panel.rs index 9f630a188a..d916145ee2 100644 --- a/rust/perspective-viewer/src/rust/components/settings_panel.rs +++ b/rust/perspective-viewer/src/rust/components/settings_panel.rs @@ -217,6 +217,7 @@ pub fn SettingsPanel(props: &SettingsPanelProps) -> Html { update.set_update_column_defaults( &session_metadata, + &view_config, &view_config.columns, plugin_config, ); diff --git a/rust/perspective-viewer/src/rust/session.rs b/rust/perspective-viewer/src/rust/session.rs index 9e57275ed4..e7319bbfba 100644 --- a/rust/perspective-viewer/src/rust/session.rs +++ b/rust/perspective-viewer/src/rust/session.rs @@ -719,9 +719,11 @@ impl Session { config_static: &PluginStaticConfig, ) { use self::column_defaults_update::*; + let config = self.get_view_config(); config_update.set_update_column_defaults( &self.metadata(), - &self.get_view_config().columns, + &config, + &config.columns, config_static, ) } @@ -737,7 +739,11 @@ impl Session { config_static: &PluginStaticConfig, ) { use self::column_defaults_update::*; - config_update.set_update_rollup_defaults(&self.metadata(), config_static) + config_update.set_update_rollup_defaults( + &self.metadata(), + &self.get_view_config(), + config_static, + ) } /// Apply a `ViewConfigUpdate` to the live config — the ONLY view-config diff --git a/rust/perspective-viewer/src/rust/session/column_defaults_update.rs b/rust/perspective-viewer/src/rust/session/column_defaults_update.rs index 8a473c1faf..3d84db8227 100644 --- a/rust/perspective-viewer/src/rust/session/column_defaults_update.rs +++ b/rust/perspective-viewer/src/rust/session/column_defaults_update.rs @@ -20,11 +20,16 @@ use crate::config::PluginStaticConfig; #[extend::ext] pub impl ViewConfigUpdate { - /// Coerce this update's `group_rollup_mode` to one the plugin accepts - /// (`PluginStaticConfig::group_rollup_modes`, feature-filtered). An - /// absent mode counts as `Rollup` for the acceptance check, so a plugin - /// that only renders `flat` (Treemap / Sunburst) gets it stamped even - /// when the update never mentions rollup at all. + /// Coerce this update's `group_rollup_mode` / `split_rollup_mode` to one + /// the plugin accepts (`PluginStaticConfig::*_rollup_modes`, + /// feature-filtered). An absent mode counts as `current`'s committed mode + /// for the acceptance check — `ViewConfig::apply_update` KEEPS the + /// committed mode when the update's field is `None`, so acceptance must + /// be judged against the mode that will actually be in effect. A + /// hardcoded-default fallback here let a plugin swap silently retain a + /// mode the new plugin excludes (e.g. Datagrid's `split_rollup_mode: + /// "rollup"` surviving a `restore({plugin: "Y Bar"})` onto a flat-only + /// chart). /// /// Split out of [`Self::set_update_column_defaults`] so same-plugin /// restores can enforce the mode WITHOUT the column-defaulting below — @@ -33,6 +38,7 @@ pub impl ViewConfigUpdate { fn set_update_rollup_defaults( &mut self, metadata: &SessionMetadata, + current: &ViewConfig, config_static: &PluginStaticConfig, ) { let rollup_features = metadata @@ -44,7 +50,7 @@ pub impl ViewConfigUpdate { if !group_rollups.contains( self.group_rollup_mode .as_ref() - .unwrap_or(&GroupRollupMode::Rollup), + .unwrap_or(¤t.group_rollup_mode), ) { self.group_rollup_mode = group_rollups.first().cloned(); tracing::debug!( @@ -62,7 +68,7 @@ pub impl ViewConfigUpdate { if !split_rollups.contains( self.split_rollup_mode .as_ref() - .unwrap_or(&SplitRollupMode::Flat), + .unwrap_or(¤t.split_rollup_mode), ) { self.split_rollup_mode = split_rollups.first().cloned(); tracing::debug!( @@ -81,10 +87,11 @@ pub impl ViewConfigUpdate { fn set_update_column_defaults( &mut self, metadata: &SessionMetadata, + current: &ViewConfig, columns: &[Option], config_static: &PluginStaticConfig, ) { - self.set_update_rollup_defaults(metadata, config_static); + self.set_update_rollup_defaults(metadata, current, config_static); if let (None, Some(min_cols)) = (&self.columns, config_static.min_config_columns) { let names_len = config_static.config_column_names.len(); diff --git a/rust/perspective-viewer/src/rust/session/props.rs b/rust/perspective-viewer/src/rust/session/props.rs index 435d00453a..d2e4443d91 100644 --- a/rust/perspective-viewer/src/rust/session/props.rs +++ b/rust/perspective-viewer/src/rust/session/props.rs @@ -145,6 +145,7 @@ impl SessionProps { ) { config_update.set_update_column_defaults( &self.metadata, + &self.config, &self.all_columns().into_iter().map(Some).collect::>(), config_static, ) From 848e35de0803f4a99a1ec2444523c9c0d9c6aec4 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Sat, 15 Aug 2026 15:50:45 -0400 Subject: [PATCH 03/14] Fix `viewer-charts` type coercion bugs Signed-off-by: Andrew Stein --- .../charts/candlestick/candlestick-build.ts | 1 + .../src/ts/charts/candlestick/candlestick.ts | 7 +-- .../charts/common/category-axis-resolver.ts | 49 +++++++++++++++++-- .../src/ts/charts/heatmap/heatmap-build.ts | 8 ++- .../src/ts/charts/heatmap/heatmap-interact.ts | 10 +++- .../src/ts/charts/heatmap/heatmap.ts | 7 +-- .../src/ts/charts/series/series-build.ts | 1 + .../src/ts/charts/series/series-interact.ts | 15 +----- .../src/ts/charts/series/series.ts | 9 ++-- packages/viewer-charts/src/ts/layout/ticks.ts | 48 +++++++++++++----- .../test/ts/rollup-mode-coercion.spec.ts | 31 ++++++++++++ 11 files changed, 145 insertions(+), 41 deletions(-) diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts index 0e856e2c4c..3d0e713c81 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts @@ -281,6 +281,7 @@ export function buildCandlestickPipeline( numRows, groupBy.length, levelTypes, + axisMode, ); if (numCategories === 0) { return { diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts index f035137cc9..6279734d1d 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts @@ -212,9 +212,10 @@ export class CandlestickChart extends CategoricalYChart { const catIdx = this._candles.catIdx[idx]; const splitIdx = this._candles.splitIdx[idx]; - const groupByValues: (string | null)[] = this._rowPaths.map( - (level) => level.labels[catIdx] ?? null, - ); + const groupByValues: (string | number | null)[] = + this._categoryAxisMode === "numeric" && this._categoryPositions + ? [this._categoryPositions[catIdx] ?? null] + : this._rowPaths.map((level) => level.labels[catIdx] ?? null); const splitKey = this._splitPrefixes[splitIdx] ?? ""; const splitByValues = this._splitBy.length > 0 && splitKey !== "" diff --git a/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts index 4048772a29..2490b4e6c5 100644 --- a/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts +++ b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts @@ -203,15 +203,27 @@ export function synthesizeStringLevel( const seen = new Map(); seen.set("", 0); + const valueIdx = new Map(); + for (let r = 0; r < numRows; r++) { const isValid = valid ? !!((valid[r >> 3] >> (r & 7)) & 1) : true; + if (!isValid) { + indices[r] = 0; + continue; + } + const v = values[r] as number; - const label = formatLevelValue(v, isValid, levelType); - let dictIdx = seen.get(label); + let dictIdx = valueIdx.get(v); if (dictIdx === undefined) { - dictIdx = dictionary.length; - dictionary.push(label); - seen.set(label, dictIdx); + const label = formatLevelValue(v, true, levelType); + dictIdx = seen.get(label); + if (dictIdx === undefined) { + dictIdx = dictionary.length; + dictionary.push(label); + seen.set(label, dictIdx); + } + + valueIdx.set(v, dictIdx); } indices[r] = dictIdx; @@ -240,7 +252,34 @@ export function resolveCategoryAxis( numRows: number, groupByLen: number, levelTypes: string[] = [], + axisMode?: AxisMode, ): CategoryAxisResult { + if (axisMode?.mode === "numeric") { + const rp = columns.get("__ROW_PATH_0__"); + if (rp && rp.type !== "string" && rp.values) { + let rowOffset = 0; + const values = rp.values; + const valid = rp.valid; + while (rowOffset < numRows) { + const isValid = valid + ? !!((valid[rowOffset >> 3] >> (rowOffset & 7)) & 1) + : true; + const v = values[rowOffset] as number; + if (isValid && v != null && !Number.isNaN(v)) { + break; + } + + rowOffset++; + } + + return { + rowPaths: [], + numCategories: Math.max(0, numRows - rowOffset), + rowOffset, + }; + } + } + type RawLevel = { indices: Int32Array; dictionary: string[] }; const rawRowPaths: RawLevel[] = []; for (let n = 0; ; n++) { diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts index 726f6b7182..f7c9b2e441 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts @@ -128,7 +128,13 @@ export function buildHeatmapPipeline( rowPaths: xLevels, numCategories: numX, rowOffset, - } = resolveCategoryAxis(columns, numRows, groupBy.length, levelTypes); + } = resolveCategoryAxis( + columns, + numRows, + groupBy.length, + levelTypes, + xAxisMode, + ); // Numeric X domain: sourced from `__ROW_PATH_0__`'s raw values when // the single group_by is non-string. diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts index 116c531a7d..a38d429d52 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts @@ -215,6 +215,7 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { let layout: import("../../layout/plot-layout").PlotLayout | null; let xLevels: CategoricalLevel[]; let yLevels: CategoricalLevel[]; + let xPositions: Float64Array | null; let facetLabel: string | null = null; if (chart._hoveredFacetIdx >= 0) { @@ -226,6 +227,7 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { layout = facet.layout; xLevels = facet.pipeline.xLevels; yLevels = facet.pipeline.yLevels; + xPositions = facet.pipeline.xPositions; facetLabel = facet.label; } else { if (!chart._lastLayout) { @@ -235,6 +237,7 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { layout = chart._lastLayout; xLevels = chart._xLevels; yLevels = chart._yLevels; + xPositions = chart._xPositions; } const cell = chart._hoveredCell; @@ -249,7 +252,12 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { lines.push(facetLabel); } - const xPath = formatHierarchicalPath(xLevels, cell.xIdx); + const xPath = + chart._xAxisMode.mode === "numeric" && xPositions + ? chart.getColumnFormatter(chart._groupBy[0], "value")( + xPositions[cell.xIdx], + ) + : formatHierarchicalPath(xLevels, cell.xIdx); const yPath = formatHierarchicalPath(yLevels, cell.yIdx); if (xPath) { lines.push(xPath); diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts index 725af29309..490f675451 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts @@ -176,9 +176,10 @@ export class HeatmapChart extends AbstractChart { xIdx: number, yIdx: number, ): Promise { - const groupByValues: (string | null)[] = this._xLevels.map( - (level) => level.labels[xIdx] ?? null, - ); + const groupByValues: (string | number | null)[] = + this._xAxisMode.mode === "numeric" && this._xPositions + ? [this._xPositions[xIdx] ?? null] + : this._xLevels.map((level) => level.labels[xIdx] ?? null); const splitByValues: (string | null)[] = this._yLevels .slice(0, this._splitBy.length) .map((level) => level.labels[yIdx] ?? null); diff --git a/packages/viewer-charts/src/ts/charts/series/series-build.ts b/packages/viewer-charts/src/ts/charts/series/series-build.ts index 95c094e730..147a0d91ff 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-build.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -473,6 +473,7 @@ export function buildSeriesPipeline( numRows, groupBy.length, levelTypes, + axisMode, ); if (numCategories === 0) { diff --git a/packages/viewer-charts/src/ts/charts/series/series-interact.ts b/packages/viewer-charts/src/ts/charts/series/series-interact.ts index 4200c63d02..39c0be036a 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-interact.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-interact.ts @@ -628,19 +628,8 @@ export function formatBarCategoryPath( chart: SeriesChart, catIdx: number, ): string { - // Numeric category mode: resolve from the bar's xCenter (real data - // value) rather than the row-path label array, which is empty when - // the single group_by level is non-string. - if (chart._categoryAxisMode === "numeric" && chart._numericCategoryDomain) { - const bars = chart._bars; - let v: number | null = null; - for (let i = 0; i < bars.count; i++) { - if (bars.catIdx[i] === catIdx) { - v = bars.xCenter[i]; - break; - } - } - + if (chart._categoryAxisMode === "numeric" && chart._categoryPositions) { + const v = chart._categoryPositions[catIdx]; if (v == null) { return ""; } diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts index 1ddd2396bf..3ec96b5af8 100644 --- a/packages/viewer-charts/src/ts/charts/series/series.ts +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -514,9 +514,12 @@ export class SeriesChart extends CategoricalYChart { return; } - const groupByValues: (string | null)[] = this._rowPaths.map( - (level) => level.labels[b.catIdx] ?? null, - ); + const groupByValues: (string | number | null)[] = + this._categoryAxisMode === "numeric" && this._categoryPositions + ? [this._categoryPositions[b.catIdx] ?? null] + : this._rowPaths.map( + (level) => level.labels[b.catIdx] ?? null, + ); const splitKey = this._splitPrefixes[b.splitIdx] ?? ""; const splitByValues = this._splitBy.length > 0 && splitKey !== "" diff --git a/packages/viewer-charts/src/ts/layout/ticks.ts b/packages/viewer-charts/src/ts/layout/ticks.ts index 08eed8cfc4..8caa8db458 100644 --- a/packages/viewer-charts/src/ts/layout/ticks.ts +++ b/packages/viewer-charts/src/ts/layout/ticks.ts @@ -111,6 +111,30 @@ export function formatTickValue(val: number): string { return val.toFixed(2); } +/** + * Cached `Intl.DateTimeFormat` per option shape. `Date.prototype. + * toLocale*` constructs a fresh `DateTimeFormat` (plus its ICU + * backing) on EVERY call — ~30µs each — which turned per-row label + * synthesis over large pivots into a multi-second stall. A cached + * formatter's `format()` is ~1µs. Keyed by precision tier; the + * default locale is fixed for the lifetime of the worker, so entries + * never invalidate. + */ +const DATE_FORMAT_CACHE = new Map(); + +function cachedDateFormat( + key: string, + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormat { + let fmt = DATE_FORMAT_CACHE.get(key); + if (!fmt) { + fmt = new Intl.DateTimeFormat(undefined, options); + DATE_FORMAT_CACHE.set(key, fmt); + } + + return fmt; +} + /** * Format a timestamp (ms since epoch) as a human-readable date/time label. * Adapts precision based on the tick spacing. @@ -129,49 +153,49 @@ export function formatDateTickValue(val: number, stepMs?: number): string { if (stepMs >= DAY * 28) { // Monthly or longer — show year-month - return d.toLocaleDateString(undefined, { + return cachedDateFormat("ym", { year: "numeric", month: "short", - }); + }).format(d); } if (stepMs >= DAY) { // Daily — show month and day - return d.toLocaleDateString(undefined, { + return cachedDateFormat("md", { month: "short", day: "numeric", - }); + }).format(d); } if (stepMs >= HOUR) { // Hourly - return d.toLocaleString(undefined, { + return cachedDateFormat("mdh", { month: "short", day: "numeric", hour: "numeric", - }); + }).format(d); } if (stepMs >= MINUTE) { // Minutes - return d.toLocaleTimeString(undefined, { + return cachedDateFormat("hm", { hour: "numeric", minute: "2-digit", - }); + }).format(d); } // Sub-minute - return d.toLocaleTimeString(undefined, { + return cachedDateFormat("hms", { hour: "numeric", minute: "2-digit", second: "2-digit", - }); + }).format(d); } // Default: show date only - return d.toLocaleDateString(undefined, { + return cachedDateFormat("ymd", { year: "numeric", month: "short", day: "numeric", - }); + }).format(d); } diff --git a/packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts b/packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts index 7eccc50bb4..cbeb6c9f32 100644 --- a/packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts +++ b/packages/viewer-charts/test/ts/rollup-mode-coercion.spec.ts @@ -110,4 +110,35 @@ test.describe("Rollup-mode coercion on plugin restore", () => { expect(config.plugin).toBe("Y Bar"); expect(config.split_rollup_mode).toBe("flat"); }); + + test("fresh plugin-less panel keeps a configured group_rollup_mode", async ({ + page, + }) => { + await gotoBasic(page); + const config = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + const table = await viewer.getTable(); + const name = await table.get_name(); + for (const id of viewer.getPanelNames()) { + await viewer.removePanel(id); + } + + await viewer.restoreWorkspace({ + layout: { type: "tab-layout", tabs: ["p0"], selected: 0 }, + panels: { + p0: { + table: name, + group_by: ["Region", "State"], + columns: ["Sales", "Profit"], + group_rollup_mode: "flat", + }, + }, + }); + + return (await viewer.save()) as unknown as Record; + }); + + expect(config.plugin).toBe("Datagrid"); + expect(config.group_rollup_mode).toBe("flat"); + }); }); From a88f82af661d41411161b326ce6e94d13b8d8013 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Sat, 15 Aug 2026 15:57:36 -0400 Subject: [PATCH 04/14] Profile-driven datagrid scrolling fix Signed-off-by: Andrew Stein --- .../viewer-datagrid/src/css/regular_table.css | 4 + .../src/ts/event_handlers/edit_focus.ts | 112 ++++++ .../src/ts/event_handlers/focus.ts | 15 +- .../ts/event_handlers/keydown/edit_keydown.ts | 2 +- .../viewer-datagrid/src/ts/model/create.ts | 1 + .../viewer-datagrid/src/ts/plugin/activate.ts | 10 + .../src/ts/style_handlers/body.ts | 373 ++++++++++++------ .../src/ts/style_handlers/column_alignment.ts | 99 +++++ .../src/ts/style_handlers/column_header.ts | 2 - .../src/ts/style_handlers/focus.ts | 13 +- .../src/ts/style_handlers/group_header.ts | 2 - pnpm-lock.yaml | 4 +- pnpm-workspace.yaml | 2 +- 13 files changed, 506 insertions(+), 133 deletions(-) create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/column_alignment.ts diff --git a/packages/viewer-datagrid/src/css/regular_table.css b/packages/viewer-datagrid/src/css/regular_table.css index f8e1e32d96..ad279f50a8 100644 --- a/packages/viewer-datagrid/src/css/regular_table.css +++ b/packages/viewer-datagrid/src/css/regular_table.css @@ -433,6 +433,10 @@ regular-table table tbody td { cursor: pointer; } +regular-table table tbody td.psp-editable { + user-select: text; +} + regular-table table { font-size: 1em; user-select: none; diff --git a/packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts b/packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts new file mode 100644 index 0000000000..7f4435175a --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts @@ -0,0 +1,112 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { PRIVATE_PLUGIN_SYMBOL } from "../model/index.js"; +import { + type RegularTable, + type DatagridModel, + type ColumnsConfig, + get_psp_type, + isEditableMode, +} from "../types.js"; +import type { ColumnType } from "@perspective-dev/client"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +const LAST_EDITABLE: WeakMap = new WeakMap(); + +/** + * Whether a column of `type` holds text-editable cells (booleans toggle by + * click and "link"-formatted strings navigate, so neither takes a caret). + */ +export function is_type_text_editable( + type: ColumnType | undefined, + format?: string, +): boolean { + return type !== "boolean" && !(type === "string" && format === "link"); +} + +function is_cell_text_editable( + model: DatagridModel, + table: RegularTable, + td: HTMLElement, +): boolean { + const meta = table.getMeta(td); + if ( + meta?.type !== "body" || + !isEditableMode(model, undefined as unknown as HTMLPerspectiveViewerElement) + ) { + return false; + } + + if (!model._is_editable[meta.x]) { + return false; + } + + const type = get_psp_type(model, meta); + const plugins: ColumnsConfig = + (table as any)[PRIVATE_PLUGIN_SYMBOL] || {}; + const column_name = meta.column_header?.[model._config.split_by.length]; + const format = column_name + ? plugins[column_name.toString()]?.format + : undefined; + + return is_type_text_editable(type, format); +} + +export function ensure_cell_editable( + table: RegularTable, + td: HTMLElement, +): void { + const prev = LAST_EDITABLE.get(table); + if (prev !== td) { + prev?.removeAttribute("contenteditable"); + td.setAttribute("contenteditable", "true"); + LAST_EDITABLE.set(table, td); + } +} + +export function release_cell_editable( + table: RegularTable, + td: HTMLElement, +): void { + td.removeAttribute("contenteditable"); + if (LAST_EDITABLE.get(table) === td) { + LAST_EDITABLE.delete(table); + } +} + +export function createEditPointerdownListener( + model: DatagridModel, + table: RegularTable, + _viewer: HTMLPerspectiveViewerElement, +): EventListener { + return (event: Event): void => { + const target = event.target as HTMLElement; + if (target?.tagName !== "TD") { + return; + } + + if (is_cell_text_editable(model, table, target)) { + ensure_cell_editable(table, target); + } + }; +} + +export function ensure_editable_for_focus( + model: DatagridModel, + table: RegularTable, + td: HTMLElement, +): void { + if (is_cell_text_editable(model, table, td)) { + ensure_cell_editable(table, td); + } +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/focus.ts b/packages/viewer-datagrid/src/ts/event_handlers/focus.ts index 821ac81712..fc944d9b69 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/focus.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/focus.ts @@ -11,6 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { write_cell } from "./click/edit_click.js"; +import { + release_cell_editable, + ensure_editable_for_focus, +} from "./edit_focus.js"; import type { RegularTable, DatagridModel, @@ -28,8 +32,9 @@ export function createFocusoutListener( ): EventListener { return (event: Event): void => { const focusEvent = event as FocusEvent; + const target = focusEvent.target as HTMLElement; + let refocused = false; if (isEditableMode(model, viewer) && selected_position_map.has(table)) { - const target = focusEvent.target as HTMLElement; target.classList.remove("psp-error"); const selectedPosition = selected_position_map.get(table)!; selected_position_map.delete(table); @@ -38,14 +43,19 @@ export function createFocusoutListener( target.textContent = selectedPosition.content || ""; target.classList.add("psp-error"); target.focus(); + refocused = true; } } } + + if (!refocused && target?.hasAttribute?.("contenteditable")) { + release_cell_editable(table, target); + } }; } export function createFocusinListener( - _model: DatagridModel, + model: DatagridModel, table: RegularTable, _viewer: HTMLPerspectiveViewerElement, selected_position_map: SelectedPositionMap, @@ -53,6 +63,7 @@ export function createFocusinListener( return (event: Event): void => { const focusEvent = event as FocusEvent; const target = focusEvent.target as HTMLElement; + ensure_editable_for_focus(model, table, target); const meta = table.getMeta(target); if (meta?.type === "body") { const new_state: SelectedPosition = { diff --git a/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts b/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts index 506f93fe09..63a88a9635 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts @@ -116,7 +116,7 @@ const moveSelection = lock(async function ( let x = meta.x0 + dx, y = meta.y0 + dy; while ( - !focusSelectedCell(table, selected_position_map) && + !focusSelectedCell(table, selected_position_map, model) && x >= xmin && x < xmax && y >= ymin && diff --git a/packages/viewer-datagrid/src/ts/model/create.ts b/packages/viewer-datagrid/src/ts/model/create.ts index dc98dceec2..306be4d513 100644 --- a/packages/viewer-datagrid/src/ts/model/create.ts +++ b/packages/viewer-datagrid/src/ts/model/create.ts @@ -328,6 +328,7 @@ export async function createModel( | "horizontal" | "vertical" | "none", + column_classes: true, }, ); diff --git a/packages/viewer-datagrid/src/ts/plugin/activate.ts b/packages/viewer-datagrid/src/ts/plugin/activate.ts index 0b82e9b8b8..36732e8a51 100644 --- a/packages/viewer-datagrid/src/ts/plugin/activate.ts +++ b/packages/viewer-datagrid/src/ts/plugin/activate.ts @@ -21,6 +21,7 @@ import { createFocusinListener, createFocusoutListener, } from "../event_handlers/focus.js"; +import { createEditPointerdownListener } from "../event_handlers/edit_focus.js"; import { createKeydownListener, createEditClickListener, @@ -203,6 +204,15 @@ export async function activate( createEditClickListener(this.model, this.regular_table, viewer), ); + this.regular_table.addEventListener( + "pointerdown", + createEditPointerdownListener( + this.model, + this.regular_table, + viewer, + ), + ); + this.regular_table.addEventListener( "focusin", createFocusinListener( diff --git a/packages/viewer-datagrid/src/ts/style_handlers/body.ts b/packages/viewer-datagrid/src/ts/style_handlers/body.ts index 18d42ec213..07c27cff83 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/body.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/body.ts @@ -15,16 +15,68 @@ import { RegularTableElement } from "regular-table"; import { type DatagridModel, type ColumnsConfig, + type ColumnConfig, get_psp_type, } from "../types.js"; +import type { ColumnType } from "@perspective-dev/client"; import { cell_style_numeric } from "./table_cell/numeric.js"; import { cell_style_string } from "./table_cell/string.js"; import { cell_style_datetime } from "./table_cell/datetime.js"; import { cell_style_boolean } from "./table_cell/boolean.js"; import { cell_style_row_header } from "./table_cell/row_header.js"; +import { is_type_text_editable } from "../event_handlers/edit_focus.js"; +import { + sync_column_alignment, + type ColumnAlignment, +} from "./column_alignment.js"; import { CollectedCell } from "./types.js"; +const B_VALUE_NULL = 1; +const B_USER_NULL = 2; +const B_HIDDEN = 4; +const B_POS = 8; +const B_NEG = 16; +const B_SEL_EXACT = 32; +const B_SEL_SUB = 64; + +interface ColState { + plugin: ColumnConfig | undefined; + type: ColumnType | undefined; + is_numeric: boolean; + is_rollup_col: boolean; + n_split_levels: number | undefined; + column_name: string | undefined; + mods: number; + value_styled: boolean; + text_editable: boolean; + boolean_editable: boolean; +} + +interface StyleMemo { + plugin: ColumnConfig | undefined; + type: string | undefined; + mods: number; + theme: unknown; + bits: number; + value: unknown; +} + +function row_header_depth(row_header: unknown[] | undefined): number { + if (!row_header) { + return 0; + } + + let n = 0; + for (let i = 0; i < row_header.length; i++) { + if (row_header[i] !== undefined) { + n++; + } + } + + return n; +} + /** * Apply styles to all body cells in a single pass. */ @@ -44,73 +96,199 @@ export function applyBodyCellStyles( model._config.group_rollup_mode === "flat", ); - for (const { element: td, metadata, isHeader } of cells) { - const column_name = - metadata.column_header?.[model._config.split_by.length]; + const is_rollup_mode = model._config.group_rollup_mode === "rollup"; + const n_split_by = model._config.split_by.length; + const group_by_len = model._config.group_by.length; + const theme = model._pos_bg_color; + const menu_col = model._column_settings_selected_column; + const col_states: Map = new Map(); + const col_state = ( + key: number, + meta_x: number | undefined, + column_name: string | undefined, + type: ColumnType | undefined, + ): ColState => { + let state = col_states.get(key); + if (state) { + return state; + } + + const plugin = column_name + ? plugins[column_name.toString()] + : undefined; - // Mark subtotal/grand-total column cells (`split_rollup_mode: - // "rollup"`) so themes can emphasize them like row-tree totals. - // `x` is absent on row-header cell metadata. - const meta_x = (metadata as { x?: number }).x; const n_split_levels = meta_x === undefined ? undefined : model._column_paths[meta_x]?.split("|").length - 1; const is_rollup_col = - model._config.split_by.length > 0 && + n_split_by > 0 && n_split_levels !== undefined && - n_split_levels < model._config.split_by.length; + n_split_levels < n_split_by; - td.classList.toggle( - "psp-split-total", - is_rollup_col && n_split_levels === 0, - ); + const is_numeric = type === "integer" || type === "float"; + const value_styled = + (is_numeric && + (plugin?.number_bg_mode === "gradient" || + plugin?.number_bg_mode === "pulse")) || + (type === "string" && + (plugin?.string_color_mode === "series" || + plugin?.format === "link")); - td.classList.toggle( - "psp-split-subtotal", - is_rollup_col && n_split_levels! > 0, - ); + const editable_col = isEditable && !!model._is_editable[meta_x ?? -1]; + state = { + plugin, + type, + is_numeric, + is_rollup_col, + n_split_levels, + column_name: column_name?.toString(), + mods: + (isSettingsOpen ? 1 : 0) | + (isSelectable ? 2 : 0) | + (editable_col ? 4 : 0) | + (is_rollup_col ? 8 : 0) | + (n_split_levels === 0 ? 16 : 0) | + (column_name === menu_col ? 32 : 0) | + (menu_col ? 64 : 0) | + (is_rollup_mode ? 128 : 0) | + (plugin?.number_fg_mode === "bar" ? 256 : 0) | + (plugin?.number_fg_mode === "label-bar" ? 512 : 0), + value_styled, + text_editable: + editable_col && is_type_text_editable(type, plugin?.format), + boolean_editable: editable_col && type === "boolean", + }; + + col_states.set(key, state); + return state; + }; + + const alignments: Map = new Map(); + for (const { element: td, metadata, isHeader } of cells) { + const column_name = + metadata.column_header?.[n_split_by]?.toString?.() ?? + (metadata.column_header?.[n_split_by] as string | undefined); + + const meta_x = (metadata as { x?: number }).x; const type = get_psp_type(model, metadata); - const plugin = column_name - ? plugins[column_name.toString()] - : undefined; + const key = + meta_x ?? + -1 - ((metadata as { row_header_x?: number }).row_header_x ?? 0); + const c = col_state(key, meta_x, column_name, type); - const is_numeric = type === "integer" || type === "float"; + const size_key = (metadata as { size_key?: number }).size_key; + if (size_key !== undefined && !alignments.has(size_key)) { + alignments.set( + size_key, + !isHeader && c.is_numeric ? "right" : "left", + ); + } - // Calculate aggregate depth visibility - // @ts-ignore - metadata._is_hidden_by_aggregate_depth = - model._config.group_rollup_mode === "rollup" && - ((x?: number) => - x === 0 || x === undefined + const hidden = + is_rollup_mode && + ((d: number) => + d === 0 ? false - : x - 1 < - Math.min( - model._config.group_by.length, - plugin?.aggregate_depth || 0, - ))( - (metadata.row_header as unknown[] | undefined)?.filter( - (x) => x !== undefined, - )?.length, + : d - 1 < + Math.min(group_by_len, c.plugin?.aggregate_depth || 0))( + row_header_depth(metadata.row_header as unknown[] | undefined), ); + // @ts-ignore + metadata._is_hidden_by_aggregate_depth = hidden; + + let bits = + (metadata.value === null ? B_VALUE_NULL : 0) | + (metadata.user === null ? B_USER_NULL : 0) | + (hidden ? B_HIDDEN : 0); + + if (c.is_numeric || c.type === "boolean") { + const user = metadata.user as number | boolean | null | undefined; + bits |= + (user === true || (user as number) > 0 ? B_POS : 0) | + (user === false || (user as number) < 0 ? B_NEG : 0); + } + + let isExact = false, + isSub = false; + if (isSelectable && selectedId) { + const id = model._ids[(metadata.y ?? 0) - (metadata.y0 ?? 0)]; + let key_match = true; + for (let i = 0; i < selectedId.length; i++) { + if (selectedId[i] !== id[i]) { + key_match = false; + break; + } + } + + isExact = id.length === selectedId.length && key_match; + isSub = id.length !== selectedId.length && key_match; + bits |= (isExact ? B_SEL_EXACT : 0) | (isSub ? B_SEL_SUB : 0); + } + + if (!isHeader) { + // @ts-ignore + const memo: StyleMemo | undefined = metadata.__psp_style_memo; + if ( + memo !== undefined && + memo.plugin === c.plugin && + memo.type === c.type && + memo.mods === c.mods && + memo.theme === theme && + memo.bits === bits && + (!c.value_styled || memo.value === metadata.user) + ) { + continue; + } + + if (memo !== undefined) { + memo.plugin = c.plugin; + memo.type = c.type; + memo.mods = c.mods; + memo.theme = theme; + memo.bits = bits; + memo.value = c.value_styled ? metadata.user : undefined; + } else { + // @ts-ignore + metadata.__psp_style_memo = { + plugin: c.plugin, + type: c.type, + mods: c.mods, + theme, + bits, + value: c.value_styled ? metadata.user : undefined, + } satisfies StyleMemo; + } + } + + td.classList.toggle( + "psp-split-total", + c.is_rollup_col && c.n_split_levels === 0, + ); + + td.classList.toggle( + "psp-split-subtotal", + c.is_rollup_col && c.n_split_levels! > 0, + ); + // Apply type-specific cell styling - if (is_numeric) { + if (c.is_numeric) { cell_style_numeric( model, - plugin as any, + c.plugin as any, td, metadata as any, isSettingsOpen, ); - } else if (type === "boolean") { - cell_style_boolean(model, plugin, td, metadata as any); - } else if (type === "string") { - cell_style_string(model, plugin as any, td, metadata as any); - } else if (type === "date" || type === "datetime") { - cell_style_datetime(model, plugin as any, td, metadata); + } else if (c.type === "boolean") { + cell_style_boolean(model, c.plugin, td, metadata as any); + } else if (c.type === "string") { + cell_style_string(model, c.plugin as any, td, metadata as any); + } else if (c.type === "date" || c.type === "datetime") { + cell_style_datetime(model, c.plugin as any, td, metadata); } else { td.style.backgroundColor = ""; td.style.color = ""; @@ -119,29 +297,23 @@ export function applyBodyCellStyles( // Apply common cell classes td.classList.toggle( "psp-bool-type", - type === "boolean" && metadata.user !== null, + c.type === "boolean" && metadata.user !== null, ); td.classList.toggle("psp-null", metadata.value === null); - td.classList.toggle("psp-align-right", !isHeader && is_numeric); - td.classList.toggle("psp-align-left", isHeader || !is_numeric); - if (model._column_settings_selected_column) { - td.classList.toggle( - "psp-menu-open", - column_name === model._column_settings_selected_column, - ); - } else { - td.classList.toggle("psp-menu-open", false); - } + td.classList.toggle( + "psp-menu-open", + !!menu_col && column_name === menu_col, + ); td.classList.toggle( "psp-color-mode-bar", - plugin?.number_fg_mode === "bar" && is_numeric, + c.plugin?.number_fg_mode === "bar" && c.is_numeric, ); td.classList.toggle( "psp-color-mode-label-bar", - plugin?.number_fg_mode === "label-bar" && is_numeric, + c.plugin?.number_fg_mode === "label-bar" && c.is_numeric, ); // Apply row header styling @@ -158,78 +330,35 @@ export function applyBodyCellStyles( if (isSelectable) { if (!selectedId) { td.classList.toggle("psp-select-region", false); + } else if ( + isHeader && + metadata.type === "row_header" && + metadata.row_header_x !== undefined && + metadata.row_header_x < + model._ids[(metadata.y ?? 0) - (metadata.y0 ?? 0)].length + ) { + td.classList.toggle("psp-select-region", false); } else { - const id = model._ids[(metadata.y ?? 0) - (metadata.y0 ?? 0)]; - const key_match = selectedId.reduce( - (agg, x, i) => agg && x === id[i], - true, - ); - - const isExact = id.length === selectedId.length && key_match; - const isSub = id.length !== selectedId.length && key_match; - - if (isHeader) { - // A row-header `` is "inert" when its level lies - // within the row's path depth — those cells are the - // merged/rowspan'd group headers whose metadata row is - // merely the first row of their span. Compare indices, - // not values: a falsy group key (0, "", false, null) - // is still a real path segment. - if ( - metadata.type === "row_header" && - metadata.row_header_x !== undefined && - metadata.row_header_x < id.length - ) { - td.classList.toggle("psp-select-region", false); - } else { - td.classList.toggle("psp-select-region", isExact); - td.classList.toggle( - "psp-select-region-inactive", - isSub, - ); - } - } else { - td.classList.toggle("psp-select-region", isExact); - td.classList.toggle("psp-select-region-inactive", isSub); - } + td.classList.toggle("psp-select-region", isExact); + td.classList.toggle("psp-select-region-inactive", isSub); } - // } else if ( - // model._edit_mode === "READ_ONLY" || - // model._edit_mode === "EDIT" - // ) { - // td.classList.toggle("psp-select-region", false); } - // Apply editable styling (if editable) if (!isHeader && metadata.type === "body") { - if (isEditable && model._is_editable[metadata.x]) { - const col_name = - metadata.column_header?.[model._config.split_by.length]; - const col_name_str = col_name?.toString(); - if ( - col_name_str && - type === "string" && - plugins[col_name_str]?.format === "link" - ) { - td.toggleAttribute("contenteditable", false); - td.classList.toggle("boolean-editable", false); - } else if (type === "boolean") { - td.toggleAttribute("contenteditable", false); - td.classList.toggle( - "boolean-editable", - (metadata as { user?: unknown }).user !== null, - ); - } else { - if (isEditable !== td.hasAttribute("contenteditable")) { - td.toggleAttribute("contenteditable", isEditable); - } - - td.classList.toggle("boolean-editable", false); - } - } else { - td.toggleAttribute("contenteditable", false); - td.classList.toggle("boolean-editable", false); + td.classList.toggle("psp-editable", c.text_editable); + if (c.text_editable) { + td.setAttribute("tabindex", "-1"); + } else if (td.hasAttribute("tabindex")) { + td.removeAttribute("tabindex"); } + + td.classList.toggle( + "boolean-editable", + c.boolean_editable && + (metadata as { user?: unknown }).user !== null, + ); } } + + sync_column_alignment(regularTable, alignments); } diff --git a/packages/viewer-datagrid/src/ts/style_handlers/column_alignment.ts b/packages/viewer-datagrid/src/ts/style_handlers/column_alignment.ts new file mode 100644 index 0000000000..097cd1f560 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/column_alignment.ts @@ -0,0 +1,99 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { RegularTableElement } from "regular-table"; + +export type ColumnAlignment = "left" | "right"; + +interface AlignmentSheetState { + table: Element | undefined; + scope: string | undefined; + sheet: CSSStyleSheet; + rules: Map; +} + +const STATE: WeakMap = new WeakMap(); + +/** + * Column alignment via `regular-table`'s dedicated column classes + * (`setDataListener()`'s `column_classes` option): one generated rule per + * visible column `size_key` targeting `td.rt-col-{k}, th.rt-col-{k}`, + * instead of a per-cell alignment class on every cell of every draw. + */ +export function sync_column_alignment( + regularTable: RegularTableElement, + wanted: Map, +): void { + const root = regularTable.getRootNode() as { + adoptedStyleSheets?: CSSStyleSheet[]; + }; + + if (!root || !root.adoptedStyleSheets) { + return; + } + + let state = STATE.get(regularTable); + if (!state) { + state = { + table: undefined, + scope: undefined, + sheet: new CSSStyleSheet(), + rules: new Map(), + }; + + STATE.set(regularTable, state); + } + + const table = regularTable.children[0]; + if (!table) { + return; + } + + if (state.table !== table) { + state.table = table; + state.scope = Array.from(table.classList).find((x) => + x.startsWith("rt-scope-"), + ); + + state.sheet.replaceSync(""); + state.rules.clear(); + } + + if (!state.scope) { + return; + } + + if (!root.adoptedStyleSheets.includes(state.sheet)) { + root.adoptedStyleSheets = [...root.adoptedStyleSheets, state.sheet]; + } + + for (const [size_key, align] of wanted) { + let entry = state.rules.get(size_key); + if (entry === undefined) { + const index = state.sheet.cssRules.length; + state.sheet.insertRule( + `.${state.scope} td.rt-col-${size_key}, .${state.scope} th.rt-col-${size_key}{text-align:${align}}`, + index, + ); + + entry = { + rule: state.sheet.cssRules[index] as CSSStyleRule, + last: align, + }; + + state.rules.set(size_key, entry); + } else if (entry.last !== align) { + entry.rule.style.textAlign = align; + entry.last = align; + } + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts b/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts index 2d6f4b5cb5..1f98bc4f67 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts @@ -198,8 +198,6 @@ export function styleColumnHeaderRow( const is_date = type === "date"; const is_datetime = type === "datetime"; - td.classList.toggle("psp-align-right", is_numeric); - td.classList.toggle("psp-align-left", !is_numeric); td.classList.toggle( "psp-menu-enabled", (is_string || is_numeric || is_date || is_datetime) && diff --git a/packages/viewer-datagrid/src/ts/style_handlers/focus.ts b/packages/viewer-datagrid/src/ts/style_handlers/focus.ts index 1770bc35c9..71138f1280 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/focus.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/focus.ts @@ -12,6 +12,7 @@ import { RegularTableElement } from "regular-table"; import type { DatagridModel, SelectedPositionMap } from "../types.js"; +import { ensure_editable_for_focus } from "../event_handlers/edit_focus.js"; import { CollectedCell } from "./types.js"; /** @@ -19,7 +20,7 @@ import { CollectedCell } from "./types.js"; * Optimized to use collected cells instead of querySelectorAll. */ export function applyFocusStyle( - _model: DatagridModel, + model: DatagridModel, cells: CollectedCell[], regularTable: RegularTableElement, selectedPositionMap: SelectedPositionMap, @@ -35,6 +36,7 @@ export function applyFocusStyle( metadata.y === selected_position.y ) { if (host.activeElement !== td) { + ensure_editable_for_focus(model, regularTable, td); td.focus({ preventScroll: true }); } @@ -62,6 +64,7 @@ export function applyFocusStyle( export function focusSelectedCell( regularTable: RegularTableElement, selectedPositionMap: SelectedPositionMap, + model?: DatagridModel, ): boolean { const selected_position = selectedPositionMap.get(regularTable); if (!selected_position) { @@ -81,6 +84,14 @@ export function focusSelectedCell( metadata.y === selected_position.y ) { if (host.activeElement !== cell) { + if (model) { + ensure_editable_for_focus( + model, + regularTable, + cell as HTMLElement, + ); + } + (cell as HTMLElement).focus({ preventScroll: true }); } diff --git a/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts b/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts index f31b607405..a2f15d8eac 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts @@ -52,8 +52,6 @@ export function applyGroupHeaderStyles( } td.style.backgroundColor = ""; - td.classList.toggle("psp-align-right", false); - td.classList.toggle("psp-align-left", false); td.classList.toggle("psp-header-group", true); td.classList.toggle("psp-header-leaf", false); td.classList.toggle( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d22e66bfa8..46b145210a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,8 +124,8 @@ catalogs: specifier: '=0.6.1' version: 0.6.1 regular-table: - specifier: '=0.8.6' - version: 0.8.6 + specifier: '=0.9.0' + version: 0.9.0 stoppable: specifier: '=1.1.0' version: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5f4b59678e..ea490093de 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,7 +37,7 @@ catalog: "react-dom": ">17 <20" "react": ">17 <20" "regular-layout": "=0.6.1" - "regular-table": "=0.8.6" + "regular-table": "=0.9.0" "stoppable": "=1.1.0" "ws": "^8.17.0" From 4f337a328e81cbd069d16a8be048dae402d51b7c Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Sat, 15 Aug 2026 16:01:28 -0400 Subject: [PATCH 05/14] Virtual Server window column fixes Signed-off-by: Andrew Stein --- .../rust/virtual_server/generic_sql_model.rs | 4 +- .../generic_sql_model/table_make_view.rs | 98 +++++++++++++---- .../virtual_server/generic_sql_model/tests.rs | 64 +++++------ .../src/rust/generic_sql_model.rs | 9 +- .../src/ts/virtual_servers/clickhouse.ts | 13 ++- .../src/ts/virtual_servers/duckdb.ts | 13 ++- rust/perspective-js/test/js/duckdb/setup.js | 15 +++ .../test/js/duckdb/windows.spec.js | 104 ++++++++++++++++++ rust/perspective-js/test/js/windows.spec.js | 35 ++++++ .../perspective/virtual_servers/clickhouse.py | 4 +- .../perspective/virtual_servers/duckdb.py | 4 +- .../src/server/generic_sql_model.rs | 25 +++-- .../cpp/perspective/src/cpp/window.cpp | 62 ++++++++--- 13 files changed, 367 insertions(+), 83 deletions(-) diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs index 2f1c95f1f0..553baa340e 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs @@ -249,6 +249,7 @@ impl GenericSQLVirtualServerModel { /// * `view_id` - The identifier for the new view. /// * `config` - The view configuration specifying columns, group_by, /// split_by, etc. + /// * `schema` - The schema of the source table (column names to types). /// /// # Returns /// SQL: `CREATE TABLE {view_id} AS (...)` @@ -257,8 +258,9 @@ impl GenericSQLVirtualServerModel { table_id: &str, view_id: &str, config: &ViewConfig, + schema: &IndexMap, ) -> GenericSQLResult { - let ctx = ViewQueryContext::new(self, table_id, config)?; + let ctx = ViewQueryContext::new(self, table_id, config, schema)?; let query = ctx.build_query(); let template = self.0.create_entity.as_deref().unwrap_or("TABLE"); Ok(format!("CREATE {} {} AS ({})", template, view_id, query)) diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs index 87f0327349..824a2567e9 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs @@ -10,11 +10,14 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +use indexmap::IndexMap; + use super::GenericSQLError; use crate::config::{ Aggregate, Filter, FilterTerm, GroupRollupMode, Scalar, Sort, SortDir, SplitRollupMode, ViewConfig, WindowFrame, WindowSortDir, WindowSpec, }; +use crate::proto::ColumnType; fn aggregate_to_string(agg: &Aggregate) -> String { match agg { @@ -58,7 +61,7 @@ enum QueryOrientation { TotalPivoted, } -fn window_over_clause(w: &WindowSpec, frame: Option<&str>) -> String { +fn window_over_clause(w: &WindowSpec, frame: Option<&str>, order_expr: Option<&str>) -> String { let mut parts: Vec = Vec::new(); if !w.partition_by.is_empty() { parts.push(format!( @@ -78,14 +81,16 @@ fn window_over_clause(w: &WindowSpec, frame: Option<&str>) -> String { // natural row order - `rowid`, the same identity unsorted view results // are already ordered by. match &w.order_by { - Some(order_by) => parts.push(format!( - "ORDER BY \"{}\" {} NULLS FIRST", - quote_ident(&order_by.0), - match order_by.1 { + Some(order_by) => { + let key = match order_expr { + Some(expr) => expr.to_string(), + None => format!("\"{}\"", quote_ident(&order_by.0)), + }; + parts.push(format!("ORDER BY {} {} NULLS FIRST", key, match order_by.1 { WindowSortDir::Asc => "ASC", WindowSortDir::Desc => "DESC", - } - )), + })) + }, None => parts.push("ORDER BY rowid ASC".to_string()), } if let Some(f) = frame { @@ -112,8 +117,11 @@ fn window_frame_sql(frame: Option<&WindowFrame>) -> String { /// One `WindowSpec` as a SQL window-function expression - the 1:1 `OVER` /// mapping that keeps hot-tier and virtual-server semantics interchangeable /// (WINDOW_FUNCTIONS_PLAN Phase 5). `resolve` inlines expression-alias -/// sources. `ema` is recursive and has no SQL window equivalent. -fn window_sql(w: &WindowSpec, resolve: &dyn Fn(&str) -> String) -> Result { +fn window_sql( + w: &WindowSpec, + resolve: &dyn Fn(&str) -> String, + order_type: Option, +) -> Result { // `range` frame interval arithmetic is defined on the order key's // units - the natural (`rowid`) fallback is meaningless for it, so an // explicit `order_by` is required (mirrors the engine's validation). @@ -123,6 +131,18 @@ fn window_sql(w: &WindowSpec, resolve: &dyn Fn(&str) -> String) -> Result { + let quoted = format!("\"{}\"", quote_ident(&order_by.0)); + match order_type { + Some(ColumnType::Date) => Some(format!("({} - DATE '1970-01-01')", quoted)), + Some(ColumnType::Datetime) => Some(format!("epoch_ms({})", quoted)), + _ => None, + } + }, + _ => None, + }; + let src = resolve(&w.column); let op = w.aggregate.as_str(); if matches!( @@ -146,7 +166,7 @@ fn window_sql(w: &WindowSpec, resolve: &dyn Fn(&str) -> String) -> Result String) -> Result String) -> Result { let frame = window_frame_sql(w.frame.as_ref()); @@ -171,13 +195,13 @@ fn window_sql(w: &WindowSpec, resolve: &dyn Fn(&str) -> String) -> Result Ok(format!( "ntile({}) OVER ({})", w.offset.unwrap_or(1), - window_over_clause(w, None) + window_over_clause(w, None, None) )), // `diff` and `rate` are Perspective's, not any SQL dialect's - they // are synthesized here so a config authored against the engine keeps @@ -187,7 +211,7 @@ fn window_sql(w: &WindowSpec, resolve: &dyn Fn(&str) -> String) -> Result { let Some(order_by) = &w.order_by else { @@ -206,8 +230,13 @@ fn window_sql(w: &WindowSpec, resolve: &dyn Fn(&str) -> String) -> Result { impl<'a> ViewQueryContext<'a> { /// Creates a new query context by resolving expressions, the grouping /// function, and row-path aliases from the given model and config. + /// `schema` types the table's columns for window order-key + /// linearization; an empty map degrades to untyped emission. pub(crate) fn new( model: &'a super::GenericSQLVirtualServerModel, table: &'a str, config: &'a ViewConfig, + schema: &IndexMap, ) -> Result { let expressions = &config.expressions.0; let col_name_resolve = |col: &str| -> String { @@ -308,15 +340,25 @@ impl<'a> ViewQueryContext<'a> { windows.sort_by_key(|(name, _)| name.as_str()); let mut selects = Vec::with_capacity(windows.len()); for (name, w) in windows { + let order_type = w + .order_by + .as_ref() + .and_then(|order_by| schema.get(&order_by.0)) + .copied(); + selects.push(format!( "{} AS \"{}\"", - window_sql(w, &col_name_resolve)?, + window_sql(w, &col_name_resolve, order_type)?, quote_ident(name) )); } + // `rowid` is a virtual column bound only on base tables - it + // does not survive `SELECT *` into the sub-select, so the + // natural row order is re-exported under an internal alias for + // the outer query's `ORDER BY` (see [`Self::natural_order_col`]). format!( - "(SELECT *, {} FROM {}) AS __PSP_WINDOW_SRC__", + "(SELECT *, rowid AS __PSP_ROWID__, {} FROM {}) AS __PSP_WINDOW_SRC__", selects.join(", "), table ) @@ -602,7 +644,7 @@ impl<'a> ViewQueryContext<'a> { && self.config.group_rollup_mode != GroupRollupMode::Total { let default_order = if self.config.split_by.is_empty() { - "rowid" + self.natural_order_col() } else { "__ROW_NUM__" }; @@ -900,9 +942,21 @@ impl<'a> ViewQueryContext<'a> { } } + /// The natural row-order column as visible to clauses that select `FROM + /// {from_expr}` - the base table's virtual `rowid` directly, or its + /// re-export when windows wrap the table in a sub-select (through which + /// `rowid` does not propagate). + fn natural_order_col(&self) -> &'static str { + if self.config.windows.is_empty() { + "rowid" + } else { + "__PSP_ROWID__" + } + } + /// Builds the `ORDER BY` expression for the `ROW_NUMBER()` window /// function used inside `PIVOT` queries. Uses sort config if available, - /// otherwise falls back to `rowid`. + /// otherwise falls back to the natural row order. fn pivot_row_num_order(&self) -> String { let sort_exprs: Vec = self .config @@ -913,7 +967,7 @@ impl<'a> ViewQueryContext<'a> { .collect(); if sort_exprs.is_empty() { - "rowid".to_string() + self.natural_order_col().to_string() } else { sort_exprs.join(", ") } diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs index f17447868a..a5257f616a 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs @@ -56,7 +56,7 @@ fn test_table_make_view_simple() { let mut config = ViewConfig::default(); config.columns = vec![Some("col1".to_string()), Some("col2".to_string())]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.starts_with("CREATE TABLE dest_view AS")); @@ -71,7 +71,7 @@ fn test_table_make_view_with_group_by() { config.columns = vec![Some("value".to_string())]; config.group_by = vec!["category".to_string()]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("GROUP BY ROLLUP")); @@ -87,7 +87,7 @@ fn test_table_make_view_with_group_by_and_split_by() { config.group_by = vec!["category".to_string()]; config.split_by = vec!["quarter".to_string()]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("GROUP BY ROLLUP"), "expected ROLLUP: {}", sql); @@ -119,7 +119,7 @@ fn test_table_make_view_with_sort_group_by_and_split_by() { )]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("__SORT_0__"), "expected __SORT_0__: {}", sql); @@ -156,7 +156,7 @@ fn test_table_make_view_with_sort_multi_group_by_and_split_by() { )]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -191,7 +191,7 @@ fn test_table_make_view_with_sort_and_group_by_no_split_by() { )]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -221,7 +221,7 @@ fn test_table_make_view_col_sort_excludes_row_order_by() { )]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -263,7 +263,7 @@ fn test_table_make_view_mixed_row_and_col_sort() { ]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -287,7 +287,7 @@ fn test_table_make_view_pivoted_with_sort() { config.split_by = vec!["quarter".to_string()]; config.sort = vec![Sort("value".to_string(), SortDir::Desc)]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); @@ -397,7 +397,7 @@ fn test_table_make_view_flat_group_by() { config.group_by = vec!["category".to_string()]; config.group_rollup_mode = GroupRollupMode::Flat; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -431,7 +431,7 @@ fn test_table_make_view_flat_group_by_with_split_by() { config.split_by = vec!["quarter".to_string()]; config.group_rollup_mode = GroupRollupMode::Flat; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); @@ -465,7 +465,7 @@ fn test_table_make_view_flat_group_by_with_sort() { )]); config.group_rollup_mode = GroupRollupMode::Flat; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -499,7 +499,7 @@ fn test_table_make_view_flat_group_by_with_split_by_and_sort() { )]); config.group_rollup_mode = GroupRollupMode::Flat; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); @@ -573,7 +573,7 @@ fn test_table_make_view_total() { Aggregate::SingleAggregate("sum".to_string()), )]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -600,7 +600,7 @@ fn test_table_make_view_flat_preserves_underscores() { let mut config = ViewConfig::default(); config.columns = vec![Some("account_number".to_string())]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -625,7 +625,7 @@ fn test_table_make_view_pivoted_column_paths() { ]; config.split_by = vec!["state".to_string()]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -661,7 +661,7 @@ fn test_table_make_view_pivoted_custom_separator() { config.columns = vec![Some("account_number".to_string())]; config.split_by = vec!["state".to_string()]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -678,7 +678,7 @@ fn test_table_make_view_multi_split_by_separator() { config.columns = vec![Some("value".to_string())]; config.split_by = vec!["region".to_string(), "state".to_string()]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -696,7 +696,7 @@ fn test_table_make_view_grouped_pivoted_null_safe_join() { config.group_by = vec!["category".to_string()]; config.split_by = vec!["quarter".to_string()]; let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -725,7 +725,7 @@ fn test_table_make_view_total_pivoted_aggregate() { Aggregate::SingleAggregate("sum".to_string()), )]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -871,7 +871,7 @@ fn test_table_make_view_total_with_split_by() { Aggregate::SingleAggregate("sum".to_string()), )]); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); @@ -908,7 +908,7 @@ fn test_table_make_view_window_natural_order() { spec.order_by = None; config.windows = Windows(HashMap::from([(name, spec)])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); // An omitted `order_by` takes the model's natural order - the same @@ -928,7 +928,7 @@ fn test_table_make_view_window_range_requires_order_by() { let (name, mut spec) = window_spec("rs", "sum", Some(WindowFrame::Range(10.0))); spec.order_by = None; config.windows = Windows(HashMap::from([(name, spec)])); - let result = builder.table_make_view("source_table", "dest_view", &config); + let result = builder.table_make_view("source_table", "dest_view", &config, &IndexMap::new()); assert!(matches!( result, Err(GenericSQLError::UnsupportedOperation(_)) @@ -944,7 +944,7 @@ fn test_table_make_view_window_order_desc() { spec.order_by.as_mut().unwrap().1 = WindowSortDir::Desc; config.windows = Windows(HashMap::from([(name, spec)])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!( @@ -965,7 +965,7 @@ fn test_table_make_view_window_cumulative_sum() { Some(WindowFrame::Cumulative), )])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains( @@ -986,7 +986,7 @@ fn test_table_make_view_window_rows_and_range_frames() { window_spec("rsum", "sum", Some(WindowFrame::Range(100.0))), ])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("avg(\"price\") OVER")); @@ -1006,7 +1006,7 @@ fn test_table_make_view_window_lag_diff() { window_spec("df", "diff", None), ])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains( @@ -1026,7 +1026,7 @@ fn test_table_make_view_window_rate() { Some(WindowFrame::Range(10.0)), )])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("first_value(\"price\") OVER")); @@ -1047,7 +1047,7 @@ fn test_table_make_view_window_over_expression_source() { w.column = "double_price".to_string(); config.windows = Windows(HashMap::from([(w_name, w)])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("sum(\"price\" * 2) OVER")); @@ -1069,7 +1069,7 @@ fn test_table_make_view_window_group_by_over_window_column() { Some(WindowFrame::Cumulative), )])); let sql = builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap(); assert!(sql.contains("GROUP BY")); @@ -1085,7 +1085,7 @@ fn test_table_make_view_window_ema_unsupported() { let (w_name, mut w) = window_spec("e", "ema", None); w.alpha = Some(0.5); config.windows = Windows(HashMap::from([(w_name, w)])); - let result = builder.table_make_view("source_table", "dest_view", &config); + let result = builder.table_make_view("source_table", "dest_view", &config, &IndexMap::new()); assert!(matches!( result, Err(GenericSQLError::UnsupportedOperation(_)) @@ -1118,6 +1118,6 @@ fn filter_sql(args: GenericSQLVirtualServerModelArgs, filter: serde_json::Value) config.columns = vec![Some("a".to_string())]; config.filter = filters(filter); builder - .table_make_view("source_table", "dest_view", &config) + .table_make_view("source_table", "dest_view", &config, &IndexMap::new()) .unwrap() } diff --git a/rust/perspective-js/src/rust/generic_sql_model.rs b/rust/perspective-js/src/rust/generic_sql_model.rs index b0d486a4e1..41d739d5f2 100644 --- a/rust/perspective-js/src/rust/generic_sql_model.rs +++ b/rust/perspective-js/src/rust/generic_sql_model.rs @@ -111,12 +111,19 @@ impl GenericSQLVirtualServerModel { table_id: &str, view_id: &str, config: JsValue, + schema: JsValue, ) -> Result { let config: ViewConfig = serde_wasm_bindgen::from_value(config) .map_err(|e| JsValue::from_str(&e.to_string()))?; + let schema = if schema.is_undefined() || schema.is_null() { + IndexMap::new() + } else { + self.parse_schema(schema)? + }; + self.inner - .table_make_view(table_id, view_id, &config) + .table_make_view(table_id, view_id, &config, &schema) .map_err(|e| JsValue::from_str(&e.to_string())) } diff --git a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts index d5e7654120..46395902d8 100644 --- a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts +++ b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts @@ -359,7 +359,18 @@ export class ClickhouseHandler implements perspective.VirtualServerHandler { viewId: string, config: ViewConfigUpdate, ) { - const query = this.sqlBuilder.tableMakeView(tableId, viewId, config); + // Window order keys need column types for `range` frame emission. + const schema = Object.keys(config.windows ?? {}).length + ? await this.tableSchema(tableId) + : undefined; + + const query = this.sqlBuilder.tableMakeView( + tableId, + viewId, + config, + schema, + ); + await runQuery(this.db, query, { execute: true }); } diff --git a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts index e0c0e83ceb..3eef948a8c 100644 --- a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -371,7 +371,18 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { viewId: string, config: ViewConfigUpdate, ) { - const query = this.sqlBuilder.tableMakeView(tableId, viewId, config); + // Window order keys need column types for `range` frame emission. + const schema = Object.keys(config.windows ?? {}).length + ? await this.tableSchema(tableId) + : undefined; + + const query = this.sqlBuilder.tableMakeView( + tableId, + viewId, + config, + schema, + ); + await runQuery(this.db, query); } diff --git a/rust/perspective-js/test/js/duckdb/setup.js b/rust/perspective-js/test/js/duckdb/setup.js index 4150207469..73cb18bbf8 100644 --- a/rust/perspective-js/test/js/duckdb/setup.js +++ b/rust/perspective-js/test/js/duckdb/setup.js @@ -125,6 +125,20 @@ async function loadCoerceTypesData(db) { `); } +async function loadTemporalData(db) { + await db.query(` + CREATE TABLE temporal_test (ts TIMESTAMP, d DATE, x DOUBLE); + `); + + await db.query(` + INSERT INTO temporal_test VALUES + (TIMESTAMP '2024-01-30 00:00:00', DATE '2024-01-30', 1.0), + (TIMESTAMP '2024-01-31 12:00:00', DATE '2024-01-31', 2.0), + (TIMESTAMP '2024-02-01 00:00:00', DATE '2024-02-01', 4.0), + (TIMESTAMP '2024-02-03 00:00:00', DATE '2024-02-03', 8.0); + `); +} + export function describeDuckDB(name, fn) { test.describe("DuckDB Virtual Server " + name, function () { let db; @@ -139,6 +153,7 @@ export function describeDuckDB(name, fn) { await loadSuperstoreData(db); await loadUnderscoreData(db); await loadCoerceTypesData(db); + await loadTemporalData(db); }); fn(() => client); diff --git a/rust/perspective-js/test/js/duckdb/windows.spec.js b/rust/perspective-js/test/js/duckdb/windows.spec.js index cf5173494c..9bdcbf41e4 100644 --- a/rust/perspective-js/test/js/duckdb/windows.spec.js +++ b/rust/perspective-js/test/js/duckdb/windows.spec.js @@ -199,4 +199,108 @@ describeDuckDB("windows", (getClient) => { } await view.delete(); }); + + test("unsorted window view takes the natural row order", async function () { + const rows = await raw_rows(getClient()); + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Row ID", "rsum"], + windows: { + rsum: { + column: "Sales", + aggregate: "sum", + order_by: ["Order Date", "asc"], + range: 0, + }, + }, + }); + + const result = await view.to_columns(); + expect(result["Row ID"].length).toBe(rows.length); + expect(result["Row ID"].slice(0, 5)).toEqual([1, 2, 3, 4, 5]); + await view.delete(); + }); + + test("range frame over a date order column measures days", async function () { + const table = await getClient().open_table("memory.temporal_test"); + const view = await table.view({ + columns: ["x", "rsum"], + windows: { + rsum: { + column: "x", + aggregate: "sum", + order_by: ["d", "asc"], + range: 2, + }, + }, + }); + + const result = await view.to_columns(); + expect(result["rsum"]).toEqual([1, 3, 7, 12]); + await view.delete(); + }); + + test("range frame over a datetime order column measures ms", async function () { + const table = await getClient().open_table("memory.temporal_test"); + const view = await table.view({ + columns: ["x", "rsum"], + windows: { + rsum: { + column: "x", + aggregate: "sum", + order_by: ["ts", "asc"], + // 36 hours. + range: 129600000, + }, + }, + }); + + const result = await view.to_columns(); + expect(result["rsum"]).toEqual([1, 3, 6, 8]); + await view.delete(); + }); + + test("rate over a date order column measures per-day slope", async function () { + const table = await getClient().open_table("memory.temporal_test"); + const view = await table.view({ + columns: ["x", "r"], + windows: { + r: { + column: "x", + aggregate: "rate", + order_by: ["d", "asc"], + range: 2, + }, + }, + }); + + const result = await view.to_columns(); + expect(result["r"]).toEqual([null, 1, 1.5, 2]); + await view.delete(); + }); + + test("unsorted window view with split_by takes the natural row order", async function () { + const rows = await raw_rows(getClient()); + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["cumsum"], + split_by: ["Region"], + windows: { + cumsum: { + column: "Sales", + aggregate: "sum", + order_by: ["Row ID", "asc"], + partition_by: ["Region"], + cumulative: true, + }, + }, + }); + + const result = await view.to_columns(); + const first = Object.keys(result).filter((k) => + k.endsWith("cumsum"), + )[0]; + expect(result[first].length).toBe(rows.length); + await view.delete(); + }); }); diff --git a/rust/perspective-js/test/js/windows.spec.js b/rust/perspective-js/test/js/windows.spec.js index 2b3852b139..3249bfe105 100644 --- a/rust/perspective-js/test/js/windows.spec.js +++ b/rust/perspective-js/test/js/windows.spec.js @@ -997,4 +997,39 @@ test.describe("Window columns", function () { await view.delete(); await table.delete(); }); + + test("range frame over a date order column measures days", async function () { + const table = await perspective.table({ d: "date", x: "float" }); + await table.update([ + { d: "2024-01-30", x: 1 }, + { d: "2024-01-31", x: 2 }, + { d: "2024-02-01", x: 4 }, + { d: "2024-02-03", x: 8 }, + ]); + + const view = await table.view({ + columns: ["rsum", "r"], + sort: [["d", "asc"]], + windows: { + rsum: { + column: "x", + aggregate: "sum", + order_by: ["d", "asc"], + range: 2, + }, + r: { + column: "x", + aggregate: "rate", + order_by: ["d", "asc"], + range: 2, + }, + }, + }); + + const result = await view.to_columns(); + expect(result["rsum"]).toEqual([1, 3, 7, 12]); + expect(result["r"]).toEqual([null, 1, 1.5, 2]); + await view.delete(); + await table.delete(); + }); }); diff --git a/rust/perspective-python/perspective/virtual_servers/clickhouse.py b/rust/perspective-python/perspective/virtual_servers/clickhouse.py index 43b775c2f0..909d6fb942 100644 --- a/rust/perspective-python/perspective/virtual_servers/clickhouse.py +++ b/rust/perspective-python/perspective/virtual_servers/clickhouse.py @@ -195,7 +195,9 @@ def table_size(self, table_name): return results[0][0] def table_make_view(self, table_name, view_name, config): - query = self.sql_builder.table_make_view(table_name, view_name, config) + # Window order keys need column types for `range` frame emission. + schema = self.table_schema(table_name) if config.get("windows") else None + query = self.sql_builder.table_make_view(table_name, view_name, config, schema) run_query(self.db, query, execute=True) def table_validate_expression(self, view_name, expression): diff --git a/rust/perspective-python/perspective/virtual_servers/duckdb.py b/rust/perspective-python/perspective/virtual_servers/duckdb.py index 31c8c58f09..074f9c2cbb 100644 --- a/rust/perspective-python/perspective/virtual_servers/duckdb.py +++ b/rust/perspective-python/perspective/virtual_servers/duckdb.py @@ -236,7 +236,9 @@ def table_size(self, table_name): return results[0][0] def table_make_view(self, table_name, view_name, config): - query = self.sql_builder.table_make_view(table_name, view_name, config) + # Window order keys need column types for `range` frame emission. + schema = self.table_schema(table_name) if config.get("windows") else None + query = self.sql_builder.table_make_view(table_name, view_name, config, schema) run_query(self.db, query, execute=True) def table_validate_expression(self, view_name, expression): diff --git a/rust/perspective-python/src/server/generic_sql_model.rs b/rust/perspective-python/src/server/generic_sql_model.rs index 8af84eeabc..0aa503e731 100644 --- a/rust/perspective-python/src/server/generic_sql_model.rs +++ b/rust/perspective-python/src/server/generic_sql_model.rs @@ -74,20 +74,31 @@ impl PyGenericSQLVirtualServerModel { .map_err(|e| PyValueError::new_err(e.to_string())) } + #[pyo3(signature = (table_id, view_id, config, schema=None))] pub fn table_make_view( &self, table_id: &str, view_id: &str, config: Py, + schema: Option>, ) -> PyResult { - let config: ViewConfig = Python::with_gil(|py| { - pythonize::depythonize(config.bind(py)) - .map_err(|e| PyValueError::new_err(e.to_string())) - })?; + Python::with_gil(|py| { + let config: ViewConfig = pythonize::depythonize(config.bind(py)) + .map_err(|e| PyValueError::new_err(e.to_string()))?; - self.inner - .table_make_view(table_id, view_id, &config) - .map_err(|e| PyValueError::new_err(e.to_string())) + let schema = match &schema { + Some(schema) => { + self.parse_schema(schema.downcast_bound::(py).map_err(|_| { + PyValueError::new_err("Schema must be a dict mapping column names to types") + })?)? + }, + None => IndexMap::new(), + }; + + self.inner + .table_make_view(table_id, view_id, &config, &schema) + .map_err(|e| PyValueError::new_err(e.to_string())) + }) } pub fn view_get_data( diff --git a/rust/perspective-server/cpp/perspective/src/cpp/window.cpp b/rust/perspective-server/cpp/perspective/src/cpp/window.cpp index e3775dd514..e851633f13 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/window.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/window.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -110,10 +111,34 @@ partition_first_valid(const ROWS_T& rows) { return lo; } -// Range-frame interval arithmetic. Keys compared as doubles (`Range` -// frames require a numeric or temporal order key, so intervals are defined -// on the column's raw units); keys are sorted in the spec's direction, so -// "before" any boundary is a positional prefix in either direction. +// Linear order-key scale for `Range` frame arithmetic. DTYPE_DATE's raw +// storage is a packed y/m/d bitfield - a month boundary jumps it by 256 - +// so it converts to days since epoch, matching the SQL model's `DATE` day +// units; DTYPE_TIME (ms since epoch) and the numeric types are already +// linear in their raw value. Ordering itself never uses this - `key_less` +// compares scalars, and packed y/m/d is order-preserving. +double +order_key_to_double(const t_tscalar& s) { + if (s.get_dtype() == DTYPE_DATE) { + t_date val = s.get(); + // `t_date::month()` is [0-11], `date::month` is [1-12]. + date::year_month_day ymd( + date::year{val.year()}, + date::month{static_cast(val.month() + 1)}, + date::day{static_cast(val.day())} + ); + return static_cast( + date::sys_days(ymd).time_since_epoch().count() + ); + } + + return s.to_double(); +} + +// Range-frame interval arithmetic. Keys compared as doubles on the linear +// scale above (`Range` frames require a numeric or temporal order key); +// keys are sorted in the spec's direction, so "before" any boundary is a +// positional prefix in either direction. // First position in [first_valid, size) at or inside a boundary key: // ascending the first key >= `boundary`, descending the first key <= @@ -129,7 +154,7 @@ frame_lower_bound( std::size_t hi = rows.size(); while (lo < hi) { std::size_t mid = lo + (hi - lo) / 2; - double v = rows[mid].m_order.to_double(); + double v = order_key_to_double(rows[mid].m_order); if (desc ? v > boundary : v < boundary) { lo = mid + 1; } else { @@ -157,7 +182,7 @@ reach_upper_bound( std::size_t hi = rows.size(); while (lo < hi) { std::size_t mid = lo + (hi - lo) / 2; - double v = rows[mid].m_order.to_double(); + double v = order_key_to_double(rows[mid].m_order); if (desc ? v >= limit : v <= limit) { lo = mid + 1; } else { @@ -730,7 +755,7 @@ t_window_engine::recompute_range( if (spec.m_op == t_window_op::WINDOW_OP_RATE) { for (std::size_t pos = start; pos < hi; ++pos) { t_uindex out_ridx = rows[pos].m_ridx; - double key = rows[pos].m_order.to_double(); + double key = order_key_to_double(rows[pos].m_order); std::size_t frame_lo = frame_lower_bound( rows, desc ? key + spec.m_frame_range @@ -747,7 +772,8 @@ t_window_engine::recompute_range( // Δv/Δk is the same slope whichever end of the frame is // "first" - for desc both deltas negate. - double dk = key - rows[frame_lo].m_order.to_double(); + double dk = + key - order_key_to_double(rows[frame_lo].m_order); if (dk == 0) { out.clear(out_ridx); continue; @@ -769,8 +795,10 @@ t_window_engine::recompute_range( ); std::size_t frame_start = frame_lower_bound( rows, - desc ? rows[start].m_order.to_double() + spec.m_frame_range - : rows[start].m_order.to_double() - spec.m_frame_range, + desc ? order_key_to_double(rows[start].m_order) + + spec.m_frame_range + : order_key_to_double(rows[start].m_order) + - spec.m_frame_range, first_valid, desc ); @@ -779,16 +807,18 @@ t_window_engine::recompute_range( } for (std::size_t pos = start; pos < hi; ++pos) { - double key = rows[pos].m_order.to_double(); + double key = order_key_to_double(rows[pos].m_order); double boundary = desc ? key + spec.m_frame_range : key - spec.m_frame_range; for (; frame_start < pos && (desc - ? rows[frame_start].m_order.to_double() - > boundary - : rows[frame_start].m_order.to_double() - < boundary); + ? order_key_to_double( + rows[frame_start].m_order + ) > boundary + : order_key_to_double( + rows[frame_start].m_order + ) < boundary); ++frame_start) { sliding.evict(frame_start); } @@ -1327,7 +1357,7 @@ t_window_engine::collect_invalidations( break; } - double k = key.first.to_double(); + double k = order_key_to_double(key.first); std::size_t lo = frame_lower_bound( rows, k, first_valid, spec.m_order_desc ); From 3b20bbcc897d18525af713266cfc2c352d84327e Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Sat, 15 Aug 2026 16:02:19 -0400 Subject: [PATCH 06/14] Fix partial restore race Signed-off-by: Andrew Stein --- .../src/rust/components/viewer/settings.rs | 2 +- .../src/rust/tasks/restore_and_render.rs | 11 ++- .../test/js/settings_panel/toggle.spec.ts | 91 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/rust/perspective-viewer/src/rust/components/viewer/settings.rs b/rust/perspective-viewer/src/rust/components/viewer/settings.rs index 309fc03c4b..6a747508a2 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/settings.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/settings.rs @@ -153,7 +153,6 @@ impl PerspectiveViewer { sender: Option>>, ) { let is_open = ctx.props().presentation.is_settings_open(); - ctx.props().presentation.set_settings_before_open(!is_open); match force { Some(force) if is_open == force => { if let Some(sender) = sender { @@ -161,6 +160,7 @@ impl PerspectiveViewer { } }, Some(_) | None => { + ctx.props().presentation.set_settings_before_open(!is_open); let force = !is_open; let callback = ctx.link().callback(move |resolve| { let update = SettingsUpdate::Update(force); diff --git a/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs b/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs index 1f1314a06a..c01c06b64f 100644 --- a/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs +++ b/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs @@ -114,7 +114,16 @@ pub fn restore_and_render( // subtotal rows. Rollup only; the full column-defaults pass is // reserved for plugin swaps, where `columns` genuinely needs // re-defaulting. - session.set_update_rollup_defaults(&mut view_config, &renderer.metadata()); + let metadata = if renderer.active_plugin().is_none() { + renderer + .resolve_plugin_update(&OptionalUpdate::SetDefault) + .map(|(_, metadata)| metadata) + .unwrap_or_else(|| renderer.metadata()) + } else { + renderer.metadata() + }; + + session.set_update_rollup_defaults(&mut view_config, &metadata); } let plugin_idx = resolved_plugin.map(|(idx, _)| idx); diff --git a/rust/perspective-viewer/test/js/settings_panel/toggle.spec.ts b/rust/perspective-viewer/test/js/settings_panel/toggle.spec.ts index 29c5061515..6f73db625e 100644 --- a/rust/perspective-viewer/test/js/settings_panel/toggle.spec.ts +++ b/rust/perspective-viewer/test/js/settings_panel/toggle.spec.ts @@ -57,3 +57,94 @@ test.describe("Settings Panel", () => { await compareContentsToSnapshot(contents); }); }); + +test.describe("Settings Panel no-op force", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer").restore({ + plugin: "Debug", + }); + }); + }); + + test("toggle > toggleConfig(false) while closed is a no-op", async ({ + page, + }) => { + const state = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.getTable(); + await viewer.toggleConfig(false); + return { + settings: (await viewer.save()).settings, + attribute: viewer.hasAttribute("settings"), + }; + }); + + expect(state).toEqual({ settings: false, attribute: false }); + const after = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.toggleConfig(); + return { + settings: (await viewer.save()).settings, + attribute: viewer.hasAttribute("settings"), + }; + }); + + expect(after).toEqual({ settings: true, attribute: true }); + }); + + test("toggle > toggleConfig(true) while open is a no-op", async ({ + page, + }) => { + const state = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.getTable(); + await viewer.toggleConfig(true); + await viewer.toggleConfig(true); + return { + settings: (await viewer.save()).settings, + attribute: viewer.hasAttribute("settings"), + }; + }); + + expect(state).toEqual({ settings: true, attribute: true }); + }); + + test("toggle > restoreWorkspace without `active` leaves settings closed and toggleable", async ({ + page, + }) => { + const state = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + const table = await viewer.getTable(); + const name = await table.get_name(); + await viewer.restoreWorkspace({ + layout: { type: "tab-layout", tabs: ["p0"], selected: 0 }, + panels: { p0: { table: name, plugin: "Debug" } }, + }); + + const before = { + settings: (await viewer.save()).settings, + attribute: viewer.hasAttribute("settings"), + }; + + await viewer.toggleConfig(); + return { + before, + after: { + settings: (await viewer.save()).settings, + attribute: viewer.hasAttribute("settings"), + }, + }; + }); + + expect(state.before).toEqual({ settings: false, attribute: false }); + expect(state.after).toEqual({ settings: true, attribute: true }); + }); +}); From 240d1f3c288edb67fe103861d9af197348118c1f Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Wed, 19 Aug 2026 14:49:36 -0400 Subject: [PATCH 07/14] Fix initial/default theme selection, settings via restore bug, missing stack frames in viewer errors Signed-off-by: Andrew Stein --- .gitignore | 1 + Cargo.lock | 187 +---- package.json | 2 + packages/react/src/viewer.tsx | 5 + packages/react/test/js/workspace.story.tsx | 2 +- pnpm-lock.yaml | 17 - rust/bundle/Cargo.toml | 3 - rust/bundle/main.rs | 127 +++- rust/perspective-js/src/rust/utils/futures.rs | 103 ++- .../test/js/duckdb/client.spec.js | 1 + .../src/client/client_async.rs | 2 +- rust/perspective-viewer/build.mjs | 5 +- .../column_selector/aggregate_selector.rs | 5 +- .../src/rust/components/main_panel/msg.rs | 4 +- .../rust/components/main_panel/reconcile.rs | 8 +- .../src/rust/components/viewer/msg.rs | 9 +- .../src/rust/components/viewer/panels.rs | 19 +- .../src/rust/components/viewer/snapshots.rs | 29 +- .../src/rust/components/viewer/wiring.rs | 11 +- .../src/rust/config/viewer_config.rs | 104 ++- .../src/rust/config/workspace_config.rs | 4 +- .../src/rust/custom_elements/viewer.rs | 290 +++---- .../src/rust/custom_events.rs | 4 + .../src/rust/js/regular_layout.rs | 6 +- .../src/rust/presentation.rs | 75 +- .../src/rust/queries/get_viewer_config.rs | 3 - rust/perspective-viewer/src/rust/renderer.rs | 57 +- .../src/rust/renderer/dispatch.rs | 20 +- rust/perspective-viewer/src/rust/session.rs | 2 +- .../src/rust/session/metadata.rs | 6 +- .../src/rust/tasks/create_panel.rs | 101 ++- .../src/rust/tasks/eject.rs | 5 +- .../src/rust/tasks/reset_all.rs | 8 +- .../src/rust/tasks/restore_and_render.rs | 45 +- .../src/rust/tasks/restore_panel.rs | 22 +- .../src/rust/tasks/update_theme.rs | 40 +- .../src/rust/utils/spawn.rs | 2 +- rust/perspective-viewer/src/rust/workspace.rs | 707 +++++++++++------- .../test/js/multi_panel/inert_load.spec.ts | 22 + .../save_restore_workspace.spec.ts | 4 +- .../test/js/multi_panel/workspace.spec.ts | 4 +- .../test/js/multi_panel/zero_panel.spec.ts | 72 ++ .../test/js/status_indicator/updating.spec.ts | 26 - tools/scripts/install_binaryen.mjs | 80 ++ 44 files changed, 1286 insertions(+), 963 deletions(-) create mode 100644 tools/scripts/install_binaryen.mjs diff --git a/.gitignore b/.gitignore index a8dd00ef04..190d71867b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ .clangd .DS_Store .emsdk +.binaryen .plan .pnpm-store .ipynb_checkpoints diff --git a/Cargo.lock b/Cargo.lock index 2278da4d64..aeb3c694a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,8 +430,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -482,7 +480,7 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.111", @@ -510,18 +508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", - "unicode-width 0.1.14", -] - -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "serde", - "termcolor", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -677,68 +664,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "cxx" -version = "1.0.190" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7620f6cfc4dcca21f2b085b7a890e16c60fd66f560cd69ee60594908dc72ab1" -dependencies = [ - "cc", - "cxx-build", - "cxxbridge-cmd", - "cxxbridge-flags", - "cxxbridge-macro", - "foldhash 0.2.0", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.190" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9bc1a22964ff6a355fbec24cf68266a0ed28f8b84c0864c386474ea3d0e479" -dependencies = [ - "cc", - "codespan-reporting 0.13.1", - "indexmap 2.14.0", - "proc-macro2", - "quote", - "scratch", - "syn 2.0.111", -] - -[[package]] -name = "cxxbridge-cmd" -version = "1.0.190" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f29a879d35f7906e3c9b77d7a1005a6a0787d330c09dfe4ffb5f617728cb44" -dependencies = [ - "clap", - "codespan-reporting 0.13.1", - "indexmap 2.14.0", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.190" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d67109015f93f683e364085aa6489a5b2118b4a40058482101d699936a7836d6" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.190" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d187e019e7b05a1f3e69a8396b70800ee867aa9fc2ab972761173ccee03742df" -dependencies = [ - "indexmap 2.14.0", - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -947,12 +872,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1344,7 +1263,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", "serde", ] @@ -1360,12 +1279,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1633,16 +1546,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.85" @@ -2090,7 +1993,6 @@ version = "0.0.0" dependencies = [ "clap", "wasm-bindgen-cli-support", - "wasm-opt", ] [[package]] @@ -2456,7 +2358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.5.0", + "heck", "itertools 0.12.1", "log", "multimap", @@ -2601,7 +2503,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "pyo3-build-config", "quote", @@ -2873,12 +2775,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scratch" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" - [[package]] name = "seahash" version = "4.1.0" @@ -3148,12 +3044,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" - [[package]] name = "strum" version = "0.26.3" @@ -3172,26 +3062,13 @@ dependencies = [ "strum_macros 0.27.2", ] -[[package]] -name = "strum_macros" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", -] - [[package]] name = "strum_macros" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", @@ -3204,7 +3081,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.111", @@ -3677,12 +3554,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unindent" version = "0.2.4" @@ -3768,7 +3639,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ef06db404cbaed87cb25fd2ca3a62502af485f43383c9641ffcf1479d02fffd" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.111", @@ -3937,46 +3808,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-opt" -version = "0.116.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd87a4c135535ffed86123b6fb0f0a5a0bc89e50416c942c5f0662c645f679c" -dependencies = [ - "anyhow", - "libc", - "strum 0.24.1", - "strum_macros 0.24.3", - "tempfile", - "thiserror 1.0.69", - "wasm-opt-cxx-sys", - "wasm-opt-sys", -] - -[[package]] -name = "wasm-opt-cxx-sys" -version = "0.116.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c57b28207aa724318fcec6575fe74803c23f6f266fce10cbc9f3f116762f12e" -dependencies = [ - "anyhow", - "cxx", - "cxx-build", - "wasm-opt-sys", -] - -[[package]] -name = "wasm-opt-sys" -version = "0.116.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a1cce564dc768dacbdb718fc29df2dba80bd21cb47d8f77ae7e3d95ceb98cbe" -dependencies = [ - "anyhow", - "cc", - "cxx", - "cxx-build", -] - [[package]] name = "wasmparser" version = "0.240.0" @@ -4236,7 +4067,7 @@ dependencies = [ "basic-toml", "bumpalo", "clap", - "codespan-reporting 0.11.1", + "codespan-reporting", "diffy", "dirs", "either", diff --git a/package.json b/package.json index a65143e3ec..84938d10b9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "type": "module", "emscripten": "4.0.9", + "binaryen": "version_132", "llvm": "17.0.6", "pyodide": "0.29.4", "engines": { @@ -59,6 +60,7 @@ "scripts": { "preinstall": "npx only-allow pnpm", "postinstall": "npm-run-all postinstall:*", + "postinstall:binaryen": "node tools/scripts/install_binaryen.mjs", "postinstall:emsdk": "node tools/scripts/install_emsdk.mjs", "postinstall:playwright": "npx playwright install --with-deps chromium", "postinstall:vscode": "cp -n ./.vscode/settings.default.json ./.vscode/settings.json || true", diff --git a/packages/react/src/viewer.tsx b/packages/react/src/viewer.tsx index ba8607d849..98fb810a9b 100644 --- a/packages/react/src/viewer.tsx +++ b/packages/react/src/viewer.tsx @@ -111,6 +111,11 @@ export interface PerspectiveViewerProps { * literal on each render does not re-apply. Combine with * {@link PerspectiveViewerProps.onConfigUpdate} to use the viewer as a * controlled component. + * + * When {@link PerspectiveViewerProps.client} is a bare `Client` (which + * creates no panel by itself), the config must include `table` — a + * panel-creating `restore()` without one rejects. `Table` clients bind + * their own panel, so `table` may be omitted there. */ config?: pspViewer.ViewerConfigUpdate | pspViewer.WorkspaceConfigUpdate; diff --git a/packages/react/test/js/workspace.story.tsx b/packages/react/test/js/workspace.story.tsx index f0598db515..056b4814d6 100644 --- a/packages/react/test/js/workspace.story.tsx +++ b/packages/react/test/js/workspace.story.tsx @@ -37,7 +37,7 @@ await Promise.all([ const CLIENT = await perspective.worker(); -/// The merged `` whole-element config: a `regular-layout` +/// The merged `` workspace config: a `regular-layout` /// tree + a per-panel `ViewerConfig` map. (Formerly /// `@perspective-dev/workspace`'s `PerspectiveWorkspaceConfig`.) interface MultiPanelConfig { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46b145210a..41ad4b8a46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,9 +251,6 @@ importers: blocks: specifier: 'workspace:' version: link:../examples/blocks - prismjs: - specifier: ^1.29.0 - version: 1.30.0 puppeteer: specifier: 'catalog:' version: 24.26.1(typescript@5.9.3) @@ -261,9 +258,6 @@ importers: specifier: 'catalog:' version: 3.2.0 devDependencies: - '@types/prismjs': - specifier: ^1.26.0 - version: 1.26.6 '@zip.js/zip.js': specifier: 'catalog:' version: 2.8.8 @@ -2221,9 +2215,6 @@ packages: '@types/node@24.9.1': resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} - '@types/prismjs@1.26.6': - resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} - '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -4296,10 +4287,6 @@ packages: pretty-error@4.0.0: resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} - prismjs@1.30.0: - resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} - engines: {node: '>=6'} - pro_self_extracting_wasm@0.0.9: resolution: {integrity: sha512-95/dZfLmlGc/6Xp7gqvRBgXF8M+osw/Xtalz1U/Va8MpSC1TiR7rM4lEvAs1p/q4v/EZk6bow3tKEclbMGsSFQ==} hasBin: true @@ -6799,8 +6786,6 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/prismjs@1.26.6': {} - '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@19.2.2)': @@ -9032,8 +9017,6 @@ snapshots: lodash: 4.17.21 renderkid: 3.0.0 - prismjs@1.30.0: {} - pro_self_extracting_wasm@0.0.9: dependencies: http-server: 14.1.1 diff --git a/rust/bundle/Cargo.toml b/rust/bundle/Cargo.toml index e066d7e47a..828ddda5a0 100644 --- a/rust/bundle/Cargo.toml +++ b/rust/bundle/Cargo.toml @@ -24,6 +24,3 @@ bench = false [dependencies] clap = { version = "4.4.8", features = ["derive"] } wasm-bindgen-cli-support = "*" - -# https://github.com/brson/wasm-opt-rs/issues/154 -wasm-opt = { version = "0.116.1", default-features = false } diff --git a/rust/bundle/main.rs b/rust/bundle/main.rs index 4ddde811a3..bb9d6ac8c7 100644 --- a/rust/bundle/main.rs +++ b/rust/bundle/main.rs @@ -10,7 +10,8 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -use std::path::Path; +use std::env; +use std::path::{Path, PathBuf}; use std::process::{Command, exit}; use clap::*; @@ -31,7 +32,6 @@ struct BundleArgs { } use wasm_bindgen_cli_support::{Bindgen, EncodeInto}; -use wasm_opt::{Feature, OptimizationOptions}; /// Run the packages `build` task with the appropriate flags. These can't be /// defined in the `/.cargo/config.toml` because they would define this build @@ -69,9 +69,33 @@ fn build(pkg: Option<&str>, is_release: bool, features: Vec) { cmd.execute() } +/// The workspace root, per the repo-wide `$PSP_ROOT_DIR` convention (set by +/// each package's `build.mjs`). +fn root_dir() -> Option { + Some(PathBuf::from(env::var("PSP_ROOT_DIR").ok()?)) +} + +/// The `cargo` target directory: `$CARGO_TARGET_DIR` when set (which +/// [`build`]'s `cargo` invocation honors too, keeping the two coherent), +/// else the workspace-config target directory under `$PSP_ROOT_DIR`. +fn target_dir() -> PathBuf { + let dir = env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .ok() + .or_else(|| Some(root_dir()?.join("rust/target"))); + + let Some(dir) = dir else { + eprintln!("Set $PSP_ROOT_DIR (or $CARGO_TARGET_DIR) to locate build artifacts."); + exit(1); + }; + + dir +} + /// Generate the `wasm-bindgen` JavaScript and WASM bindings. fn bindgen(outdir: &Path, artifact: &str, is_release: bool) { - let input = Path::new("../target/wasm32-unknown-unknown") + let input = target_dir() + .join("wasm32-unknown-unknown") .join(if is_release { "release" } else { "debug" }) .join(format!("{artifact}.wasm")); @@ -88,18 +112,97 @@ fn bindgen(outdir: &Path, artifact: &str, is_release: bool) { .unwrap(); } +/// The oldest Binaryen accepted. Version 116 verifiably lacks `table.fill` +/// parsing, which wasm-bindgen's externref pass emits when the artifact is +/// built without `strip`; `install_binaryen.mjs` pins 132. The floor is +/// defensive, not exact. +const WASM_OPT_MIN_VERSION: u32 = 118; + +/// One command line for EVERY bundle build. The feature set is the union of +/// what `build`'s RUSTFLAGS request and what the `target_features` section +/// advertises un-stripped (among them `multivalue`, which `wasm-bindgen` +/// auto-enables from that same section). `-g` preserves the name section +/// when one exists; on a stripped artifact it costs only an empty +/// name-section header, so prod and dev need not diverge. +const WASM_OPT_ARGS: &[&str] = &[ + "-Oz", + "--enable-bulk-memory", + "--enable-reference-types", + "--enable-simd", + "--enable-relaxed-simd", + "--enable-nontrapping-float-to-int", + "--enable-multivalue", + "--enable-sign-ext", + "--enable-mutable-globals", + "-g", +]; + +/// Locate the Binaryen `wasm-opt` binary: `$WASM_OPT`, the native toolchain +/// installed by `tools/scripts/install_binaryen.mjs` under `$PSP_ROOT_DIR`, +/// then `$PATH`. +fn find_wasm_opt() -> Option { + if let Ok(path) = env::var("WASM_OPT") { + return Some(PathBuf::from(path)); + } + + if let Some(toolchain) = root_dir().map(|x| x.join(".binaryen/bin/wasm-opt")) + && toolchain.exists() + { + return Some(toolchain); + } + + Some(PathBuf::from("wasm-opt")).filter(|x| { + Command::new(x) + .arg("--version") + .output() + .is_ok_and(|x| x.status.success()) + }) +} + +/// Binaryen's major version, from `wasm-opt --version`. +fn wasm_opt_version(bin: &Path) -> Option { + let output = Command::new(bin).arg("--version").output().ok()?; + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .find_map(|x| x.parse().ok()) +} + /// Run `wasm-opt` and output the new binary on top of the old one. fn opt(outpath: &Path, is_release: bool) { - if is_release { - OptimizationOptions::new_optimize_for_size_aggressively() - .enable_feature(Feature::BulkMemory) - .enable_feature(Feature::ReferenceTypes) - .enable_feature(Feature::Simd) - .enable_feature(Feature::RelaxedSimd) - .enable_feature(Feature::TruncSat) - .run(outpath, outpath) - .unwrap(); + if !is_release { + return; + } + + let Some(bin) = find_wasm_opt() else { + eprintln!( + "`wasm-opt` (Binaryen >= {WASM_OPT_MIN_VERSION}) is required. `pnpm run \ + postinstall:binaryen` installs it under $PSP_ROOT_DIR/.binaryen, or set $WASM_OPT." + ); + + exit(1); + }; + + match wasm_opt_version(&bin) { + Some(version) if version >= WASM_OPT_MIN_VERSION => (), + version => { + let version = version.map_or("unknown".to_string(), |x| x.to_string()); + eprintln!( + "{} is Binaryen version {version}; version >= {WASM_OPT_MIN_VERSION} is required \ + (`table.fill` parsing, emitted by wasm-bindgen's externref pass on un-stripped \ + artifacts).", + bin.display(), + ); + + exit(1); + }, } + + Command::new(&bin) + .args(WASM_OPT_ARGS) + .arg(outpath) + .arg("-o") + .arg(outpath) + .execute(); } fn main() { diff --git a/rust/perspective-js/src/rust/utils/futures.rs b/rust/perspective-js/src/rust/utils/futures.rs index 50fc6001d1..3e1bbaf18e 100644 --- a/rust/perspective-js/src/rust/utils/futures.rs +++ b/rust/perspective-js/src/rust/utils/futures.rs @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +use std::cell::RefCell; use std::future::Future; use std::pin::Pin; @@ -25,13 +26,64 @@ use wasm_bindgen_futures::{JsFuture, future_to_promise}; use super::errors::*; +#[wasm_bindgen] +extern "C" { + /// A DevTools task handle from `console.createTask` — Chrome's Async + /// Stack Tagging API. Bound manually, as `js_sys` does not expose it. + type JsConsoleTask; + + #[wasm_bindgen(method, catch)] + fn run(this: &JsConsoleTask, f: &js_sys::Function) -> Result; +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(variadic, js_namespace = console , js_name = createTask, catch)] + pub fn createTask(name: JsValue) -> Result; +} + +/// Create a DevTools task, or `None` where the API is unavailable (it is +/// Chrome-only). +fn create_task(name: &str) -> Option { + thread_local! { + static CREATE_TASK: Option<(JsValue, js_sys::Function)> = (|| { + let global = js_sys::global(); + let console = js_sys::Reflect::get(&global, &"console".into()).ok()?; + let create = js_sys::Reflect::get(&console, &"createTask".into()).ok()?; + Some((console, create.dyn_into().ok()?)) + })(); + } + + CREATE_TASK.with(|x| { + let (console, create) = x.as_ref()?; + Some(create.call1(console, &name.into()).ok()?.unchecked_into()) + }) +} + +thread_local! { + /// Scoped poll thunks for [`TRAMPOLINE`], a stack because tagged polls + /// nest (an `ApiFuture` awaiting another `ApiFuture`). + static POLL_STACK: RefCell> = RefCell::new(Vec::new()); + + /// The single reusable JS closure handed to [`JsConsoleTask::run`]. + static TRAMPOLINE: Closure = Closure::new(|| { + let thunk = POLL_STACK.with(|x| x.borrow().last().copied()); + if let Some(thunk) = thunk { + unsafe { (*thunk)() } + } + }); +} + /// A newtype wrapper for a `Future` trait object which supports being /// marshalled to a `JsPromise`. /// /// This avoids implementing an API which requires type casting to /// and from `JsValue` and the associated loss of type safety. #[must_use] -pub struct ApiFuture(Pin>>>) +pub struct ApiFuture( + Pin>>>, + Option, +) where Result: IntoJsResult + 'static; @@ -44,13 +96,17 @@ where /// `Promise`, either explicitly or implcitly (when exposed via /// `wasm_bindgen`). pub fn new> + 'static>(x: U) -> Self { - Self(Box::pin(x)) + Self::new_named("perspective", x) + } + + /// [`Self::new`] with an explicit DevTools task label, shown at the async + /// gap in tagged stack traces. + pub fn new_named> + 'static>(name: &str, x: U) -> Self { + Self(Box::pin(x), create_task(name)) } pub fn new_throttled> + 'static>(x: U) -> ApiFuture<()> { - ApiFuture::<()>(Box::pin( - async move { x.await.ignore_view_delete().map(|_| ()) }, - )) + ApiFuture::<()>::new(async move { x.await.ignore_view_delete().map(|_| ()) }) } } @@ -65,6 +121,11 @@ where drop(js_sys::Promise::from(Self::new(x))) } + /// [`Self::spawn`] with an explicit DevTools task label. + pub fn spawn_named> + 'static>(name: &str, x: U) { + drop(js_sys::Promise::from(Self::new_named(name, x))) + } + pub fn spawn_throttled> + 'static>(x: U) { drop(js_sys::Promise::from(Self::new_throttled(x))) } @@ -94,7 +155,8 @@ where Result: IntoJsResult + 'static, { fn from(fut: ApiFuture) -> Self { - future_to_promise(async move { Ok(fut.0.await?).into_js_result() }) + // Await `fut` NOT `fut.0` or the stack frame is lost in Chrome. + future_to_promise(async move { Ok(fut.await?).into_js_result() }) } } @@ -145,8 +207,33 @@ where self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll { - let mut fut = unsafe { self.map_unchecked_mut(|s| &mut s.0) }; - fut.as_mut().poll(cx) + // SAFETY: structural projection only — neither field is moved, and + // the inner future stays behind its own `Pin>`. + let Self(inner, task) = unsafe { self.get_unchecked_mut() }; + let Some(task) = task else { + return inner.as_mut().poll(cx); + }; + + let mut result = None; + let mut thunk = || result = Some(inner.as_mut().poll(cx)); + + // SAFETY: the lifetime is erased for storage only. The entry is + // pushed and popped within this scope, and `run` invokes the + // trampoline synchronously, so the pointer never outlives `thunk`. + let short: *mut (dyn FnMut() + '_) = &mut thunk; + let long: *mut (dyn FnMut() + 'static) = unsafe { std::mem::transmute(short) }; + POLL_STACK.with(|x| x.borrow_mut().push(long)); + let _ = TRAMPOLINE.with(|x| task.run(x.as_ref().unchecked_ref())); + POLL_STACK.with(|x| x.borrow_mut().pop()); + + #[allow(clippy::drop_non_drop)] + drop(thunk); + + match result { + Some(result) => result, + // `run` threw without invoking the trampoline. + None => inner.as_mut().poll(cx), + } } } diff --git a/rust/perspective-js/test/js/duckdb/client.spec.js b/rust/perspective-js/test/js/duckdb/client.spec.js index 4e18eccbd0..7ac44a377b 100644 --- a/rust/perspective-js/test/js/duckdb/client.spec.js +++ b/rust/perspective-js/test/js/duckdb/client.spec.js @@ -20,6 +20,7 @@ describeDuckDB("client", (getClient) => { expect(tables).toEqual([ "memory.coerce_types", "memory.superstore", + "memory.temporal_test", "memory.underscore_test", ]); }); diff --git a/rust/perspective-python/src/client/client_async.rs b/rust/perspective-python/src/client/client_async.rs index d7cce0dbfb..a9ece51f26 100644 --- a/rust/perspective-python/src/client/client_async.rs +++ b/rust/perspective-python/src/client/client_async.rs @@ -59,7 +59,7 @@ fn parse_list_flatten(value: Option) -> PyResult> { Some("zip") => Ok(Some(ListFlatten::Zip)), Some("cartesian") => Ok(Some(ListFlatten::Cartesian)), Some("stringify") => Ok(Some(ListFlatten::Stringify)), - Some(x) => Err(PyValueError::new_err(format!("Unknown `list_flatten`"))), + Some(_x) => Err(PyValueError::new_err("Unknown `list_flatten`".to_string())), } } diff --git a/rust/perspective-viewer/build.mjs b/rust/perspective-viewer/build.mjs index f8a330b6fc..48a5126ba1 100644 --- a/rust/perspective-viewer/build.mjs +++ b/rust/perspective-viewer/build.mjs @@ -41,7 +41,10 @@ export async function build_all() { if (!process.env.PSP_SKIP_WASM) { execSync( `cargo bundle --target=${get_host()} -- perspective_viewer ${IS_DEBUG ? "" : "--release"}`, - INHERIT, + { + ...INHERIT, + env: { ...process.env, PSP_ROOT_DIR: "../.." }, + }, ); await compress( diff --git a/rust/perspective-viewer/src/rust/components/column_selector/aggregate_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector/aggregate_selector.rs index 15c10110a7..38cfe6c703 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/aggregate_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/aggregate_selector.rs @@ -10,9 +10,9 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -use std::collections::HashSet; use std::rc::Rc; +use itertools::Itertools; use perspective_client::config::*; use yew::prelude::*; @@ -163,8 +163,7 @@ impl AggregateSelector { Aggregate::MultiAggregate(x, _) => Some(x), _ => None, }) - .collect::>() - .into_iter() + .unique() .map(|x| { SelectItem::OptGroup( x.clone().into(), diff --git a/rust/perspective-viewer/src/rust/components/main_panel/msg.rs b/rust/perspective-viewer/src/rust/components/main_panel/msg.rs index a9b2281238..73d85272ae 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/msg.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/msg.rs @@ -16,8 +16,8 @@ use crate::components::panel_menu::PanelCommand; pub enum MainPanelMsg { PointerEvent(web_sys::PointerEvent), - /// The `Workspace` staged-panel set changed (`staged_changed` PubSub — - /// see `Workspace::stage_panel`): re-render so cells/tabs/wrappers + /// A panel's `PanelPhase` changed (`staged_changed` PubSub — see + /// `Workspace::insert_panel`/`promote`): re-render so cells/tabs/wrappers /// reflect it and `reconcile` inserts a promoted panel. Subscribed on /// MainPanel's OWN scope — the root's `LayoutChanged` delivery proved /// unreliable from element-API task contexts (promoted panels stranded diff --git a/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs b/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs index d33448b727..135c0e530d 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs @@ -41,7 +41,7 @@ const LAYOUT_PHYSICS: LayoutPhysics = LayoutPhysics { impl MainPanel { /// Size each STAGED panel's hidden wrapper (`.psp-staging` — see - /// `MainPanel::render` and `Workspace::stage_panel`) to its PREDICTED + /// `MainPanel::render` and `PanelPhase::Staging`) to its PREDICTED /// post-insert cell: an equal share of the layout box's width (the /// reconcile insert splits the root horizontally with equal /// redistribution) minus the frame-chrome fallback the presize sweep @@ -139,7 +139,7 @@ impl MainPanel { let layout: RegularLayout = el.unchecked_into(); let panel_ids = &ctx.props().panel_ids; - // Whole-element restore stages its saved layout tree on the Workspace + // `restoreWorkspace` stages its saved layout tree on the Workspace // (the model; regular-layout is a slave view). Apply it here, BEFORE // the insert reconcile, and seed `inserted` from its panel names — so // restored panels mount directly at their saved positions in ONE @@ -165,8 +165,8 @@ impl MainPanel { // STAGED panels are withheld from the layout: their first draw // is completing in the hidden staging wrapper (see - // `Workspace::stage_panel`). The promote re-render clears the - // flag, and this loop then inserts the already-drawn panel. + // `PanelPhase::Staging`). The promote re-render flips the phase, + // and this loop then inserts the already-drawn panel. if ctx.props().workspace.is_staged(id) { continue; } diff --git a/rust/perspective-viewer/src/rust/components/viewer/msg.rs b/rust/perspective-viewer/src/rust/components/viewer/msg.rs index 26866fd5e1..f1d54cd1f3 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/msg.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/msg.rs @@ -57,8 +57,9 @@ pub enum PerspectiveViewerMsg { PreloadFontsUpdate, /// Element-level reset (the public `reset()` API): reset EVERY panel and - /// clear the cross-filter overlay, symmetric with whole-element - /// `save`/`restore`. The `bool` also clears expressions/column settings. + /// clear the cross-filter overlay, symmetric with + /// `saveWorkspace`/`restoreWorkspace`. The `bool` also clears + /// expressions/column settings. Reset(bool, Option), /// Reset ONLY the named panel — or the active panel when `None` — to its @@ -86,10 +87,10 @@ pub enum PerspectiveViewerMsg { /// I6) — carrying any teardown error, which was previously dropped. ClosePanel(String, Option), - /// Whole-element `restore` finished replacing the panel set in the + /// `restoreWorkspace` finished replacing the panel set in the /// `Workspace` (new models inserted, old panels ejected, layout staged): /// activate the named panel, re-subscribe the per-panel wiring, and - /// re-render — the SINGLE visible commit of the whole restore. + /// re-render — the SINGLE visible commit of the restore. CommitWorkspaceRestore(String), /// Duplicate the named panel: snapshot its config into a new independent diff --git a/rust/perspective-viewer/src/rust/components/viewer/panels.rs b/rust/perspective-viewer/src/rust/components/viewer/panels.rs index 3c6e0b11f0..2b273e1de8 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/panels.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/panels.rs @@ -27,7 +27,7 @@ use super::wiring::{ clear_active_callbacks, create_active_subscriptions, inject_active_callbacks, subscribe_panel_titles, }; -use crate::config::{ViewerConfigInitial, ViewerConfigUpdate}; +use crate::config::ViewerConfigInitial; use crate::queries::*; use crate::renderer::Renderer; use crate::session::*; @@ -124,7 +124,7 @@ impl PerspectiveViewer { } } - /// Whole-element `restore`'s single commit: the `Workspace` already holds + /// `restoreWorkspace`'s single commit: the `Workspace` already holds /// the final panel set (models inserted, olds ejected) and the staged /// layout. Re-subscribe the per-panel title wiring (the set changed /// wholesale), activate the restored panel, and re-render — `MainPanel`'s @@ -193,7 +193,8 @@ impl PerspectiveViewer { }) .await?; - let update = ViewerConfigUpdate::decode(&config.encode()?)?; + // TODO(texodus): what the ****? + let update = ViewerConfigInitial::decode(&config.encode()?)?; let client = panel.session.get_client(); let new_id = create_panel( &elem, @@ -235,7 +236,7 @@ impl PerspectiveViewer { &workspace, ¬ify, None, - ViewerConfigInitial::new(table_name).into(), + ViewerConfigInitial::new(table_name), client, ) .await?; @@ -279,7 +280,7 @@ impl PerspectiveViewer { &workspace, ¬ify, None, - ViewerConfigInitial::new(table).into(), + ViewerConfigInitial::new(table), Some(client), ) .await?; @@ -308,10 +309,10 @@ impl PerspectiveViewer { let presentation = ctx.props().presentation.clone(); let workspace = ctx.props().workspace.clone(); ApiFuture::spawn(async move { - let default_theme = presentation.get_default_theme_name().await; - if let Some(renderer) = workspace.active_renderer() - && let Some(theme) = renderer.theme().or(default_theme) - { + // Only an EXPLICITLY themed panel mirrors onto the host; an + // unpinned one inherits whatever the host already shows, so + // activating it must not restate (and thereby pin) that value. + if let Some(theme) = workspace.active_renderer().and_then(|x| x.theme()) { presentation.set_theme_name(Some(&theme)).await?; } diff --git a/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs b/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs index 926a3ff35c..126641830d 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs @@ -52,18 +52,17 @@ impl PerspectiveViewer { ctx: &Context, props: PresentationProps, ) -> bool { - // Default-theme fan-out: when the registry default (first available - // theme) changes — the async theme discovery resolving at boot, or a - // `resetThemes` — push the new default into every panel's renderer - // cache (locked draws stamp the effective theme from it), and - // restyle the panels whose captured `--psp-*` CSS is STALE against - // the new effective value (`Renderer::needs_restyle` — the plugin's - // captured theme is the baseline; plugins only re-read CSS at - // `restyle()`/first-draw, so a plain redraw would not repaint a - // panel that first drew before discovery resolved, while a panel - // that captured post-discovery — or owes its first paint — restyles - // nothing). The outer default-diff scopes the scan; the per-panel - // gate is state, never call history or DOM state. + // Boot fill-in: theme discovery resolving turns an empty registry + // into a real one, and any panel created before that has no theme to + // stamp. Give those — and ONLY those — the new default, then restyle + // the ones whose captured `--psp-*` CSS is now stale + // (`Renderer::needs_restyle`; plugins re-read CSS only at + // `restyle()`/first-draw, so a plain redraw would not repaint them). + // + // A panel that already has a theme is never touched here: `theme` is + // concrete state, so re-ordering the registry — or any other + // `resetThemes` that leaves a panel's theme available — must repaint + // nothing. let old_default = self.presentation_props.available_themes.first().cloned(); let new_default = props.available_themes.first().cloned(); if old_default != new_default { @@ -74,7 +73,11 @@ impl PerspectiveViewer { .into_iter() .filter_map(|id| ctx.props().workspace.panel(&id)) { - panel.renderer.set_default_theme(new_default.clone()); + if panel.renderer.theme().is_some() { + continue; + } + + panel.renderer.set_theme(new_default.clone()); if panel.renderer.needs_restyle() { let renderer = panel.renderer.clone(); crate::utils::spawn_owned("default-theme-restyle", async move { diff --git a/rust/perspective-viewer/src/rust/components/viewer/wiring.rs b/rust/perspective-viewer/src/rust/components/viewer/wiring.rs index 3a02f11680..a95d4d2001 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/wiring.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/wiring.rs @@ -388,11 +388,12 @@ pub(super) fn create_shared_subscriptions(ctx: &Context) -> V .add_notify_listener(&cb) }; - // Staged-set transitions (`Workspace::stage_panel`/`clear_staged`) → a - // root re-render for the stage-level chrome (`only-child` class, - // binding resync). `MainPanel` inserts/reveals promoted panels via its - // OWN `staged_changed` subscription (`MainPanelMsg::StagedChanged`) — - // see `WorkspaceData::staged_changed`. + // Panel-phase transitions (`Workspace::insert_panel` at + // `PanelPhase::Staging` / `Workspace::promote`) → a root re-render for + // the stage-level chrome (`only-child` class, binding resync). + // `MainPanel` inserts/reveals promoted panels via its OWN + // `staged_changed` subscription (`MainPanelMsg::StagedChanged`) — see + // `WorkspaceData::staged_changed`. let staged_sub = { let cb = ctx.link().callback(|_: ()| LayoutChanged); ctx.props() diff --git a/rust/perspective-viewer/src/rust/config/viewer_config.rs b/rust/perspective-viewer/src/rust/config/viewer_config.rs index 23d8a29b43..6c05d5ced6 100644 --- a/rust/perspective-viewer/src/rust/config/viewer_config.rs +++ b/rust/perspective-viewer/src/rust/config/viewer_config.rs @@ -24,7 +24,7 @@ use crate::renderer::ColumnConfigMap; /// The state of an entire `custom_elements::PerspectiveViewerElement` component /// and its `Plugin`: the element-level `settings` flag plus the per-panel -/// [`PanelViewerConfig`]. The split exists so the whole-element config format +/// [`PanelViewerConfig`]. The split exists so the workspace config format /// can serialize panel entries *without* a `settings` key (it is element-level /// state there, carried by the top-level `active` field instead), while the /// single-panel format flattens back to the legacy shape. @@ -39,7 +39,7 @@ pub struct ViewerConfig { /// The per-panel state of a [`ViewerConfig`] — everything except the /// element-level `settings` flag. This is the `panels` entry type of the -/// whole-element config format. +/// workspace config format. #[derive(Debug, Default, Serialize, PartialEq, TS)] pub struct PanelViewerConfig { /// The `@perspective-dev/viewer` version that wrote this config, @@ -315,15 +315,53 @@ impl From for ViewerConfigUpdate { } } -// There is deliberately NO `TryFrom` here. Requiring a -// `table` is a property of the CREATION ENTRY POINTS — `addPanel`'s -// argument type, `WorkspaceConfigUpdate::panels`, and the agent's -// `add_panel` decode — each of which already has a `ViewerConfigInitial` -// in hand. An update→initial conversion exists only to let a route holding -// a PATCH pretend it is creating from scratch, which is how `restore`'s -// upsert acquired the gate and started rejecting the table-less -// restore-then-`load` contract. Its absence is what keeps that from -// recurring: `create_panel` takes an update, so no caller needs one. +/// The rejection every panel-creating route without a `table` resolves to. +pub const CREATE_REQUIRES_TABLE: &str = "Cannot create a panel without a `table` — `load()` a \ + `Client` and include `table` in the config, or use \ + `addPanel()`"; + +fn down(value: OptionalUpdate) -> Option { + match value { + OptionalUpdate::Update(value) => Some(value), + OptionalUpdate::Missing | OptionalUpdate::SetDefault => None, + } +} + +impl TryFrom for ViewerConfigInitial { + type Error = ApiError; + + fn try_from(value: ViewerConfigUpdate) -> Result { + // Exhaustive (no `..`) on purpose — the same drift alarm as the + // `From` impl above. `settings` is element-level + // state, stripped by `restore()` before resolution; discarded here. + let ViewerConfigUpdate { + version, + plugin, + title, + table, + theme, + settings: _settings, + plugin_config, + columns_config, + view_config, + } = value; + + let OptionalUpdate::Update(table) = table else { + return Err(ApiError::new(CREATE_REQUIRES_TABLE)); + }; + + Ok(Self { + table, + version: down(version), + plugin: down(plugin), + title: down(title), + theme: down(theme), + plugin_config: down(plugin_config), + columns_config: down(columns_config), + view_config, + }) + } +} impl std::fmt::Display for ViewerConfigUpdate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -360,50 +398,6 @@ pub type VersionUpdate = OptionalUpdate; pub type ColumnConfigUpdate = OptionalUpdate; pub type PluginConfigUpdate = OptionalUpdate>; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn initial_requires_a_table() { - let json = serde_json::json!({ "group_by": ["State"] }); - let err = serde_json::from_value::(json).unwrap_err(); - assert!(format!("{err}").contains("table")); - } - - /// The creation → patch direction is the only one that exists (see the - /// note above the `From` impl); a `table` becomes a concrete update and - /// the element-level `settings` is not carried. - #[test] - fn initial_widens_to_an_update() { - let json = serde_json::json!({ - "table": "superstore", - "plugin": "Datagrid", - "group_by": ["State"], - }); - - let initial: ViewerConfigInitial = serde_json::from_value(json).unwrap(); - let update = ViewerConfigUpdate::from(initial); - assert!(matches!(&update.table, OptionalUpdate::Update(x) if x == "superstore")); - assert!(matches!(&update.settings, OptionalUpdate::Missing)); - assert!(matches!(&update.plugin, OptionalUpdate::Update(x) if x == "Datagrid")); - assert_eq!( - update.view_config.group_by.as_deref(), - Some(&["State".to_owned()][..]) - ); - } - - /// A table-less patch is a legitimate creation input now that - /// `create_panel` takes an update — the deferred panel a `load()` - /// binds. Nothing in this module may reject it. - #[test] - fn a_table_less_update_is_representable() { - let json = serde_json::json!({ "group_by": ["State"] }); - let update: ViewerConfigUpdate = serde_json::from_value(json).unwrap(); - assert!(matches!(&update.table, OptionalUpdate::Missing)); - } -} - /// Handles `{}` when included as a field with `#[serde(default)]`. impl Default for OptionalUpdate { fn default() -> Self { diff --git a/rust/perspective-viewer/src/rust/config/workspace_config.rs b/rust/perspective-viewer/src/rust/config/workspace_config.rs index a496a646a0..347956becd 100644 --- a/rust/perspective-viewer/src/rust/config/workspace_config.rs +++ b/rust/perspective-viewer/src/rust/config/workspace_config.rs @@ -16,7 +16,7 @@ use perspective_client::config::Filter; use crate::config::{PanelViewerConfig, ViewerConfigInitial}; -/// The whole-element config format (`{version, active?, layout, panels}`) — +/// The workspace config format (`{version, active?, layout, panels}`) — /// the multi-panel counterpart of the single-panel [`ViewerConfig`] — as /// emitted by [`PerspectiveViewerElement::save`]. /// @@ -58,7 +58,7 @@ pub struct WorkspaceConfig { pub masters: Vec, } -/// The parse target of a whole-element config in +/// The parse target of a workspace config in /// [`PerspectiveViewerElement::restoreWorkspace`]. Mirrors /// [`WorkspaceConfig`], but `panels` entries are [`ViewerConfigInitial`]s — /// every entry creates a NEW panel, so `table` is required by type (a diff --git a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs index f7275e23da..855e399048 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs @@ -84,11 +84,11 @@ extern "C" { #[wasm_bindgen(typescript_type = "GetClientOptions")] pub type JsGetClientOptions; - /// `restoreWorkspace()` argument: a whole-element config update. + /// `restoreWorkspace()` argument: a workspace config update. #[wasm_bindgen(typescript_type = "WorkspaceConfigUpdate")] pub type JsWorkspaceConfigUpdate; - /// `saveWorkspace()` return: a whole-element config. + /// `saveWorkspace()` return: a workspace config. #[wasm_bindgen(typescript_type = "Promise")] pub type JsWorkspaceConfigPromise; @@ -521,7 +521,7 @@ impl PerspectiveViewerElement { renderer .clone() .render_task(|guard| async move { - renderer.set_default_theme(presentation.get_default_theme_name().await); + seed_panel_theme(&presentation, &renderer).await; renderer.stamp_theme(None); let jstable = JsFuture::from(promise) .await @@ -536,7 +536,7 @@ impl PerspectiveViewerElement { }; if let Some(notify) = ¬ify { - place_reserved(&workspace, notify); + place_reserved(&workspace, notify, true); } let _plugin = renderer.ensure_plugin_selected()?; @@ -594,7 +594,35 @@ impl PerspectiveViewerElement { // commits already applied live (`commit_view_config`). let owned_window = session.take_pending_load(generation).is_some(); let discard = if owned_window && notify.is_some() { - workspace.take_reserved() + match workspace.take_reserved() { + Some(panel) => Some((panel, None)), + // The one-shot claim read: a table-less + // `restore` claimed the reservation, and no + // table has bound nor is pending — evict the + // panel `CREATE_REQUIRES_TABLE` forbids. + None if workspace + .resolve_claim() + .is_some_and(|has_table| !has_table) + && session.get_table().is_none() + && session.pending_table().is_none() => + { + let evicted = renderer + .slot_name() + .map(PanelId::from) + .and_then(|id| workspace.remove_panel(&id)); + + if evicted.is_some() + && let Some(notify) = ¬ify + { + notify.emit(()); + } + + evicted.map(|panel| { + (panel, Some(ApiError::new(CREATE_REQUIRES_TABLE))) + }) + }, + None => None, + } } else { None }; @@ -613,13 +641,19 @@ impl PerspectiveViewerElement { Err(e) => { session.take_pending_load(generation); if let Some(notify) = ¬ify { - place_reserved(&workspace, notify); + place_reserved(&workspace, notify, true); } session.set_error(false, e.clone()).await?; Err(e) }, - Ok(Some(panel)) => eject_panel(panel).await, + Ok(Some((panel, error))) => { + eject_panel(panel).await?; + match error { + Some(e) => Err(e), + None => Ok(()), + } + }, Ok(None) => Ok(()), } })) @@ -931,18 +965,18 @@ impl PerspectiveViewerElement { /// optional `{panel}` selector. /// /// If `panel` names no existing panel, a NEW panel is created with that id - /// and the config restored into it (an upsert). As with a created panel, - /// the element-level `settings`/`theme` fields are ignored in that case. - /// Unlike [`Self::addPanel`], the argument is a PATCH, so a `table` is - /// optional: creating without one yields a DEFERRED panel that the next - /// [`Self::load`] binds. Such a panel renders but cannot be serialized — - /// [`Self::save`] rejects with "Panel has no `table`" until it is bound. + /// and the config restored into it (an upsert). Creation REQUIRES a + /// `table` — the same rule [`Self::addPanel`] enforces in its argument + /// type — and a would-create call without one REJECTS before any state + /// (including `settings`) is applied: with no panel to target and no + /// `table`, the patch has no data arrival path. In particular, on an + /// element with zero panels every `restore` must carry a `table`. /// /// On an empty element with a pending [`Self::load`] whose payload is not /// yet classified, the active-target form (no `panel`) instead claims and /// restores into that load's reserved first panel — see [`Self::load`]. /// - /// This restores a SINGLE panel; a whole-element config (with a `panels` + /// This restores a SINGLE panel; a workspace config (with a `panels` /// map) must be applied via [`Self::restoreWorkspace`] — its `panels` / /// `layout` keys are ignored here. /// @@ -990,10 +1024,6 @@ impl PerspectiveViewerElement { suppress_errors, } = parse_options(options); - // `suppress_errors` failures reject the returned Promise WITHOUT - // committing to the viewer's visible error state — for programmatic - // callers (the agent's `set_view_config`) whose failed patches are - // caller feedback, not user-facing faults. let errors = if suppress_errors.unwrap_or_default() { RestoreErrors::Suppress } else { @@ -1006,26 +1036,35 @@ impl PerspectiveViewerElement { let _effect = effect; let id = name.map(PanelId::from); let mut update = ViewerConfigUpdate::decode(&update)?; - - // `settings` is ELEMENT-level chrome, not panel state — it - // rides in this object only because the legacy single-panel - // `restore()` has always carried it. Apply it HERE and hand - // the panel pipeline a purely per-panel update: the - // panel-CREATING branch below converts to - // `ViewerConfigInitial`, which has no `settings` field at - // all, so a `restore()` against an empty element — the first - // call every embedding makes — would otherwise drop it - // silently. Applied BEFORE the panel work so the plugin - // draws once, already at its final size. - // BOTH halves move together, or the flag and the chrome - // disagree: `set_settings_before_open` writes the persisted - // `is_settings_open` (what `save()` reports) and the host - // `settings` attribute, while `ToggleSettingsComplete` - // performs the component's own toggle. `restore_and_render` - // used to do the first for the panel-UPDATING path only — - // which the creating path never reaches, since - // `ViewerConfigInitial` has no `settings` field to carry. let settings = std::mem::replace(&mut update.settings, OptionalUpdate::Missing); + enum Target { + Existing { panel: Panel, active: bool }, + Claimed(Panel), + Create(Box), + } + + let notify = this.layout_changed_notify(); + let target = match this.workspace.panel_or_active(id.as_ref()) { + // An existing (or the active) panel — update it in place. + Some(panel) => { + let active = this.workspace.active_id().as_ref() == Some(&panel.id); + Target::Existing { panel, active } + }, + None => { + let has_table = matches!(&update.table, OptionalUpdate::Update(_)); + match id + .is_none() + .then(|| place_reserved(&this.workspace, ¬ify, has_table)) + .flatten() + { + Some(panel) => Target::Claimed(panel), + None => Target::Create(Box::new(ViewerConfigInitial::try_from( + std::mem::take(&mut update), + )?)), + } + }, + }; + if !matches!(settings, OptionalUpdate::Missing) { if let OptionalUpdate::Update(open) = settings { this.presentation.set_settings_before_open(open); @@ -1039,10 +1078,8 @@ impl PerspectiveViewerElement { receiver.await.unwrap_or_log(); } - match this.workspace.panel_or_active(id.as_ref()) { - // An existing (or the active) panel — update it in place. - Some(panel) => { - let active = this.workspace.active_id().as_ref() == Some(&panel.id); + match target { + Target::Existing { panel, active } => { restore_panel( &panel.session, &panel.renderer, @@ -1054,61 +1091,30 @@ impl PerspectiveViewerElement { ) .await }, - // No existing panel matched. The active-target form - // (`panel: None`) CLAIMS a pending `load()`'s reserved panel - // — placing it and restoring into it — so a `restore` fired - // right after an unawaited `load(promise)` configures the - // panel that load's payload will bind, per the call-site - // ordering contract in [`Self::load`]. Named upserts and - // reservation-less elements create a fresh panel instead, - // routing through the shared `create_panel` - // (`RestoreMode::Fresh`) pipeline so the new panel's id is - // the requested `panel`. - None => { - let notify = this.layout_changed_notify(); - let claimed = id - .is_none() - .then(|| place_reserved(&this.workspace, ¬ify)) - .flatten(); - match claimed { - Some(panel) => { - restore_panel( - &panel.session, - &panel.renderer, - &this.presentation, - &this.workspace, - RestoreMode::Existing { active: true }, - update, - errors, - ) - .await - }, - None => { - // The panel-creating upsert. `restore`'s - // argument is a PATCH, so a `table` is - // optional here as it is everywhere else on - // this path: an update carrying none creates - // a deferred panel, which the next `load()` - // binds (it targets `active_panel()` before - // reserving one of its own). The - // table-required rule belongs to the - // CREATION types — `addPanel`, - // `restoreWorkspace`'s `panels`, the agent's - // `add_panel` — which enforce it in their - // own signatures. - create_panel( - &this.elem, - &this.presentation, - &this.workspace, - ¬ify, - id, - update, - None, - ) - .await?; - Ok(()) - }, - } + Target::Claimed(panel) => { + restore_panel( + &panel.session, + &panel.renderer, + &this.presentation, + &this.workspace, + RestoreMode::Existing { active: true }, + update, + errors, + ) + .await + }, + Target::Create(config) => { + create_panel( + &this.elem, + &this.presentation, + &this.workspace, + ¬ify, + id, + *config, + None, + ) + .await?; + Ok(()) }, } }); @@ -1116,8 +1122,8 @@ impl PerspectiveViewerElement { js_sys::Promise::from(fut).unchecked_into() } - /// Restore the ENTIRE element from a whole-element - /// [`WorkspaceConfigUpdate`] (`{version, active?, layout, panels, ...}`) — + /// Restore the ENTIRE element from a [`WorkspaceConfigUpdate`] + /// (`{version, active?, layout, panels, ...}`) — /// the multi-panel counterpart of [`Self::restore`]. Every existing panel /// is replaced by the `panels` entries, and the layout tree + master/detail /// cross-filter state re-applied. Unlike [`Self::restore`], this never @@ -1241,11 +1247,11 @@ impl PerspectiveViewerElement { js_sys::Promise::from(fut).unchecked_into() } - /// Save the ENTIRE element to a whole-element [`WorkspaceConfig`] + /// Save the ENTIRE element to a [`WorkspaceConfig`] /// (`{version, active?, layout, panels, ...}`) — the multi-panel /// counterpart of [`Self::save`]. Unlike [`Self::save`] (which emits a /// single `ViewerConfig` for one panel), this ALWAYS emits the - /// whole-element format, restorable via [`Self::restoreWorkspace`]. + /// workspace format, restorable via [`Self::restoreWorkspace`]. /// /// # JavaScript Examples /// @@ -1385,9 +1391,10 @@ impl PerspectiveViewerElement { /// Reset a panel's `ViewerConfig` to its data-relative default. /// /// Without a `panel`, this is ELEMENT-LEVEL: EVERY panel is reset and the - /// cross-filter overlay cleared (symmetric with whole-element - /// [`Self::save`] / [`Self::restore`]). With `{panel}`, only that panel is - /// reset — the other panels and the overlay are left untouched. + /// cross-filter overlay cleared (symmetric with + /// [`Self::saveWorkspace`] / [`Self::restoreWorkspace`]). With `{panel}`, + /// only that panel is reset — the other panels and the overlay are left + /// untouched. /// /// # Arguments /// @@ -1638,17 +1645,15 @@ impl PerspectiveViewerElement { /// ``` #[wasm_bindgen] pub fn restyleElement(&self) -> ApiFuture { - clone!(self.workspace, self.presentation); + clone!(self.workspace); let effect = workspace.effects().guard(); ApiFuture::new(async move { let _effect = effect; - let default = presentation.get_default_theme_name().await; for panel in workspace .panel_ids() .into_iter() .filter_map(|id| workspace.panel(&id)) { - panel.renderer.set_default_theme(default.clone()); panel.renderer.restyle_all().await?; } @@ -1656,6 +1661,21 @@ impl PerspectiveViewerElement { }) } + #[wasm_bindgen] + pub fn getThemes(&self) -> ApiFuture { + clone!(self.presentation); + ApiFuture::new(async move { + let x = presentation + .get_available_themes() + .await? + .iter() + .cloned() + .collect::>(); + + Ok(JsValue::from(x)) + }) + } + /// Set the available theme names available in the status bar UI. /// /// Calling [`Self::resetThemes`] may cause the current theme to switch, @@ -1676,43 +1696,37 @@ impl PerspectiveViewerElement { let effect = workspace.effects().guard(); ApiFuture::new(async move { let _effect = effect; - let themes: Option> = themes - .unwrap_or_default() - .iter() - .map(|x| x.as_string()) - .collect(); + // `None` (re-parse the document) must survive the conversion — + // mapping BEFORE defaulting is what keeps that branch reachable + // from JavaScript at all. + let themes: Option> = match themes { + None => None, + Some(themes) => themes.iter().map(|x| x.as_string()).collect(), + }; - let theme_name = presentation.get_selected_theme_name().await; - presentation.reset_available_themes(themes).await; + let previous = presentation.active_theme_name_sync(); + let active = presentation.reset_themes(themes).await?; let available = presentation.get_available_themes().await?; - let reset_theme = available - .iter() - .find(|y| theme_name.as_ref() == Some(y)) - .cloned(); - - presentation.set_theme_name(reset_theme.as_deref()).await?; - let new_default = presentation.get_default_theme_name().await; for panel in workspace .panel_ids() .into_iter() .filter_map(|id| workspace.panel(&id)) { - // Availability applies to PANELS too: a panel pinned to a - // theme outside the new set follows the host — clear the pin - // so it renders (and `save`s) the new registry default, the - // same keep-if-available-else-default rule applied to the - // host selection above. - if panel - .renderer - .theme() - .is_some_and(|t| !available.contains(&t)) - { - panel.renderer.set_theme(None); - } - - panel.renderer.set_default_theme(new_default.clone()); - if panel.renderer.needs_restyle() { - panel.renderer.restyle_all().await?; + let theme = panel.renderer.theme(); + + // A panel follows the host only when it was TRACKING it (it + // holds the host's previous theme, which is how every panel + // born without an explicit one starts — and what keeps the + // active panel and the host in agreement), or when its own + // theme has left the registry. A panel explicitly set to a + // different, still-available theme is untouched: re-ordering + // alone must repaint nothing. + let stale = theme.as_ref().is_none_or(|x| !available.contains(x)); + if (stale || theme == previous) && theme != active { + panel.renderer.set_theme(active.clone()); + if panel.renderer.needs_restyle() { + panel.renderer.restyle_all().await?; + } } } @@ -1824,8 +1838,6 @@ impl PerspectiveViewerElement { let notify = self.layout_changed_notify(); ApiFuture::new(async move { let _effect = effect; - // The table-required guarantee is HERE, in the argument type - // and this decode — `create_panel` itself takes a patch. let config = ViewerConfigInitial::decode(&config)?; let id = create_panel( &elem, @@ -1833,7 +1845,7 @@ impl PerspectiveViewerElement { &workspace, ¬ify, None, - config.into(), + config, None, ) .await?; diff --git a/rust/perspective-viewer/src/rust/custom_events.rs b/rust/perspective-viewer/src/rust/custom_events.rs index 1774e9b6f2..c50a8e0853 100644 --- a/rust/perspective-viewer/src/rust/custom_events.rs +++ b/rust/perspective-viewer/src/rust/custom_events.rs @@ -150,6 +150,10 @@ fn dispatch_config_update( renderer: &Renderer, presentation: &Presentation, ) { + if session.get_table().is_none() && session.pending_table().is_none() { + return; + } + clone!(session, renderer, presentation); let elem = elem.clone(); let tracker = session.clone(); diff --git a/rust/perspective-viewer/src/rust/js/regular_layout.rs b/rust/perspective-viewer/src/rust/js/regular_layout.rs index 999f6bc350..75e80f073e 100644 --- a/rust/perspective-viewer/src/rust/js/regular_layout.rs +++ b/rust/perspective-viewer/src/rust/js/regular_layout.rs @@ -235,9 +235,9 @@ impl Layout { } /// Rewrite every panel id through `f`, returning the remapped tree. Ids for - /// which `f` returns `None` are left unchanged. Used by whole-element - /// `restore`, which recreates panels under fresh (collision-free) ids and - /// must point the saved layout tree at them. + /// which `f` returns `None` are left unchanged. Used by `restoreWorkspace`, + /// which recreates panels under fresh (collision-free) ids and must point + /// the saved layout tree at them. pub fn remap(&self, f: &impl Fn(&str) -> Option) -> Layout { match self { Layout::Split(split) => Layout::Split(SplitLayout { diff --git a/rust/perspective-viewer/src/rust/presentation.rs b/rust/perspective-viewer/src/rust/presentation.rs index 30e8b16f3d..51a8b73a55 100644 --- a/rust/perspective-viewer/src/rust/presentation.rs +++ b/rust/perspective-viewer/src/rust/presentation.rs @@ -15,7 +15,7 @@ pub mod drag_helpers; mod props; mod sheets; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::ops::Deref; use std::rc::Rc; @@ -85,6 +85,16 @@ pub struct PresentationHandle { /// concurrent `get_available_themes` calls await one parse instead of /// racing their own. theme_init: Mutex<()>, + + /// Whether the host's theme was ever EXPLICITLY chosen — authored as a + /// `theme` attribute, or set by name through [`Self::set_theme_name`]. + /// + /// `false` means the host merely displays the registry default, which + /// [`Self::reset_themes`] is free to move; `true` pins the selection + /// until it leaves the registry. The `theme` attribute alone cannot + /// carry this, because `init` stamps it unconditionally so the document + /// cascade has a theme to match. + theme_selected: Cell, is_settings_open: RefCell, open_column_settings: RefCell, is_workspace: RefCell>, @@ -158,6 +168,7 @@ impl Presentation { agent: Default::default(), themes: Default::default(), theme_init: Default::default(), + theme_selected: Cell::new(elem.get_attribute("theme").is_some()), is_workspace: Default::default(), settings_open_changed: Default::default(), settings_before_open_changed: Default::default(), @@ -315,22 +326,24 @@ impl Presentation { index.and_then(|x| themes.get(x).cloned()) } - /// The default theme (first registered), used to resolve a panel's - /// effective theme when it has no per-panel override. `None` if no + /// The theme a NEW panel is born with: the host's if it has one, else + /// the registry default. Synchronous, because panel creation is — the + /// registry fallback is `None` until the registry first parses, which + /// [`crate::tasks::seed_panel_theme`] fills in. + pub fn active_theme_name_sync(&self) -> Option { + self.0 + .viewer_elem + .get_attribute("theme") + .or_else(|| self.0.themes.borrow().as_ref()?.first().cloned()) + } + + /// The registry default — the FIRST registered theme, which a panel or + /// host resolves to only when it has no theme of its own. `None` if no /// themes exist. pub async fn get_default_theme_name(&self) -> Option { self.get_available_themes().await.ok()?.first().cloned() } - /// SYNC read of the registry default theme name, derived on demand from - /// the memoized registry — `None` until the registry first parses (or - /// when no themes exist). For synchronous stamping paths that must not - /// await registry init; prefer [`Self::get_default_theme_name`] where - /// awaiting is acceptable. - pub fn default_theme_name_sync(&self) -> Option { - self.0.themes.borrow().as_ref()?.first().cloned() - } - fn set_theme_attribute(&self, theme: Option<&str>) -> ApiResult<()> { if let Some(theme) = theme { Ok(self.0.viewer_elem.set_attribute("theme", theme)?) @@ -341,12 +354,43 @@ impl Presentation { pub async fn reset_theme(&self) -> ApiResult<()> { *self.0.is_workspace.borrow_mut() = None; - let themes = self.get_available_themes().await?; - let default_theme = themes.first().map(|x| x.as_str()); - self.set_theme_name(default_theme).await?; + self.set_theme_name(None).await?; Ok(()) } + /// Adopt `themes` as the available set, KEEPING the host's theme unless + /// it was never explicitly chosen or has left the set — the only two + /// cases in which re-ordering the registry may move the viewer. + /// + /// Always re-stamps and re-emits, because the available list has changed + /// even when the selection has not. + /// + /// @param themes the new set, or `None` to re-parse the document. + /// + /// # Returns + /// The active theme after the change. + pub async fn reset_themes(&self, themes: Option>) -> ApiResult> { + let selected = self + .0 + .theme_selected + .get() + .then(|| self.0.viewer_elem.get_attribute("theme")) + .flatten(); + + self.reset_available_themes(themes).await; + let available = self.get_available_themes().await?; + let kept = selected.filter(|name| available.contains(name)); + self.0.theme_selected.set(kept.is_some()); + let active = kept.or_else(|| available.first().cloned()); + self.set_theme_attribute(active.as_deref())?; + let index = active + .as_ref() + .and_then(|name| available.iter().position(|x| x == name)); + + self.theme_config_updated.emit((available, index)); + Ok(active) + } + /// Set the theme by name, or `None` for the default theme. /// /// A NAMED theme's host attribute write is SYNCHRONOUS ("stamp with @@ -368,6 +412,7 @@ impl Presentation { /// # Returns /// A `bool` indicating whether the internal state changed. pub async fn set_theme_name(&self, theme: Option<&str>) -> ApiResult { + self.0.theme_selected.set(theme.is_some()); if let Some(theme) = theme { if self.0.viewer_elem.get_attribute("theme").as_deref() == Some(theme) { return Ok(false); diff --git a/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs b/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs index 9e777afd63..c86f1a3cc5 100644 --- a/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs +++ b/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs @@ -51,9 +51,6 @@ pub async fn get_viewer_config( None => presentation.get_selected_theme_name().await, }; let title = session.get_title(); - // Placed ⇒ bound (or binding in flight): creation requires a `table` - // by type, so a panel with neither a bound nor pending table is an - // invariant violation, not a serializable state. let table = session .get_table() .map(|x| x.get_name().to_owned()) diff --git a/rust/perspective-viewer/src/rust/renderer.rs b/rust/perspective-viewer/src/rust/renderer.rs index cc2fecc757..d2ace42f44 100644 --- a/rust/perspective-viewer/src/rust/renderer.rs +++ b/rust/perspective-viewer/src/rust/renderer.rs @@ -142,19 +142,14 @@ pub struct RendererData { /// coexist there. slot_name: RefCell>, - /// This panel's selected theme name (per-panel theming), or `None` to - /// inherit the element-level (active) theme. + /// This panel's theme name — CONCRETE, resolved once at creation from + /// the config's `theme`, else the host's, else the registry default. + /// There is no "unthemed panel" that resolves against live element + /// state at draw time: a registry re-ordering must never repaint a + /// panel, so nothing but an explicit write may change this. `None` only + /// while no themes exist at all. theme: RefCell>, - /// The registry default theme name (first registered), cached here so - /// LOCKED draw paths can resolve this panel's EFFECTIVE theme - /// synchronously (`Presentation::get_default_theme_name` is async — it - /// awaits theme discovery). Seeded by every content-load path before its - /// first locked draw (`restore_and_render`, `load()`, the - /// resize-observer's deferred first render) and kept fresh by the root's - /// `UpdatePresentation` default-theme fan-out and `resetThemes`. - default_theme: RefCell>, - /// Whether the active plugin has completed a draw. An EXPLICIT flag — /// not inferred from DOM connectedness — because plugin elements may be /// mounted eagerly (at panel creation / draw start, before the view @@ -276,7 +271,6 @@ impl Renderer { on_render_limits_changed: Default::default(), slot_name: Default::default(), theme: Default::default(), - default_theme: Default::default(), has_drawn: Cell::new(false), captured_theme: Default::default(), cached_context: Default::default(), @@ -299,45 +293,24 @@ impl Renderer { self.0.slot_name.borrow().clone() } - /// Set this panel's theme name (`None` = inherit the element-level theme). + /// Set this panel's theme name. Callers must pass a CONCRETE name — + /// resolve "the default" through `Presentation` before calling, never by + /// leaving this empty. pub fn set_theme(&self, name: Option) { *self.0.theme.borrow_mut() = name; } - /// This panel's selected theme name, if any (per-panel theming). + /// This panel's theme name. pub fn theme(&self) -> Option { self.0.theme.borrow().clone() } - /// [`Self::set_theme`] plus a synchronous [`Self::stamp_theme`] when the - /// effective theme is resolvable now (a named theme needs no registry; a - /// reset stamps only from a warm default cache — a cold one would stamp - /// attribute-removal). The shared "stamp-with-commit" head of every - /// per-panel theme mutation site. + /// [`Self::set_theme`] plus a synchronous [`Self::stamp_theme`] — the + /// shared "stamp-with-commit" head of every per-panel theme mutation + /// site. pub fn set_theme_stamped(&self, theme: Option) { - let stamp = theme.is_some() || self.default_theme().is_some(); self.set_theme(theme); - if stamp { - self.stamp_theme(None); - } - } - - /// Set the cached registry default theme name (see the field docs on - /// [`RendererData`]). - pub fn set_default_theme(&self, name: Option) { - *self.0.default_theme.borrow_mut() = name; - } - - /// The cached registry default theme name. - pub fn default_theme(&self) -> Option { - self.0.default_theme.borrow().clone() - } - - /// This panel's EFFECTIVE theme: its own ([`Self::theme`]), else the - /// cached registry default ([`Self::default_theme`]) — the value - /// [`Self::stamp_theme`] stamps. - pub fn effective_theme(&self) -> Option { - self.theme().or_else(|| self.default_theme()) + self.stamp_theme(None); } /// Whether the active plugin's captured `--psp-*` CSS is STALE — the @@ -352,7 +325,7 @@ impl Renderer { /// paint captures fresh by construction ("stamp before draw"). pub fn needs_restyle(&self) -> bool { match &*self.0.captured_theme.borrow() { - Some(captured) => *captured != self.effective_theme(), + Some(captured) => *captured != self.theme(), None => false, } } diff --git a/rust/perspective-viewer/src/rust/renderer/dispatch.rs b/rust/perspective-viewer/src/rust/renderer/dispatch.rs index 8df2dc200a..9fead97aeb 100644 --- a/rust/perspective-viewer/src/rust/renderer/dispatch.rs +++ b/rust/perspective-viewer/src/rust/renderer/dispatch.rs @@ -49,7 +49,7 @@ impl Renderer { let active_plugin = self.active_plugin(); if let Some(plugin) = plugin.or(active_plugin.as_ref()) { let theme_elem = plugin.unchecked_ref::(); - match self.effective_theme() { + match self.theme() { Some(theme) if theme_elem.get_attribute("theme").as_deref() != Some(theme.as_str()) => { @@ -73,7 +73,7 @@ impl Renderer { let _pin = self.pin_context(&guard, ctx.clone()); let plugin = self.ensure_plugin_selected()?; let meta = self.metadata(); - let stamped_theme = self.effective_theme(); + let stamped_theme = self.theme(); self.stamp_theme(Some(&plugin)); self.stamp_active(&plugin); plugin.restyle(); @@ -134,7 +134,19 @@ impl Renderer { pub async fn resize(&self) -> ApiResult<()> { self.0.geometry_cmd.set(Some(GeometryCmd::Measure)); - self.geometry_task().await.map(|_| ()) + + // This caller queued a `Measure`, but the parked run it resolves + // through executes the LATEST `geometry_cmd` — which a concurrent + // presize sweep may have swapped to a `Presize` whose sweep + // coalesced (and so received `None`). Any present closure returned + // here is therefore held by no one else: invoke it, or the + // transport's present-hold leaks and the plot freezes on its + // last-blitted frame. + if let Some(present) = self.geometry_task().await? { + present.call0(&JsValue::NULL)?; + } + + Ok(()) } /// Public one-shot resize to explicit dimensions (`viewer.resize({ @@ -416,7 +428,7 @@ impl Renderer { let viewer_elem = self.0.borrow().viewer_elem.clone(); let slot = self.slot_name(); let first_paint = !self.0.has_drawn.get(); - let stamped_theme = self.effective_theme(); + let stamped_theme = self.theme(); self.stamp_active(&plugin); self.stamp_theme(Some(&plugin)); if self.needs_restyle() { diff --git a/rust/perspective-viewer/src/rust/session.rs b/rust/perspective-viewer/src/rust/session.rs index e7319bbfba..8307638573 100644 --- a/rust/perspective-viewer/src/rust/session.rs +++ b/rust/perspective-viewer/src/rust/session.rs @@ -895,7 +895,7 @@ impl Session { .pending_dispatches .set(self.0.pending_dispatches.get() + 1); let session = self.clone(); - ApiFuture::spawn(async move { + ApiFuture::spawn_named("config-update-dispatch", async move { let result = fut.await; let remaining = session.0.pending_dispatches.get() - 1; session.0.pending_dispatches.set(remaining); diff --git a/rust/perspective-viewer/src/rust/session/metadata.rs b/rust/perspective-viewer/src/rust/session/metadata.rs index 1f332c537c..d5a1d03e54 100644 --- a/rust/perspective-viewer/src/rust/session/metadata.rs +++ b/rust/perspective-viewer/src/rust/session/metadata.rs @@ -187,7 +187,7 @@ impl SessionMetadata { } pub fn get_expression_columns(&self) -> impl Iterator { - try { + let mut columns = try { self.as_ref()? .expr_meta .as_ref()? @@ -197,6 +197,10 @@ impl SessionMetadata { } .into_iter() .flatten() + .collect::>(); + + columns.sort_unstable(); + columns.into_iter() } /// Returns the full original expression `String` for an expression alias. diff --git a/rust/perspective-viewer/src/rust/tasks/create_panel.rs b/rust/perspective-viewer/src/rust/tasks/create_panel.rs index ac488c27bb..acf039c075 100644 --- a/rust/perspective-viewer/src/rust/tasks/create_panel.rs +++ b/rust/perspective-viewer/src/rust/tasks/create_panel.rs @@ -20,7 +20,7 @@ use crate::renderer::*; use crate::session::{ResetOptions, Session}; use crate::tasks::*; use crate::utils::*; -use crate::workspace::{Panel, PanelId, Workspace}; +use crate::workspace::{Panel, PanelId, PanelPhase, Workspace}; use crate::*; /// Build the full set of subscriptions a [`Panel`] owns for its lifetime: its @@ -70,29 +70,19 @@ fn wire_panel_render_sub(session: &Session, renderer: &Renderer) -> Subscription } /// Create a new independent panel (own `Session` + `Renderer` + id) and -/// restore `update` into it. Shared by `addPanel`, `restore`'s -/// create-if-missing upsert, and `restorePanel`. `id` is the panel's id — -/// provided when restoring into a specific named slot, or `None` to -/// generate a fresh one. `theme` is stripped (element-level, not -/// per-panel) and `client` — or the element's default client when `None` — -/// is bound so the config's `table` resolves against it. -/// -/// A `table` is OPTIONAL here: an update with none creates a deferred -/// panel that a later `load()` binds, which is the pre-existing -/// `restore`-then-`load` contract and the same state the reserved -/// `load()` path produces. The routes where a table-less panel WOULD be -/// permanently blank require one in their own argument types instead — -/// `addPanel`'s [`ViewerConfigInitial`], `WorkspaceConfigUpdate`'s -/// `panels` entries, and the agent's `add_panel` — so the guarantee sits -/// at the boundary that owns it rather than here, which would sweep -/// `restore`'s patch in with them. +/// restore `config` into it. Shared by `addPanel` and `restore`'s +/// create-if-missing upsert. `id` is the panel's id — provided when +/// restoring into a specific named slot, or `None` to generate a fresh +/// one. `theme` is stripped (element-level, not per-panel) and `client` — +/// or the element's default client when `None` — is bound so the config's +/// `table` resolves against it. pub(crate) async fn create_panel( elem: &HtmlElement, presentation: &Presentation, workspace: &Workspace, notify: &Callback<()>, id: Option, - update: ViewerConfigUpdate, + config: ViewerConfigInitial, client: Option, ) -> ApiResult { let (id, session, renderer, update) = create_panel_model( @@ -100,36 +90,25 @@ pub(crate) async fn create_panel( presentation, workspace, id, - update, + config.into(), client, - Placement::Placed, + Placement::Staged, ); stamp_global_overlay(workspace, &id, &session); - // STAGE the panel (model-first): `MainPanel` renders its plugin slot - // into a hidden pre-sized wrapper — not a layout cell — so the restore - // below completes its first draw invisibly at (near-)final size. The - // promote below then re-renders, and `MainPanel::reconcile` inserts an - // ALREADY-DRAWN panel: the one layout transition reveals content, - // never a blank frame. - workspace.stage_panel(&id); notify.emit(()); - // The staging deadline: a slow restore promotes EARLY (placed, still - // loading — the pre-staging progressive reveal) rather than leaving - // the layout without feedback. Races the completion promote below on - // the staged flag; only the winner re-renders. - { + ApiFuture::spawn({ clone!(workspace, id, notify); - ApiFuture::spawn(async move { + async move { set_timeout(STAGING_DEADLINE_MS).await?; - if workspace.clear_staged(&id) { + if workspace.promote(&id) { notify.emit(()); } Ok(()) - }); - } + } + }); // A fresh panel is never the active one, so it needs no `root` for the // (active-only) settings-sidebar sequencing. @@ -146,7 +125,7 @@ pub(crate) async fn create_panel( // Promote on completion AND error — a failed restore's error state // must become visible too. - if workspace.clear_staged(&id) { + if workspace.promote(&id) { notify.emit(()); } @@ -156,10 +135,17 @@ pub(crate) async fn create_panel( /// Where [`create_panel_model`] registers the new panel model. pub(crate) enum Placement { - /// Into the placed panel set ([`Workspace::insert_panel`]) — a live, - /// visible panel from the start. + /// Into the placed panel set at [`PanelPhase::Placed`] — a live, visible + /// panel from the start (`restoreWorkspace` mounts panels directly at + /// their saved layout positions). Placed, + /// Into the placed panel set at [`PanelPhase::Staging`] — withheld from + /// the layout while its first draw completes in the hidden staging + /// wrapper, then promoted ([`Workspace::promote`]) by restore completion + /// or the [`STAGING_DEADLINE_MS`] deadline, whichever wins. + Staged, + /// Into the reservation slot ([`Workspace::reserve_panel`]) — a pending /// `load()`'s first panel, held out of the placed set until it is /// claimed ([`place_reserved`]) or discarded. @@ -180,32 +166,23 @@ pub(crate) fn create_panel_model( client: Option, placement: Placement, ) -> (PanelId, Session, Renderer, ViewerConfigUpdate) { - // Authored-theme boot: the FIRST panel of an empty element with no explicit - // theme adopts the element's `theme` attribute (a one-time initial - // selection; the attribute is otherwise an active-panel mirror). Captured - // before `insert_panel` makes the workspace non-empty. - let boot_theme = (workspace.is_empty() && matches!(update.theme, OptionalUpdate::Missing)) - .then(|| elem.get_attribute("theme")) - .flatten(); - let session = Session::new(); let renderer = Renderer::new(elem); let id = id.unwrap_or_else(|| workspace.generate_id()); renderer.set_slot_name(id.as_str()); - renderer.set_default_theme(presentation.default_theme_name_sync()); let subs = wire_panel_subs(elem, presentation, &session, &renderer); let panel = Panel::new(id.clone(), session.clone(), renderer.clone(), subs); match placement { - Placement::Placed => workspace.insert_panel(panel), + Placement::Placed => workspace.insert_panel(panel, PanelPhase::Placed), + Placement::Staged => workspace.insert_panel(panel, PanelPhase::Staging), Placement::Reserved => workspace.reserve_panel(panel), } update.settings = OptionalUpdate::Missing; - if let OptionalUpdate::Update(theme) = &update.theme { - renderer.set_theme(Some(theme.clone())); - } else if let Some(theme) = boot_theme { - renderer.set_theme(Some(theme)); - } + renderer.set_theme(match &update.theme { + OptionalUpdate::Update(theme) => Some(theme.clone()), + _ => presentation.active_theme_name_sync(), + }); update.theme = OptionalUpdate::Missing; if let Some(client) = client.or_else(|| workspace.default_client()) { @@ -221,8 +198,18 @@ pub(crate) fn create_panel_model( (id, session, renderer, update) } -pub(crate) fn place_reserved(workspace: &Workspace, notify: &Callback<()>) -> Option { - let panel = workspace.claim_reserved()?; +/// Claim the pending `load()` reservation into the placed set +/// ([`Workspace::claim_reserved`]), stamping the global-filter overlay and +/// notifying the root. `has_table` is whether the claimant carried a +/// `table`, recorded atomically with the claim — pass `true` when `load()` +/// itself places its reservation (a `Table` payload, or error surfacing), +/// which is not a restore claim and must never arm the epilogue's eviction. +pub(crate) fn place_reserved( + workspace: &Workspace, + notify: &Callback<()>, + has_table: bool, +) -> Option { + let panel = workspace.claim_reserved(has_table)?; stamp_global_overlay(workspace, &panel.id, &panel.session); notify.emit(()); Some(panel) @@ -230,7 +217,7 @@ pub(crate) fn place_reserved(workspace: &Workspace, notify: &Callback<()>) -> Op /// Tear down a [`Panel`] already removed from the [`Workspace`]: dispose its /// renderer (slot-scoped plugin + light-DOM cleanup) and eject its table. -/// Shared by the root's `ClosePanel` handler and whole-element `restore`'s +/// Shared by the root's `ClosePanel` handler and `restoreWorkspace`'s /// batch replacement of the pre-existing panel set. pub(crate) fn eject_panel(panel: Panel) -> ApiFuture<()> { let was_errored = panel.session.is_errored(); diff --git a/rust/perspective-viewer/src/rust/tasks/eject.rs b/rust/perspective-viewer/src/rust/tasks/eject.rs index 421406b447..8e81f18dff 100644 --- a/rust/perspective-viewer/src/rust/tasks/eject.rs +++ b/rust/perspective-viewer/src/rust/tasks/eject.rs @@ -23,11 +23,14 @@ use crate::workspace::Workspace; /// Tear down the entire viewer: dispose EVERY panel's engines (emit /// `table_unloaded`, destroy the renderer under its own draw lock, eject the /// session), then drop the Yew root once. Fans out over all panels — not just -/// the seed — so panels added via `addPanel` or a whole-element `restore` don't +/// the seed — so panels added via `addPanel` or `restoreWorkspace` don't /// leak their `View`/`Table` + plugin when the element is deleted or ejected. pub fn delete_all(workspace: &Workspace, root: &Root) -> ApiFuture<()> { clone!(workspace, root); ApiFuture::new(async move { + // A staged-but-unapplied `restoreWorkspace` layout tree must not + // outlive the panels it names. + workspace.take_pending_layout(); let panels = workspace .take_reserved() .into_iter() diff --git a/rust/perspective-viewer/src/rust/tasks/reset_all.rs b/rust/perspective-viewer/src/rust/tasks/reset_all.rs index 0a5ca14aff..0564445633 100644 --- a/rust/perspective-viewer/src/rust/tasks/reset_all.rs +++ b/rust/perspective-viewer/src/rust/tasks/reset_all.rs @@ -61,11 +61,11 @@ pub fn reset_all( presentation.reset_available_themes(None).await; if all { - // Clear this panel's own per-panel theme so it reverts to inheriting - // the (reset-to-default) host theme — `reset_theme` only resets the - // host, which an explicitly-themed panel would otherwise override. - renderer.set_theme(None); + // Put this panel back on the registry default CONCRETELY — + // `reset_theme` only resets the host, which an explicitly-themed + // panel would otherwise keep overriding. presentation.reset_theme().await?; + renderer.set_theme(presentation.get_default_theme_name().await); } // For `all = true`, route the bucket clears through `restore_and_render`'s diff --git a/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs b/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs index c01c06b64f..48aa56a333 100644 --- a/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs +++ b/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs @@ -18,6 +18,7 @@ use futures::Future; use perspective_client::clone; use super::pipeline::{RunOrigin, RunSpec, locked_run}; +use super::update_theme::seed_panel_theme; use crate::config::{OptionalUpdate, ViewerConfigUpdate}; use crate::presentation::Presentation; use crate::renderer::Renderer; @@ -70,11 +71,6 @@ pub fn restore_and_render( session.set_title(None); } - // Mirror a config-carried theme onto the host attribute (the shared - // chrome). The restyle a theme change requires is owned by the - // MUTATION SITES (`restorePanel`'s own-theme tail, the theme-picker - // task), not here — this run's own draw below already stamps the new - // effective theme before the plugin's first style read. match theme_name { OptionalUpdate::SetDefault => { let current_name = presentation.get_selected_theme_name().await; @@ -83,37 +79,15 @@ pub fn restore_and_render( } }, OptionalUpdate::Update(x) => { - // No pre-resolution gate: `set_theme_name` stamps the host - // attribute SYNCHRONOUSLY before its registry await and - // no-ops on literal equality itself. The old - // `get_selected_theme_name().await` guard was precisely - // the former-theme window on a cold registry — the host - // held the old attribute until stylesheet parsing (and - // everything queued behind it) resolved. presentation.set_theme_name(Some(&x)).await?; }, _ => {}, }; - // Resolve the target plugin here (pure — needed now for - // `set_update_column_defaults`), but COMMIT it only inside the locked - // run below, atomically with the view rebind it belongs to. No swap - // intent may exist outside that run: an unrelated run that wins the - // lock first (e.g. a `table_updated` redraw) must observe either the - // fully-old or fully-new world, never a staged half of this one. let resolved_plugin = renderer.resolve_plugin_update(&plugin); if let Some((_, metadata)) = &resolved_plugin { session.set_update_column_defaults(&mut view_config, metadata); } else { - // Same-plugin (or plugin-less) restore: `resolve_plugin_update` - // returns `None`, but the plugin-advised `group_rollup_mode` - // must STILL be enforced against the active plugin's metadata — - // `restorePanel`'s table-change reset wipes the committed mode, - // and nothing else on this path would restore it, leaving a - // flat-only chart (Treemap / Sunburst) rendering rollup - // subtotal rows. Rollup only; the full column-defaults pass is - // reserved for plugin swaps, where `columns` genuinely needs - // re-defaulting. let metadata = if renderer.active_plugin().is_none() { renderer .resolve_plugin_update(&OptionalUpdate::SetDefault) @@ -127,24 +101,9 @@ pub fn restore_and_render( } let plugin_idx = resolved_plugin.map(|(idx, _)| idx); - - // The config COMMIT: synchronous, validated, atomic (I1/I4). Under - // I2/I3 committing before the lock is safe — whichever queued run - // snapshots next picks it up, and this run's own snapshot (taken - // inside the lock below) can only be this commit or fresher. session.commit_view_config(view_config)?; - - // Spinner accounting (RAII): held to the end of this restore — - // INCLUDING the deferred-draw exit (no table yet → no - // `bind_snapshot`), which under the old edge-counted scheme - // stranded the `StatusIndicator` spinner permanently. let _run_token = session.begin_config_run(); - - // Awaits theme-registry init, so the stamp below can never observe a - // pre-init (empty) theme set on a cold first load. Seeds this - // panel's renderer default-theme cache, which every locked draw - // stamps the effective theme from. - renderer.set_default_theme(presentation.get_default_theme_name().await); + seed_panel_theme(&presentation, &renderer).await; locked_run(&session, &renderer, RunSpec { origin, plugin_idx, diff --git a/rust/perspective-viewer/src/rust/tasks/restore_panel.rs b/rust/perspective-viewer/src/rust/tasks/restore_panel.rs index d1c0be17a2..c9fcb782e6 100644 --- a/rust/perspective-viewer/src/rust/tasks/restore_panel.rs +++ b/rust/perspective-viewer/src/rust/tasks/restore_panel.rs @@ -28,17 +28,13 @@ pub(crate) enum RestoreMode { Fresh, } -/// Where a failed restore's error goes. `Publish` (every user-facing path) -/// commits it to the session — the visible error UI and errored session -/// state. `Suppress` (the agent's `set_view_config` tool) returns it ONLY -/// to the caller: the model receives the error as a tool result and -/// self-corrects, so the transient failure is noise to the human user — -/// and the errored session state would otherwise BLOCK the corrected -/// retry, since error recovery below requires a `table` field the model's -/// patches don't carry. +/// Where a failed restore's error goes. #[derive(Clone, Copy)] pub(crate) enum RestoreErrors { + // Raise errors in the UI. Publish, + + // Report only in the API. Suppress, } @@ -61,8 +57,8 @@ pub(crate) async fn bind_table_task( } /// Apply a [`ViewerConfigUpdate`] to a single panel and re-draw — the one -/// pipeline shared by `restorePanel` (an existing panel), whole-element -/// `restoreWorkspace`, and `addPanel` (both fresh panels). +/// pipeline shared by `restorePanel` (an existing panel), `restoreWorkspace`, +/// and `addPanel` (both fresh panels). pub(crate) async fn restore_panel( session: &Session, renderer: &Renderer, @@ -76,7 +72,11 @@ pub(crate) async fn restore_panel( let fresh = matches!(mode, RestoreMode::Fresh); match &update.theme { OptionalUpdate::Update(theme) => renderer.set_theme_stamped(Some(theme.clone())), - OptionalUpdate::SetDefault => renderer.set_theme_stamped(None), + // `SetDefault` resolves to a CONCRETE registry default here, rather + // than clearing the panel's theme — nothing downstream re-resolves. + OptionalUpdate::SetDefault => { + renderer.set_theme_stamped(presentation.get_default_theme_name().await) + }, OptionalUpdate::Missing => {}, } diff --git a/rust/perspective-viewer/src/rust/tasks/update_theme.rs b/rust/perspective-viewer/src/rust/tasks/update_theme.rs index 223ccc0ac6..15720bfc08 100644 --- a/rust/perspective-viewer/src/rust/tasks/update_theme.rs +++ b/rust/perspective-viewer/src/rust/tasks/update_theme.rs @@ -19,20 +19,21 @@ use crate::presentation::Presentation; use crate::renderer::Renderer; use crate::workspace::Workspace; -/// Re-seed every panel renderer's cached registry default theme from the -/// (awaited, so initialized) theme registry, returning the default name. -/// Every consumer of `Renderer::needs_restyle` after a registry-affecting -/// change must run this first — a cold cache compares against `None`. -pub(crate) async fn seed_default_themes( - presentation: &Presentation, - workspace: &Workspace, -) -> Option { - let default = presentation.get_default_theme_name().await; - for panel in workspace.panels() { - panel.renderer.set_default_theme(default.clone()); +/// Give `renderer` the concrete registry default IF it has none yet — the +/// cold-boot case, where the panel was created before the registry first +/// parsed and `active_theme_name_sync` had nothing to resolve. Only ever +/// FILLS IN, never overwrites, so it cannot repaint a themed panel. +pub(crate) async fn seed_panel_theme(presentation: &Presentation, renderer: &Renderer) { + if renderer.theme().is_none() { + renderer.set_theme(presentation.get_default_theme_name().await); } +} - default +/// [`seed_panel_theme`] for every panel. +pub(crate) async fn seed_default_themes(presentation: &Presentation, workspace: &Workspace) { + for panel in workspace.panels() { + seed_panel_theme(presentation, &panel.renderer).await; + } } /// Apply a theme change and restyle the affected panel's view. @@ -59,14 +60,17 @@ pub fn update_theme( workspace: &Workspace, theme: Option, ) { - // Per-panel: record the theme on the (active) renderer so this panel keeps - // it independent of which panel is active. `set_theme_name` below mirrors - // the same value onto the host `theme` attribute (driving the chrome), and - // MainPanel inlines this renderer's theme on its frame only when it - renderer.set_theme_stamped(theme.clone()); + // A NAMED theme needs no registry, so it stamps SYNCHRONOUSLY — no await + // separates the caller's config commit from the attribute the document + // cascade styles. Only "reset to default" has to await the registry, and + // it records the resolved NAME, never an empty theme. + if let Some(name) = &theme { + renderer.set_theme_stamped(Some(name.clone())); + } let presentation = presentation.clone(); let workspace = workspace.clone(); + let renderer = renderer.clone(); ApiFuture::spawn(async move { match theme { Some(name) => { @@ -74,10 +78,10 @@ pub fn update_theme( }, None => { presentation.reset_theme().await?; + renderer.set_theme_stamped(presentation.get_default_theme_name().await); }, } - seed_default_themes(&presentation, &workspace).await; let panels = workspace.panels(); join_all(panels.iter().map(|panel| async move { if panel.renderer.needs_restyle() { diff --git a/rust/perspective-viewer/src/rust/utils/spawn.rs b/rust/perspective-viewer/src/rust/utils/spawn.rs index fc51d29d57..a9072b06e2 100644 --- a/rust/perspective-viewer/src/rust/utils/spawn.rs +++ b/rust/perspective-viewer/src/rust/utils/spawn.rs @@ -23,7 +23,7 @@ use perspective_js::utils::*; /// (invariant I6); use this only at event-listener leaves where no caller /// exists. pub fn spawn_owned(tag: &'static str, fut: impl Future> + 'static) { - ApiFuture::spawn(async move { + ApiFuture::spawn_named(tag, async move { if let Err(e) = fut.await.ignore_view_delete() { tracing::error!("[{}] unhandled task error: {}", tag, e); } diff --git a/rust/perspective-viewer/src/rust/workspace.rs b/rust/perspective-viewer/src/rust/workspace.rs index 33f1a06202..cfa05ff28c 100644 --- a/rust/perspective-viewer/src/rust/workspace.rs +++ b/rust/perspective-viewer/src/rust/workspace.rs @@ -12,8 +12,7 @@ //! The multi-panel model backing a single ``. -use std::cell::RefCell; -use std::collections::HashSet; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use perspective_client::Client; @@ -51,12 +50,7 @@ impl From<&str> for PanelId { } } -/// The element-level global filter state (master/detail cross-filter): an -/// unattributed `restored` bucket (from whole-element `restore` — per-master -/// attribution is not persisted) plus one contribution per master panel, in -/// first-contribution order. The effective set is the ordered, deduped -/// flattening of both. Pure data — factored out of [`WorkspaceData`] so the -/// replace/dedup/removal semantics are unit-testable without engine handles. +/// The element-level global filter state (master/detail cross-filter. #[derive(Default)] struct GlobalFilterSet { restored: Vec, @@ -91,10 +85,7 @@ impl GlobalFilterSet { before != self.flatten() } - /// Replace `id`'s contribution (a master's new selection REPLACES its - /// prior one — no plugin-remembered remove-lists). A non-empty selection - /// also drops the `restored` bucket, which is a stale snapshot of some - /// pre-save master's selection. Empty removes the entry (deselect). + /// Replace `id`'s contribution. fn set_contribution(&mut self, id: &PanelId, filters: Vec) -> bool { self.with_change(|s| { if filters.is_empty() { @@ -109,11 +100,7 @@ impl GlobalFilterSet { }) } - /// Remove the flattened-view clause at `index` from EVERY bucket (a chip - /// stands for the clause, not one bucket's copy — removing it from only - /// one would resurface the duplicate). Returns whether the effective set - /// changed and the OWNING master panels of the removed clause (for - /// selection-state cleanup). Out-of-range is a no-op. + /// Remove the flattened-view clause at `index` from EVERY bucket. fn remove_clause(&mut self, index: usize) -> (bool, Vec) { let Some(clause) = self.flatten().get(index).cloned() else { return (false, Vec::new()); @@ -155,7 +142,7 @@ impl GlobalFilterSet { (changed, owners) } - /// Whole-element `restore`: replace everything with an unattributed set. + /// `restoreWorkspace`: replace everything with an unattributed set. fn set_restored(&mut self, filters: Vec) -> bool { self.with_change(|s| { s.contributions.clear(); @@ -164,19 +151,12 @@ impl GlobalFilterSet { } } -/// A single, fully-independent viewer-like unit within a [`Workspace`]: its own -/// [`Session`] (table binding + view config) and [`Renderer`] (active plugin). +/// A single, fully-independent viewer-like unit within a [`Workspace`]. #[derive(Clone)] pub struct Panel { pub id: PanelId, pub session: Session, pub renderer: Renderer, - - /// Subscriptions owned for this panel's lifetime: its redraw subscription - /// (`table_updated` → redraw) plus its custom-event fanout - /// (`wire_panel_events`). Held here — not on the element — so they drop - /// exactly when the panel is removed from the [`Workspace`], with no - /// separate add/remove bookkeeping. _subs: Rc>, } @@ -191,6 +171,120 @@ impl Panel { } } +/// A placed panel's layout phase. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelPhase { + Staging, + Placed, +} + +/// A [`Panel`] as registered in the placed set. +struct PanelEntry { + panel: Panel, + phase: PanelPhase, + master: bool, +} + +#[derive(Default)] +enum Reservation { + #[default] + Idle, + Pending(Panel), + Claimed { + has_table: bool, + }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum FlushState { + #[default] + Idle, + Queued { + layout: bool, + active: bool, + }, +} + +impl FlushState { + /// Merge newly-dirty events, returning the next state and whether a flush + /// task must be spawned. + fn mark(self, layout: bool, active: bool) -> (Self, bool) { + match self { + _ if !layout && !active => (self, false), + Self::Idle => (Self::Queued { layout, active }, true), + Self::Queued { + layout: l, + active: a, + } => ( + Self::Queued { + layout: l || layout, + active: a || active, + }, + false, + ), + } + } +} + +#[derive(Default)] +struct LayoutEmitter { + state: Cell, + layout_changed: Rc>>, + active_changed: Rc>>, +} + +impl LayoutEmitter { + fn mark(&self, layout: bool, active: bool, workspace: &Workspace) { + let (next, spawn) = self.state.get().mark(layout, active); + self.state.set(next); + if spawn { + let workspace = workspace.clone(); + spawn_owned("workspace_layout_flush", async move { + workspace.effects().settle().await; + workspace.flush_layout_events(); + Ok(()) + }); + } + } +} + +/// The loaded-[`Client`]s registry and the default-client designation. The +/// invariant "the default is always a registered client" holds in this one +/// impl: every default assignment registers first, and removal clears both. +#[derive(Default)] +struct ClientRegistry { + clients: Vec, + default: Option, + client_registered: Rc>, +} + +impl ClientRegistry { + /// Add `client` if a client with the same (globally unique) name isn't + /// already present, returning — for a genuinely-new client — the pubsub + /// for the caller to emit OUTSIDE its borrow. + fn register(&mut self, client: Client) -> Option>> { + if self + .clients + .iter() + .any(|c| c.get_name() == client.get_name()) + { + return None; + } + + self.clients.push(client); + Some(self.client_registered.clone()) + } + + /// Drop the client named `name`, clearing the default designation if it + /// referred to it. + fn remove(&mut self, name: &str) { + self.clients.retain(|c| c.get_name() != name); + if self.default.as_ref().is_some_and(|c| c.get_name() == name) { + self.default = None; + } + } +} + /// The multi-panel model backing a single ``. See the /// module docs for the Phase 1 (single-panel) invariants. #[derive(Clone)] @@ -203,62 +297,39 @@ impl PartialEq for Workspace { } struct WorkspaceData { - /// Panels in insertion order. - panels: Vec, + /// Panels in insertion order, each with its per-panel attributes + /// (placement phase, master role). + panels: Vec, /// The currently active/selected panel (a live panel in `panels`). active: Option, - /// The first [`Client`] loaded via the element's `load()`. - default_client: Option, - - /// Every [`Client`] ever loaded into this element (registration order. - clients: Vec, + /// The element-level global filters. + filters: GlobalFilterSet, + filters_changed: Rc>, - /// Fires once per genuinely-new [`Client`] registration (post-dedup) — - /// the element subscribes each new client's hosted-tables updates for - /// reactive table binding (`tasks::table_lifecycle`). - client_registered: Rc>, + /// The loaded-clients registry + default designation. + clients: ClientRegistry, /// Monotonic counter backing [`Workspace::generate_id`]. next_id: usize, - /// Panels designated as master/detail filter sources. - masters: HashSet, + /// The `load()` reservation slot (see [`Reservation`]). + reservation: Reservation, - /// The element-level global filters. - filters: GlobalFilterSet, - filters_changed: Rc>, - - /// A layout tree staged by whole-element `restore`. + /// A layout tree staged by `restoreWorkspace`. pending_layout: Option, - /// Freshly-created panels withheld from the ``. - staged: HashSet, - staged_changed: Rc>, - - /// The RESERVED first panel of a pending `load()`. - reserved: Option, - /// In-flight effects (public mutators + scheduled internal flows), - /// drained by `flush()` — see [`EffectLedger`]. + /// drained by `flush()`. effects: EffectLedger, - /// The PLACED panel set changed since the last emit. Mutation sites set - /// this and emit NOTHING; the coalescing flush task owns delivery (see - /// [`Workspace::schedule_layout_flush`]). - layout_dirty: bool, - layout_changed: Rc>>, - - /// The active panel changed since the last emit. A separate channel from - /// `layout_dirty`: "which panel is selected" and "which panels exist" are - /// distinct facts, and one event may not mean both. - active_dirty: bool, - active_changed: Rc>>, + /// Fires on every [`PanelPhase`] transition. + staged_changed: Rc>, - /// A flush task is already queued — the flag that makes N mutations - /// within one operation schedule ONE task rather than N. - flush_scheduled: bool, + /// Coalesced `layout_changed`/`active_changed` delivery (see + /// [`LayoutEmitter`]). + emitter: LayoutEmitter, } impl Default for Workspace { @@ -273,23 +344,15 @@ impl Workspace { Self(Rc::new(RefCell::new(WorkspaceData { panels: Vec::new(), active: None, - default_client: None, - clients: Vec::new(), - client_registered: Rc::new(PubSub::default()), - next_id: 0, - masters: HashSet::new(), filters: GlobalFilterSet::default(), filters_changed: Rc::new(PubSub::default()), + clients: ClientRegistry::default(), + next_id: 0, + reservation: Reservation::Idle, pending_layout: None, - staged: HashSet::new(), - staged_changed: Rc::new(PubSub::default()), - reserved: None, effects: EffectLedger::default(), - layout_dirty: false, - layout_changed: Rc::new(PubSub::default()), - active_dirty: false, - active_changed: Rc::new(PubSub::default()), - flush_scheduled: false, + staged_changed: Rc::new(PubSub::default()), + emitter: LayoutEmitter::default(), }))) } @@ -299,63 +362,43 @@ impl Workspace { } pub fn layout_changed(&self) -> Rc>> { - self.0.borrow().layout_changed.clone() + self.0.borrow().emitter.layout_changed.clone() } pub fn active_changed(&self) -> Rc>> { - self.0.borrow().active_changed.clone() + self.0.borrow().emitter.active_changed.clone() } - /// Queue the coalescing layout-event flush, if anything is dirty and no - /// flush is already pending. - fn schedule_layout_flush(&self) { - let schedule = { - let mut data = self.0.borrow_mut(); - let dirty = data.layout_dirty || data.active_dirty; - let queued = data.flush_scheduled; - data.flush_scheduled |= dirty; - dirty && !queued - }; - - if !schedule { - return; - } - - let effects = self.effects(); - let this = self.clone(); - spawn_owned("workspace_layout_flush", async move { - effects.settle().await; - this.flush_layout_events(); - Ok(()) - }); - } - - /// Emit whatever is dirty, outside any borrow. + /// Deliver whatever the queued flush recorded, outside any borrow — the + /// body of the task [`LayoutEmitter::mark`] spawns. fn flush_layout_events(&self) { - let (layout, active, panels, active_id, layout_pubsub, active_pubsub) = { - let mut data = self.0.borrow_mut(); - data.flush_scheduled = false; + let (state, panels, active_id, layout_pubsub, active_pubsub) = { + let data = self.0.borrow(); ( - std::mem::take(&mut data.layout_dirty), - std::mem::take(&mut data.active_dirty), - data.panels.iter().map(|p| p.id.clone()).collect::>(), + data.emitter.state.replace(FlushState::Idle), + data.panels + .iter() + .map(|p| p.panel.id.clone()) + .collect::>(), data.active.clone(), - data.layout_changed.clone(), - data.active_changed.clone(), + data.emitter.layout_changed.clone(), + data.emitter.active_changed.clone(), ) }; - if layout { - layout_pubsub.emit(panels); - } + if let FlushState::Queued { layout, active } = state { + if layout { + layout_pubsub.emit(panels); + } - if active { - active_pubsub.emit(active_id); + if active { + active_pubsub.emit(active_id); + } } } /// Stage a layout tree for `MainPanel` to apply at its next `rendered` - /// pass (see `WorkspaceData::pending_layout`). + /// pass (see [`WorkspaceData::pending_layout`]). pub fn set_pending_layout(&self, layout: crate::js::Layout) { self.0.borrow_mut().pending_layout = Some(layout); } @@ -371,38 +414,42 @@ impl Workspace { self.0.borrow().staged_changed.clone() } - /// Mark a freshly-created panel STAGED (see [`WorkspaceData::staged`]). - /// Emits `staged_changed` — outside the borrow. - pub fn stage_panel(&self, id: &PanelId) { - let pubsub = { - let mut data = self.0.borrow_mut(); - data.staged.insert(id.clone()); - data.staged_changed.clone() - }; - - pubsub.emit(()); - } - /// Promote a staged panel toward layout insertion, returning whether it /// was still staged — restore completion and the staging deadline RACE /// to promote, and only the winner proceeds. Emits `staged_changed` — - /// outside the borrow — iff the set changed. - pub fn clear_staged(&self, id: &PanelId) -> bool { - let (removed, pubsub) = { + /// outside the borrow — iff the phase changed. + pub fn promote(&self, id: &PanelId) -> bool { + let (promoted, pubsub) = { let mut data = self.0.borrow_mut(); - (data.staged.remove(id), data.staged_changed.clone()) + let promoted = data + .panels + .iter_mut() + .find(|p| &p.panel.id == id) + .map(|entry| { + matches!( + std::mem::replace(&mut entry.phase, PanelPhase::Placed), + PanelPhase::Staging + ) + }) + .unwrap_or(false); + + (promoted, data.staged_changed.clone()) }; - if removed { + if promoted { pubsub.emit(()); } - removed + promoted } /// Whether `id` is a staged (created, not yet layout-inserted) panel. pub fn is_staged(&self, id: &PanelId) -> bool { - self.0.borrow().staged.contains(id) + self.0 + .borrow() + .panels + .iter() + .any(|p| &p.panel.id == id && p.phase == PanelPhase::Staging) } /// The EFFECTIVE element-level global filters (master/detail @@ -435,10 +482,7 @@ impl Workspace { payload } - /// Replace the global filter set with an UNATTRIBUTED (restored) bucket, - /// dropping every master contribution — the whole-element `restore` - /// entry point. Callers push the new set into panel sessions via - /// `tasks::apply_global_filters`. + /// Replace the global filter set. pub fn set_global_filters(&self, filters: Vec) { self.mutate_filters(|s| (s.set_restored(filters), ())); } @@ -454,52 +498,61 @@ impl Workspace { self.mutate_filters(|s| (s.set_contribution(id, Vec::new()), ())); } - /// Remove the effective-set clause at `index` (the `GlobalFilterBar` - /// chip ×), returning the OWNING master panels of the removed clause so - /// the caller can clear their selection state - /// (`tasks::clear_master_selections`). + /// Remove the effective-set clause at `index`. pub fn remove_global_filter(&self, index: usize) -> Vec { self.mutate_filters(|s| s.remove_clause(index)) } /// Drop the entire global filter set (the `GlobalFilterBar` "Clear" / - /// element `reset()`), returning the contribution owners for - /// selection-state cleanup. Master ROLES are untouched — like the - /// layout, they are workspace structure, not filter state. + /// element `reset()`). pub fn clear_global_filters(&self) -> Vec { self.mutate_filters(GlobalFilterSet::clear) } - /// The master (filter-source) panel ids, SORTED — `save()` serializes - /// this, and a `HashSet`'s per-instance iteration order would make - /// consecutive `save()` outputs byte-unstable (cf. the `panels` - /// `BTreeMap`). + /// The master (filter-source) panel ids. pub fn masters(&self) -> Vec { - let mut masters: Vec<_> = self.0.borrow().masters.iter().cloned().collect(); + let mut masters: Vec<_> = self + .0 + .borrow() + .panels + .iter() + .filter(|p| p.master) + .map(|p| p.panel.id.clone()) + .collect(); + masters.sort(); masters } - /// Replace the master role set — the whole-element `restore` entry point - /// (ids already remapped to the fresh panel ids). + /// Replace the master role set — the `restoreWorkspace` entry point + /// (ids already remapped to the fresh panel ids). Ids naming no panel are + /// dropped (the caller has already warned on them). pub fn set_masters(&self, ids: Vec) { - self.0.borrow_mut().masters = ids.into_iter().collect(); + let mut data = self.0.borrow_mut(); + for entry in data.panels.iter_mut() { + entry.master = ids.contains(&entry.panel.id); + } } /// Whether `id` is a master (filter-source) panel. pub fn is_master(&self, id: &PanelId) -> bool { - self.0.borrow().masters.contains(id) + self.0 + .borrow() + .panels + .iter() + .any(|p| &p.panel.id == id && p.master) } /// Toggle `id`'s master/detail role, returning the new state (`true` = - /// master). + /// master). `false` (no role) if `id` names no panel. pub fn toggle_master(&self, id: &PanelId) -> bool { let mut data = self.0.borrow_mut(); - if data.masters.remove(id) { - false - } else { - data.masters.insert(id.clone()); - true + match data.panels.iter_mut().find(|p| &p.panel.id == id) { + Some(entry) => { + entry.master = !entry.master; + entry.master + }, + None => false, } } @@ -521,7 +574,10 @@ impl Workspace { pub fn active_panel(&self) -> Option { let data = self.0.borrow(); let active = data.active.as_ref()?; - data.panels.iter().find(|p| &p.id == active).cloned() + data.panels + .iter() + .find(|p| &p.panel.id == active) + .map(|p| p.panel.clone()) } /// The active panel's [`Session`], or `None` with zero panels. @@ -536,7 +592,12 @@ impl Workspace { /// Look up a [`Panel`] by id. pub fn panel(&self, id: &PanelId) -> Option { - self.0.borrow().panels.iter().find(|p| &p.id == id).cloned() + self.0 + .borrow() + .panels + .iter() + .find(|p| &p.panel.id == id) + .map(|p| p.panel.clone()) } /// Resolve a panel by id, or the active panel when `id` is `None` — the @@ -552,17 +613,23 @@ impl Workspace { /// fan-out source (fan-outs collect per-panel results; they never /// sequential-abort on one panel's error). pub fn panels(&self) -> Vec { - self.0.borrow().panels.to_vec() + self.0 + .borrow() + .panels + .iter() + .map(|p| p.panel.clone()) + .collect() } /// The number of PLACED panels (panels minus staged) — the single /// source for panel-count chrome (`single`/`multi`, closable, /// draggable, `only-child`). pub fn placed_count(&self) -> usize { - let data = self.0.borrow(); - data.panels + self.0 + .borrow() + .panels .iter() - .filter(|p| !data.staged.contains(&p.id)) + .filter(|p| p.phase == PanelPhase::Placed) .count() } @@ -572,7 +639,7 @@ impl Workspace { .borrow() .panels .iter() - .map(|p| p.id.clone()) + .map(|p| p.panel.id.clone()) .collect() } @@ -586,56 +653,119 @@ impl Workspace { self.0.borrow().panels.is_empty() } - /// Append a [`Panel`]. When the element had zero panels, the inserted panel - /// becomes the active one (there is no other candidate). - pub fn insert_panel(&self, panel: Panel) { - { + /// Append a [`Panel`] at `phase`. When the element had zero panels, the + /// inserted panel becomes the active one (there is no other candidate). + /// A [`PanelPhase::Staging`] insert emits `staged_changed` — outside the + /// borrow — so the phase and its announcement are one operation. + pub fn insert_panel(&self, panel: Panel, phase: PanelPhase) { + let staged_pubsub = { let mut data = self.0.borrow_mut(); - if data.active.is_none() { + let activated = if data.active.is_none() { data.active = Some(panel.id.clone()); - data.active_dirty = true; - } + true + } else { + false + }; panel .renderer .set_active_flag(data.active.as_ref() == Some(&panel.id)); - data.panels.push(panel); - data.layout_dirty = true; + data.panels.push(PanelEntry { + panel, + phase, + master: false, + }); + Self::sync_solo_flags(&data); - } + data.emitter.mark(true, activated, self); + (phase == PanelPhase::Staging).then(|| data.staged_changed.clone()) + }; - self.schedule_layout_flush(); + if let Some(pubsub) = staged_pubsub { + pubsub.emit(()); + } } - /// Hold `panel` in the reservation slot (see [`WorkspaceData::reserved`]): + /// Hold `panel` in the reservation slot ([`Reservation::Pending`]): /// NOT placed, invisible to every placed-panel consumer, awaiting a /// pending `load()`'s payload classification or an interim claimant. + /// Resets any prior claim record — the slot serves one `load()` cycle at + /// a time. pub fn reserve_panel(&self, panel: Panel) { - self.0.borrow_mut().reserved = Some(panel); + let mut data = self.0.borrow_mut(); + debug_assert!( + !matches!(data.reservation, Reservation::Pending(_)), + "reservation overwritten while pending — adopt via `reserved_panel` first" + ); + + data.reservation = Reservation::Pending(panel); } /// The reservation slot's current occupant (shared handles), without a /// transfer — a second `load()` on a still-empty element adopts the same /// reservation rather than creating a competing one. pub fn reserved_panel(&self) -> Option { - self.0.borrow().reserved.clone() + match &self.0.borrow().reservation { + Reservation::Pending(panel) => Some(panel.clone()), + _ => None, + } } - /// PLACE the reserved panel: transfer it out of the reservation slot into + /// CLAIM the reserved panel: transfer it out of the reservation slot into /// the placed set ([`Self::insert_panel`] — auto-activating on an empty - /// element). `None` when the slot is empty (no reservation, or the other - /// actor transferred first). - pub fn claim_reserved(&self) -> Option { - let panel = self.0.borrow_mut().reserved.take()?; - self.insert_panel(panel.clone()); + /// element), recording `has_table` — whether the claimant carried a + /// `table` — atomically with the transfer ([`Reservation::Claimed`]). + /// `None` when nothing is pending (no reservation, or the other actor + /// transferred first). + pub fn claim_reserved(&self, has_table: bool) -> Option { + let panel = { + let mut data = self.0.borrow_mut(); + match std::mem::take(&mut data.reservation) { + Reservation::Pending(panel) => { + data.reservation = Reservation::Claimed { has_table }; + Some(panel) + }, + other => { + data.reservation = other; + None + }, + } + }?; + + self.insert_panel(panel.clone(), PanelPhase::Placed); Some(panel) } /// DISCARD the reserved panel: transfer it out of the reservation slot /// WITHOUT placing it, for disposal (an inert `Client` payload with no - /// claimant, or teardown draining). `None` when the slot is empty. + /// claimant, or teardown draining). `None` when nothing is pending; a + /// claim record is left intact for [`Self::resolve_claim`]. pub fn take_reserved(&self) -> Option { - self.0.borrow_mut().reserved.take() + let mut data = self.0.borrow_mut(); + match std::mem::take(&mut data.reservation) { + Reservation::Pending(panel) => Some(panel), + other => { + data.reservation = other; + None + }, + } + } + + /// ONE-SHOT read of the claim record: `Some(has_table)` if the + /// reservation was claimed ([`Reservation::Claimed`]) — resetting the + /// slot to [`Reservation::Idle`] — or `None` if no claim happened. + /// Consuming closes the stale-flag window the former boolean had — + /// `load()`'s inert-`Client` epilogue is the only reader, and reads it + /// exactly once (eviction arms on `Some(false)`). + pub fn resolve_claim(&self) -> Option { + let mut data = self.0.borrow_mut(); + match data.reservation { + Reservation::Claimed { has_table } => { + data.reservation = Reservation::Idle; + Some(has_table) + }, + _ => None, + } } /// Sync every panel renderer's solo (lone-panel) flag with the current @@ -644,83 +774,79 @@ impl Workspace { /// locked plugin dispatch — see `Renderer::stamp_active`). fn sync_solo_flags(data: &WorkspaceData) { let is_solo = data.panels.len() == 1; - for panel in data.panels.iter() { - panel.renderer.set_solo_flag(is_solo); + for entry in data.panels.iter() { + entry.panel.renderer.set_solo_flag(is_solo); } } - /// Remove a [`Panel`] by id, returning it if present. - /// Remove a [`Panel`] by id, returning it if present. Model cleanup is - /// structural: EVERY removal path (close, whole-element restore's batch - /// replacement) drops the panel's master role and its global-filter - /// contribution here, so neither can outlive the panel. + /// Remove a [`Panel`] by id, returning it if present. The entry's + /// attributes (phase, master role) leave with it structurally; its + /// global-filter contribution is dropped here, so neither can outlive + /// the panel on ANY removal path (close, `restoreWorkspace`'s batch + /// replacement). pub fn remove_panel(&self, id: &PanelId) -> Option { - let (removed, changed, pubsub, staged_removed, staged_pubsub) = { + let (removed, changed, filters_pubsub, was_staged, staged_pubsub) = { let mut data = self.0.borrow_mut(); - data.masters.remove(id); - let staged_removed = data.staged.remove(id); let changed = data.filters.set_contribution(id, Vec::new()); - let removed = data - .panels - .iter() - .position(|p| &p.id == id) - .map(|idx| data.panels.remove(idx)); - - if data.active.as_ref() == Some(id) { + let (removed, was_staged) = match data.panels.iter().position(|p| &p.panel.id == id) { + Some(idx) => { + let entry = data.panels.remove(idx); + (Some(entry.panel), entry.phase == PanelPhase::Staging) + }, + None => (None, false), + }; + + let deactivated = data.active.as_ref() == Some(id); + if deactivated { data.active = None; - data.active_dirty = true; } - data.layout_dirty |= removed.is_some(); Self::sync_solo_flags(&data); + data.emitter.mark(removed.is_some(), deactivated, self); ( removed, changed, data.filters_changed.clone(), - staged_removed, + was_staged, data.staged_changed.clone(), ) }; if changed { - pubsub.emit(()); + filters_pubsub.emit(()); } - if staged_removed { + if was_staged { staged_pubsub.emit(()); } - self.schedule_layout_flush(); removed } /// Set the active panel. Returns `false` (no-op) if `id` is not a known /// panel. pub fn set_active(&self, id: PanelId) -> bool { - let known = { - let mut data = self.0.borrow_mut(); - if data.panels.iter().any(|p| p.id == id) { - data.active_dirty |= data.active.as_ref() != Some(&id); - data.active = Some(id); - for panel in data.panels.iter() { - panel - .renderer - .set_active_flag(data.active.as_ref() == Some(&panel.id)); - } - - true - } else { - false + let mut data = self.0.borrow_mut(); + if data.panels.iter().any(|p| p.panel.id == id) { + let changed = data.active.as_ref() != Some(&id); + data.active = Some(id); + for entry in data.panels.iter() { + entry + .panel + .renderer + .set_active_flag(data.active.as_ref() == Some(&entry.panel.id)); } - }; - self.schedule_layout_flush(); - known + data.emitter.mark(false, changed, self); + true + } else { + false + } } /// The default [`Client`], if one has been loaded. pub fn default_client(&self) -> Option { - self.0.borrow().default_client.clone() + self.0.borrow().clients.default.clone() } /// The active panel's bound [`Client`], if any — the default target of a @@ -736,8 +862,13 @@ impl Workspace { .borrow() .panels .iter() - .filter(|p| p.session.get_client().is_some_and(|c| c.get_name() == name)) - .map(|p| p.id.clone()) + .filter(|p| { + p.panel + .session + .get_client() + .is_some_and(|c| c.get_name() == name) + }) + .map(|p| p.panel.id.clone()) .collect() } @@ -747,26 +878,27 @@ impl Workspace { /// [`Workspace::panels_for_client`]) — `clients()` unions in live panel /// sessions, so a lingering panel would resurrect it. pub fn remove_client(&self, name: &str) { - let mut data = self.0.borrow_mut(); - data.clients.retain(|c| c.get_name() != name); - if data - .default_client - .as_ref() - .is_some_and(|c| c.get_name() == name) - { - data.default_client = None; - } + self.0.borrow_mut().clients.remove(name); } /// Record the default [`Client`] if not already set (first-wins, matching /// the "first `Client` passed to `load()` is the default" rule). Always /// registers the client (see [`Workspace::register_client`]) — first-wins - /// applies only to the *default* designation. + /// applies only to the *default* designation, which is assigned BEFORE + /// the registration event fires, so listeners observe both together. pub fn set_default_client(&self, client: Client) { - self.register_client(client.clone()); - let mut data = self.0.borrow_mut(); - if data.default_client.is_none() { - data.default_client = Some(client); + let pubsub = { + let mut data = self.0.borrow_mut(); + let pubsub = data.clients.register(client.clone()); + if data.clients.default.is_none() { + data.clients.default = Some(client.clone()); + } + + pubsub + }; + + if let Some(pubsub) = pubsub { + pubsub.emit(client); } } @@ -774,27 +906,15 @@ impl Workspace { /// same (globally unique) name isn't already present. Emits /// `client_registered` — outside the borrow — for a genuinely-new client. pub fn register_client(&self, client: Client) { - let pubsub = { - let mut data = self.0.borrow_mut(); - if data - .clients - .iter() - .any(|c| c.get_name() == client.get_name()) - { - return; - } - - data.clients.push(client.clone()); - data.client_registered.clone() - }; - - pubsub.emit(client); + let pubsub = self.0.borrow_mut().clients.register(client.clone()); + if let Some(pubsub) = pubsub { + pubsub.emit(client); + } } - /// A handle to the `client_registered` PubSub (see - /// [`WorkspaceData::client_registered`]). + /// A handle to the `client_registered` PubSub (see [`ClientRegistry`]). pub fn client_registered(&self) -> Rc> { - self.0.borrow().client_registered.clone() + self.0.borrow().clients.client_registered.clone() } /// All loaded [`Client`]s: the registry, unioned with every panel @@ -802,9 +922,9 @@ impl Workspace { /// bypasses registration), deduped by name in registration order. pub fn clients(&self) -> Vec { let data = self.0.borrow(); - let mut clients = data.clients.clone(); - for panel in &data.panels { - if let Some(client) = panel.session.get_client() + let mut clients = data.clients.clients.clone(); + for entry in &data.panels { + if let Some(client) = entry.panel.session.get_client() && !clients.iter().any(|c| c.get_name() == client.get_name()) { clients.push(client); @@ -935,4 +1055,47 @@ mod tests { assert_eq!(s.flatten(), Vec::::new()); assert_eq!(s.clear(), (false, Vec::new())); } + + #[test] + fn flush_state_spawns_exactly_once_per_cycle() { + let (state, spawn) = FlushState::Idle.mark(true, false); + assert!(spawn); + assert_eq!(state, FlushState::Queued { + layout: true, + active: false + }); + + // Subsequent marks merge without re-spawning. + let (state, spawn) = state.mark(false, true); + assert!(!spawn); + assert_eq!(state, FlushState::Queued { + layout: true, + active: true + }); + + let (state, spawn) = state.mark(true, true); + assert!(!spawn); + assert_eq!(state, FlushState::Queued { + layout: true, + active: true + }); + } + + #[test] + fn flush_state_ignores_empty_marks() { + // A no-event mark neither queues nor spawns — `Queued{false,false}` + // is unconstructible through `mark`. + let (state, spawn) = FlushState::Idle.mark(false, false); + assert!(!spawn); + assert_eq!(state, FlushState::Idle); + + let queued = FlushState::Queued { + layout: true, + active: false, + }; + + let (state, spawn) = queued.mark(false, false); + assert!(!spawn); + assert_eq!(state, queued); + } } diff --git a/rust/perspective-viewer/test/js/multi_panel/inert_load.spec.ts b/rust/perspective-viewer/test/js/multi_panel/inert_load.spec.ts index b6ee7f0cd8..000165caa8 100644 --- a/rust/perspective-viewer/test/js/multi_panel/inert_load.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/inert_load.spec.ts @@ -136,4 +136,26 @@ test.describe("Reserved panel vs. `load(Promise)`", () => { expect(panels).toEqual([]); }); + + test("unawaited load(Promise) + table-less restore evicts the claimed panel and fails the load", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const worker = (window as any).__TEST_WORKER__; + const v = document.createElement("perspective-viewer") as any; + document.body.appendChild(v); + const load = v.load(Promise.resolve(worker)).then( + () => "", + (e: unknown) => String(e), + ); + + await v.restore({ theme: "Pro Dark" }); + const loadError = await load; + await new Promise((x) => setTimeout(x, 250)); + return { panels: v.getPanelNames(), loadError }; + }); + + expect(result.loadError).toContain("table"); + expect(result.panels).toEqual([]); + }); }); diff --git a/rust/perspective-viewer/test/js/multi_panel/save_restore_workspace.spec.ts b/rust/perspective-viewer/test/js/multi_panel/save_restore_workspace.spec.ts index 4c3183d5a6..9265238f2f 100644 --- a/rust/perspective-viewer/test/js/multi_panel/save_restore_workspace.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/save_restore_workspace.spec.ts @@ -15,7 +15,7 @@ import { armInvariants } from "./harness.ts"; const TABLE = "load-viewer-csv"; -/// A two-panel side-by-side whole-element config. +/// A two-panel side-by-side workspace config. const SPLIT_CONFIG = { layout: { type: "split-layout", @@ -109,7 +109,7 @@ async function panel_names(page): Promise { } test.describe("saveWorkspace / restoreWorkspace", () => { - test("saveWorkspace always emits the whole-element format for one panel", async ({ + test("saveWorkspace always emits the workspace format for one panel", async ({ page, }) => { // On load there is a single seed panel with the `TABLE` loaded. diff --git a/rust/perspective-viewer/test/js/multi_panel/workspace.spec.ts b/rust/perspective-viewer/test/js/multi_panel/workspace.spec.ts index 6a9a2ed6df..99c346af6b 100644 --- a/rust/perspective-viewer/test/js/multi_panel/workspace.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/workspace.spec.ts @@ -15,7 +15,7 @@ import { armInvariants } from "./harness.ts"; const TABLE = "load-viewer-csv"; -/// A two-panel side-by-side whole-element config. +/// A two-panel side-by-side workspace config. const SPLIT_CONFIG = { layout: { type: "split-layout", @@ -85,7 +85,7 @@ async function panel_names(page): Promise { } test.describe("Multi-panel restore", () => { - test("restore a whole-element config with a split layout", async ({ + test("restore a workspace config with a split layout", async ({ page, }) => { await restore(page, SPLIT_CONFIG); diff --git a/rust/perspective-viewer/test/js/multi_panel/zero_panel.spec.ts b/rust/perspective-viewer/test/js/multi_panel/zero_panel.spec.ts index a080668ab1..e80017eacc 100644 --- a/rust/perspective-viewer/test/js/multi_panel/zero_panel.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/zero_panel.spec.ts @@ -87,6 +87,78 @@ test.describe("Zero panels", () => { expect(active).toBe("solo"); }); + test("restore without a table on an empty element rejects atomically", async ({ + page, + }) => { + await empty(page); + const result = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.resetThemes(["Pro Light", "Pro Dark"]); + const theme_before = viewer.getAttribute("theme"); + let message = ""; + try { + // @ts-ignore + await viewer.restore({ theme: "Pro Dark", settings: true }); + } catch (e) { + message = String(e); + } + + return { + message, + theme_unchanged: viewer.getAttribute("theme") === theme_before, + }; + }); + + expect(result.message).toContain("table"); + expect(result.theme_unchanged).toBe(true); + expect(await panel_names(page)).toEqual([]); + await expect( + page.locator("perspective-viewer #settings_panel"), + ).toBeHidden(); + }); + + test("restore of an empty patch on an empty element rejects", async ({ + page, + }) => { + await empty(page); + const message = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + try { + // @ts-ignore + await viewer.restore({}); + return ""; + } catch (e) { + return String(e); + } + }); + + expect(message).toContain("table"); + expect(await panel_names(page)).toEqual([]); + }); + + test("restore with view state but no table rejects — no deferred panel", async ({ + page, + }) => { + await empty(page); + const message = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + try { + // @ts-ignore + await viewer.restore( + { columns: ["Sales"] }, + { panel: "deferred" }, + ); + return ""; + } catch (e) { + return String(e); + } + }); + + expect(message).toContain("table"); + expect(await panel_names(page)).toEqual([]); + }); + test("addPanel from empty activates the new panel", async ({ page }) => { await empty(page); const id = await page.evaluate(async (table) => { diff --git a/rust/perspective-viewer/test/js/status_indicator/updating.spec.ts b/rust/perspective-viewer/test/js/status_indicator/updating.spec.ts index d6119c06e9..689ef89cc1 100644 --- a/rust/perspective-viewer/test/js/status_indicator/updating.spec.ts +++ b/rust/perspective-viewer/test/js/status_indicator/updating.spec.ts @@ -83,32 +83,6 @@ test.describe("StatusIndicator 'updating' settles", () => { await assert_settled(page); }); - test("after a deferred-draw restore, before and after load (T3)", async ({ - page, - }) => { - await goto(page, "/rust/perspective-viewer/test/html/superstore.html"); - await page.evaluate(async () => { - const viewer = document.createElement("perspective-viewer") as any; - viewer.setAttribute("id", "deferred"); - viewer.style.cssText = - "position:absolute;top:0;left:0;width:400px;height:300px;"; - document.body.appendChild(viewer); - await viewer.restore({ group_by: ["State"] }); - }); - - await page.evaluate(async () => { - const viewer = document.querySelector("#deferred") as any; - const table = await (window as any).__TEST_WORKER__.table( - "x,y\n1,2\n3,4", - { name: "deferred-table" }, - ); - await viewer.load(table); - }); - - await open_settings(page, "#deferred"); - await assert_settled(page, "#deferred"); - }); - test("after a table-binding restore (commit_table_defaults) (T4)", async ({ page, }) => { diff --git a/tools/scripts/install_binaryen.mjs b/tools/scripts/install_binaryen.mjs new file mode 100644 index 0000000000..b4570c72d2 --- /dev/null +++ b/tools/scripts/install_binaryen.mjs @@ -0,0 +1,80 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import path from "path"; +import os from "os"; +import fs from "fs"; +import { getWorkspaceRoot, getWorkspacePackageJson } from "./workspace.mjs"; + +import "zx/globals"; + +const pkg = getWorkspacePackageJson(); +const binaryen = pkg.binaryen; + +/// Native Binaryen release binaries (`rust/bundle` shells out to `wasm-opt`; +/// the `wasm-opt` crate is unmaintained at Binaryen 116 and the `binaryen` +/// npm package's wasm-under-Node build is ~10x slower than native). +function base() { + return path.resolve(getWorkspaceRoot(), ".binaryen").replace(/\\/g, "/"); +} + +function platform() { + const arch = { arm64: "arm64", x64: "x86_64" }[os.arch()]; + const system = { + Darwin: "macos", + Linux: "linux", + Windows_NT: "windows", + }[os.type()]; + + if (!arch || !system) { + throw new Error(`No Binaryen release for ${os.type()}/${os.arch()}`); + } + + // Linux arm64 releases use the `aarch64` spelling. + return system === "linux" && arch === "arm64" + ? "aarch64-linux" + : `${arch}-${system}`; +} + +function version_check() { + const marker = path.join(base(), "VERSION"); + return fs.existsSync(marker) && fs.readFileSync(marker, "utf8") === binaryen; +} + +async function toolchain_install() { + console.log(`-- Installing Binaryen ${binaryen}`); + const url = `https://github.com/WebAssembly/binaryen/releases/download/${binaryen}/binaryen-${binaryen}-${platform()}.tar.gz`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${response.status} fetching ${url}`); + } + + const tarball = path.join(os.tmpdir(), `binaryen-${binaryen}.tar.gz`); + fs.writeFileSync(tarball, Buffer.from(await response.arrayBuffer())); + fs.rmSync(base(), { recursive: true, force: true }); + fs.mkdirSync(base(), { recursive: true }); + + // Strip the `binaryen-version_NNN/` prefix so the layout is stable at + // `.binaryen/bin/wasm-opt` regardless of pinned version. + $.sync`tar -xzf ${tarball} -C ${base()} --strip-components=1`; + fs.rmSync(tarball, { force: true }); + fs.writeFileSync(path.join(base(), "VERSION"), binaryen); + console.log(`-- Binaryen ${binaryen} installed`); +} + +if (!process.env.PSP_SKIP_BINARYEN_INSTALL) { + if (version_check()) { + console.log(`-- Binaryen ${binaryen} already installed`); + } else { + await toolchain_install(); + } +} From 5c64a21e12a46afce4960d71ce5dfa1ab5e4ec26 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 20 Aug 2026 12:24:19 -0400 Subject: [PATCH 08/14] Fix expression validation crashes (infinite loops, buffer overflow) Signed-off-by: Andrew Stein --- .../test/js/expressions/vectors.spec.js | 121 +++++++++++++++++ .../cmake/modules/FindInstallDependency.cmake | 2 +- .../src/cpp/computed_expression.cpp | 125 ++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/rust/perspective-js/test/js/expressions/vectors.spec.js b/rust/perspective-js/test/js/expressions/vectors.spec.js index 3a5094825b..0ca6614a1a 100644 --- a/rust/perspective-js/test/js/expressions/vectors.spec.js +++ b/rust/perspective-js/test/js/expressions/vectors.spec.js @@ -151,4 +151,125 @@ import * as common from "./common.js"; await table.delete(); }); }); + + // Constant vector indices are rejected at parse time by ExprTk itself; + // runtime-computed indices and loop bounds are only checked during the + // single validation evaluation, and previously trapped or corrupted the + // engine's memory instead of reporting a validation error. + test.describe("Vector and loop runtime checks", () => { + const OOB_ERROR = "Runtime Error - Vector index out of bounds."; + const LOOP_ERROR = "Runtime Error - Exceeded maximum loop iterations."; + + test("Far out-of-bounds vector write is a validation error", async () => { + const table = await perspective.table(common.int_float_data); + const validate = await table.validate_expressions({ + a: `var v[3]; var i := 1000000000; v[i] := 1; v[0]`, + }); + + expect(validate.expression_schema["a"]).toBeUndefined(); + expect(validate.errors["a"]).toEqual({ + column: 0, + error_message: OOB_ERROR, + line: 0, + }); + + await table.delete(); + }); + + test("Far out-of-bounds vector read is a validation error", async () => { + const table = await perspective.table(common.int_float_data); + const validate = await table.validate_expressions({ + a: `var v[3]; var i := 1000000000; v[i]`, + }); + + expect(validate.expression_schema["a"]).toBeUndefined(); + expect(validate.errors["a"]).toEqual({ + column: 0, + error_message: OOB_ERROR, + line: 0, + }); + + await table.delete(); + }); + + test("Off-by-a-few vector write in a loop is a validation error", async () => { + const table = await perspective.table(common.int_float_data); + const validate = await table.validate_expressions({ + a: `var v[3]; for (var i := 0; i < 5; i += 1) { v[i] := i }; v[0]`, + }); + + expect(validate.expression_schema["a"]).toBeUndefined(); + expect(validate.errors["a"]).toEqual({ + column: 0, + error_message: OOB_ERROR, + line: 0, + }); + + await table.delete(); + }); + + test("Out-of-bounds vector access rejects `view()`", async () => { + const table = await perspective.table(common.int_float_data); + await expect( + table.view({ + expressions: { + a: `var v[3]; var i := 5; v[i] := 1; v[0]`, + }, + }), + ).rejects.toThrow(); + await table.delete(); + }); + + test("Single loop over the iteration budget is a validation error", async () => { + const table = await perspective.table(common.int_float_data); + const validate = await table.validate_expressions({ + a: `var x := 0; for (var i := 0; i < 2000000; i += 1) { x += 1 }; x`, + }); + + expect(validate.expression_schema["a"]).toBeUndefined(); + expect(validate.errors["a"]).toEqual({ + column: 0, + error_message: LOOP_ERROR, + line: 0, + }); + + await table.delete(); + }); + + test("Nested loops over the cumulative iteration budget is a validation error", async () => { + const table = await perspective.table(common.int_float_data); + const validate = await table.validate_expressions({ + a: `var x := 0; for (var i := 0; i < 2000; i += 1) { for (var j := 0; j < 2000; j += 1) { x += 1 } }; x`, + }); + + expect(validate.expression_schema["a"]).toBeUndefined(); + expect(validate.errors["a"]).toEqual({ + column: 0, + error_message: LOOP_ERROR, + line: 0, + }); + + await table.delete(); + }); + + test("In-bounds dynamic vector access in a loop still validates and computes", async () => { + const table = await perspective.table(common.int_float_data); + const validate = await table.validate_expressions({ + a: `var v[10]; var s := 0; for (var i := 0; i < 10; i += 1) { v[i] := i * 2; s += v[i] }; s`, + }); + + expect(validate.errors["a"]).toBeUndefined(); + expect(validate.expression_schema["a"]).toEqual("float"); + + const view = await table.view({ + expressions: { + a: `var v[10]; var s := 0; for (var i := 0; i < 10; i += 1) { v[i] := i * 2; s += v[i] }; s`, + }, + }); + const result = await view.to_columns(); + expect(result["a"]).toEqual(Array(4).fill(90)); + await view.delete(); + await table.delete(); + }); + }); })(perspective); diff --git a/rust/perspective-server/cmake/modules/FindInstallDependency.cmake b/rust/perspective-server/cmake/modules/FindInstallDependency.cmake index a9dabbc635..b8c103f72c 100644 --- a/rust/perspective-server/cmake/modules/FindInstallDependency.cmake +++ b/rust/perspective-server/cmake/modules/FindInstallDependency.cmake @@ -49,7 +49,7 @@ function(psp_build_dep name cmake_file) set(ARROW_WITH_ZSTD ON) set(ARROW_WITH_LZ4 ON) set(ARROW_NO_EXPORT ON) - set(ARROW_CXXFLAGS " -Wno-documentation ") + set(ARROW_CXXFLAGS "-Wno-documentation" CACHE STRING "" FORCE) set(ARROW_DEPENDENCY_SOURCE "BUNDLED" CACHE STRING "override arrow's dependency source" FORCE) if(PSP_WASM_BUILD) set(ARROW_ENABLE_THREADING OFF) diff --git a/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp b/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp index 155538bfe8..882e398d98 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp @@ -12,10 +12,100 @@ #include +#include #include namespace perspective { +namespace { + +// Runtime guards for the single-shot `expression.value()` calls in +// `precompute()` / `get_dtype()`. ExprTk only emits its bounds-checked +// `*_rtc_node` AST variants when a check is registered on the parser at +// compile() time, so these must NEVER be registered on the parser owned by +// `t_computed_expression` -- `compute()` must keep the unchecked node +// variants on the per-row path, which is only ever reached by expressions +// that already passed validation. + +// Out-of-range dynamic vector access (e.g. `v[i]` where `i` is only known +// at eval time) traps or silently corrupts the heap without this check. +struct t_validation_vector_access_check + : exprtk::vector_access_runtime_check { + bool + handle_runtime_violation(violation_context& /*context*/) override { + // Returning false clamps the access to the vector's base element, + // so evaluation completes harmlessly; the flag turns the result + // into a validation error afterwards. The base impl throws. + m_violation = true; + return false; + } + + bool m_violation = false; +}; + +// Iteration budget, so a mid-edit `while (1 > 0) {}` fails validation +// instead of hanging the engine. +struct t_validation_loop_check : exprtk::loop_runtime_check { + explicit t_validation_loop_check(std::uint64_t budget) : + m_remaining(budget) { + loop_set = e_all_loops; + max_loop_iterations = budget; + } + + // ExprTk's own `max_loop_iterations` is per loop *entry*, which nested + // loops multiply; this virtual is consulted on every iteration of every + // loop, so it enforces the budget cumulatively across the whole + // evaluation. + bool + check() override { + if (m_remaining == 0) { + return false; + } + --m_remaining; + return true; + } + + void + handle_runtime_violation(const violation_context& /*context*/) override { + // Must not throw (the base impl does): returning normally lets the + // loop node terminate via check() == false and evaluation completes + // cleanly, with every subsequent loop entry short-circuiting. + m_violation = true; + } + + std::uint64_t m_remaining; + bool m_violation = false; +}; + +// Generous for any sane single-row evaluation, small enough to keep a +// pathological validation bounded well under a second. +constexpr std::uint64_t VALIDATION_MAX_LOOP_ITERATIONS = 1000000; + +// Registration is parser-wide state; scope it strictly to the validation +// compile + value() call so no other compile on this parser can observe it. +struct t_validation_check_guard { + PSP_NON_COPYABLE(t_validation_check_guard); + + t_validation_check_guard( + exprtk::parser& parser, + t_validation_vector_access_check& vector_check, + t_validation_loop_check& loop_check + ) : + m_parser(parser) { + m_parser.register_vector_access_runtime_check(vector_check); + m_parser.register_loop_runtime_check(loop_check); + } + + ~t_validation_check_guard() { + m_parser.clear_vector_access_runtime_check(); + m_parser.clear_loop_runtime_check(); + } + + exprtk::parser& m_parser; +}; + +} // namespace + computed_function::bucket t_computed_expression_parser::BUCKET_FN = computed_function::bucket(); @@ -361,6 +451,10 @@ t_computed_expression_parser::precompute( sym_table.add_variable(column_id, values[cidx]); } + t_validation_vector_access_check vector_check; + t_validation_loop_check loop_check(VALIDATION_MAX_LOOP_ITERATIONS); + const t_validation_check_guard guard(*m_parser, vector_check, loop_check); + exprtk::expression expr_definition; expr_definition.register_symbol_table(sym_table); @@ -376,6 +470,18 @@ t_computed_expression_parser::precompute( t_tscalar v = expr_definition.value(); function_store.clear_computed_function_state(); + if (vector_check.m_violation || loop_check.m_violation) { + std::stringstream ss; + ss << "[t_computed_expression_parser::precompute] Runtime error in " + "expression: `" + << parsed_expression_string << "`, " + << (vector_check.m_violation + ? "vector index out of bounds" + : "exceeded maximum loop iterations") + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + return std::make_shared( expression_alias, expression_string, @@ -451,6 +557,10 @@ t_computed_expression_parser::get_dtype( sym_table.add_variable(column_id, values[cidx]); } + t_validation_vector_access_check vector_check; + t_validation_loop_check loop_check(VALIDATION_MAX_LOOP_ITERATIONS); + const t_validation_check_guard guard(*m_parser, vector_check, loop_check); + exprtk::expression expr_definition; expr_definition.register_symbol_table(sym_table); @@ -491,6 +601,21 @@ t_computed_expression_parser::get_dtype( function_store.clear_computed_function_state(); + if (vector_check.m_violation) { + error.m_error_message = "Runtime Error - Vector index out of bounds."; + error.m_line = 0; + error.m_column = 0; + return DTYPE_NONE; + } + + if (loop_check.m_violation) { + error.m_error_message = + "Runtime Error - Exceeded maximum loop iterations."; + error.m_line = 0; + error.m_column = 0; + return DTYPE_NONE; + } + if (v.m_status == STATUS_CLEAR || dtype == DTYPE_NONE) { error.m_error_message = "Type Error - inputs do not resolve to a valid expression."; From 23459cabd78f949f3f1c481d346f61f430268a32 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 20 Aug 2026 23:37:59 -0400 Subject: [PATCH 09/14] Worker thread compositing for `viewer-charts` Signed-off-by: Andrew Stein --- .../src/ts/transport/protocol.ts | 16 +++- .../src/ts/transport/renderer-transport.ts | 35 ++++++--- .../src/ts/worker/renderer.worker.ts | 76 +++++++++++++++++-- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/packages/viewer-charts/src/ts/transport/protocol.ts b/packages/viewer-charts/src/ts/transport/protocol.ts index ad38ff2711..687ece17d5 100644 --- a/packages/viewer-charts/src/ts/transport/protocol.ts +++ b/packages/viewer-charts/src/ts/transport/protocol.ts @@ -100,8 +100,20 @@ export interface InitMsg { * `dpr` and there is no host-side GL drawing buffer. */ glCanvas?: OffscreenCanvas; - gridlinesCanvas: OffscreenCanvas; - chromeCanvas: OffscreenCanvas; + + /** + * The gridlines (bottom) and chrome/axes (top) 2D layers. + * Transferred via `transferControlToOffscreen` on the host, present + * iff `renderMode === "direct"` — their pixels reach the screen + * through the compositor. In blit mode the renderer allocates + * worker-local surfaces instead and composites both layers into the + * shipped `FrameBitmapMsg`, so every layer of the frame rides the + * host's staged-present hold (a transferred canvas presents on the + * compositor's own schedule, unsynchronized with the host's layout + * commits — the one-frame axes warp on anticipated resizes). + */ + gridlinesCanvas?: OffscreenCanvas; + chromeCanvas?: OffscreenCanvas; /** * `MessagePort` to the host's `ProxySession`. Worker mode only — diff --git a/packages/viewer-charts/src/ts/transport/renderer-transport.ts b/packages/viewer-charts/src/ts/transport/renderer-transport.ts index 0f2881ed6a..37a3d7be46 100644 --- a/packages/viewer-charts/src/ts/transport/renderer-transport.ts +++ b/packages/viewer-charts/src/ts/transport/renderer-transport.ts @@ -299,15 +299,23 @@ export class RendererTransport { // `ImageBitmap`. Direct mode transfers the visible canvas's // drawing buffer to the renderer so GL paints straight to // screen. + // Blit mode also keeps the gridlines/chrome layers worker-side: + // a transferred canvas presents on the compositor's schedule, + // unsynchronized with the host's layout commits, so it can't + // participate in the staged-present hold. The renderer + // composites both layers into the shipped frame instead, and + // the host-side placeholder canvases stay untouched + // (transparent). Direct mode transfers all three surfaces. let glOC: OffscreenCanvas | undefined; + let gridlinesOC: OffscreenCanvas | undefined; + let chromeOC: OffscreenCanvas | undefined; if (opts.renderBlitMode === "blit") { this._displayCtx = opts.gl.getContext("2d"); } else { glOC = opts.gl.transferControlToOffscreen(); + gridlinesOC = opts.gridlines.transferControlToOffscreen(); + chromeOC = opts.chrome.transferControlToOffscreen(); } - - const gridlinesOC = opts.gridlines.transferControlToOffscreen(); - const chromeOC = opts.chrome.transferControlToOffscreen(); const rect = opts.gl.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const themeVars = snapshotThemeVars(opts.gl); @@ -365,14 +373,19 @@ export class RendererTransport { // Worker mode: the bootstrap is triggered by posting the // init message into the worker's scope (which the // `if (IS_WORKER_SCOPE)` block in `renderer.worker.ts` - // listens for). `glOC` is omitted in blit mode (the - // renderer allocates its own offscreen) — only include the - // GL canvas in the transfer list when present. - const transfer: Transferable[] = [ - gridlinesOC, - chromeOC, - this._proxyChannel!.port2, - ]; + // listens for). All three canvases are omitted in blit mode + // (the renderer allocates its own offscreens and ships + // composited frames) — only include the transferred + // surfaces present on this mode's init message. + const transfer: Transferable[] = [this._proxyChannel!.port2]; + if (chromeOC) { + transfer.unshift(chromeOC); + } + + if (gridlinesOC) { + transfer.unshift(gridlinesOC); + } + if (glOC) { transfer.unshift(glOC); } diff --git a/packages/viewer-charts/src/ts/worker/renderer.worker.ts b/packages/viewer-charts/src/ts/worker/renderer.worker.ts index dae225e4a9..007bf94d07 100644 --- a/packages/viewer-charts/src/ts/worker/renderer.worker.ts +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -106,6 +106,15 @@ export class WorkerRenderer { cssWidth: number; cssHeight: number; dpr: number; + + /** + * Blit-mode compose surface: gridlines + GL frame + chrome are + * drawn here each `endFrame` and shipped as ONE `ImageBitmap`, so + * every layer rides the host's staged-present hold (see + * `InitMsg.gridlinesCanvas`). `null` in direct mode. + */ + private _composeCanvas: OffscreenCanvas | null = null; + private _composeCtx: OffscreenCanvasRenderingContext2D | null = null; client: Client; view: View; @@ -182,20 +191,29 @@ export class WorkerRenderer { }); } + // Blit mode: the 2D layers are worker-local (the host omits + // them from the init message) and every shipped frame is the + // full composite — gridlines under the GL plot under chrome, + // the same stacking as the host's canvas elements and the + // `snapshotPng` composite. Direct mode draws into the host's + // transferred surfaces and ships nothing. + const w = Math.max(1, Math.round(msg.cssWidth * msg.dpr)); + const h = Math.max(1, Math.round(msg.cssHeight * msg.dpr)); + this.gridlines = msg.gridlinesCanvas ?? new OffscreenCanvas(w, h); + this.chrome = msg.chromeCanvas ?? new OffscreenCanvas(w, h); if (msg.renderMode === "blit") { this.glManager.setFrameCallback((bitmap) => { - this.post({ kind: "frameBitmap", bitmap }, [bitmap]); + const frame = this._composeFrame(bitmap); + this.post({ kind: "frameBitmap", bitmap: frame }, [frame]); }); } - this.gridlines = msg.gridlinesCanvas; - this.chrome = msg.chromeCanvas; this.cssWidth = msg.cssWidth; this.cssHeight = msg.cssHeight; this.dpr = msg.dpr; - this.chartImpl.setGridlineCanvas?.(msg.gridlinesCanvas); - this.chartImpl.setChromeCanvas?.(msg.chromeCanvas); + this.chartImpl.setGridlineCanvas?.(this.gridlines); + this.chartImpl.setChromeCanvas?.(this.chrome); this.chartImpl.setTheme?.(msg.themeVars); if (msg.defaultChartType) { @@ -588,6 +606,54 @@ export class WorkerRenderer { return tt ?? null; } + /** + * Blit-mode frame composite: draw the gridlines (bottom), the GL + * plot bitmap (middle) and the chrome/axes (top) into the compose + * surface and transfer the result — the single `ImageBitmap` the + * host blits, and the unit the staged-present hold stages. Runs in + * `endFrame`, which the scheduler orders after `render2D`'s 2D + * flush, so both layers hold this frame's content. + * + * A layer whose buffer differs from the plot's dimensions is + * skipped rather than scaled: its content is a stale-sized frame + * mid-resize (the 2D draws re-size their canvas to `css × dpr` on + * the next flush), and scaling it would re-introduce exactly the + * warp this composite exists to prevent. + */ + private _composeFrame(plot: ImageBitmap): ImageBitmap { + const w = plot.width; + const h = plot.height; + if (!this._composeCanvas) { + this._composeCanvas = new OffscreenCanvas(w, h); + this._composeCtx = this._composeCanvas.getContext("2d"); + } + + const canvas = this._composeCanvas; + const ctx = this._composeCtx; + if (!ctx) { + return plot; + } + + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } else { + ctx.clearRect(0, 0, w, h); + } + + if (this.gridlines.width === w && this.gridlines.height === h) { + ctx.drawImage(this.gridlines, 0, 0); + } + + ctx.drawImage(plot, 0, 0); + plot.close(); + if (this.chrome.width === w && this.chrome.height === h) { + ctx.drawImage(this.chrome, 0, 0); + } + + return canvas.transferToImageBitmap(); + } + /** * Composite the three layers into a single PNG `Blob`. */ From 8f502a32c6829ba18a894772a1b8c8f998d292e0 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Fri, 21 Aug 2026 14:37:02 -0400 Subject: [PATCH 10/14] Column pin fix Signed-off-by: Andrew Stein --- .../viewer-charts/test/ts/domain-mode.spec.ts | 258 ------------------ .../test/ts/glyph-z-order.spec.ts | 144 ---------- .../src/rust/components/viewer.rs | 10 +- .../src/rust/components/viewer/msg.rs | 10 + .../src/rust/components/viewer/settings.rs | 145 +++++++++- .../src/rust/components/viewer/snapshots.rs | 38 ++- .../src/rust/tasks/presize_panels.rs | 49 +++- .../test/js/column_settings/drawer.spec.ts | 197 +++++++++++++ .../test/js/windows.spec.ts | 58 ---- 9 files changed, 431 insertions(+), 478 deletions(-) delete mode 100644 packages/viewer-charts/test/ts/domain-mode.spec.ts delete mode 100644 packages/viewer-charts/test/ts/glyph-z-order.spec.ts create mode 100644 rust/perspective-viewer/test/js/column_settings/drawer.spec.ts diff --git a/packages/viewer-charts/test/ts/domain-mode.spec.ts b/packages/viewer-charts/test/ts/domain-mode.spec.ts deleted file mode 100644 index 51d59f97ea..0000000000 --- a/packages/viewer-charts/test/ts/domain-mode.spec.ts +++ /dev/null @@ -1,258 +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). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { Page } from "@playwright/test"; -import { test, expect } from "@perspective-dev/test"; -import { - calibratePlotBaseline, - gotoBasic, - restoreChart, - waitOneFrame, - type PlotRegionFrac, -} from "./helpers"; - -/** Canvas-fraction regions safely inside the plot rect (clear of the - * ~70px left gutter and bottom axis band at the 1280×720 viewport). */ -const RIGHT: PlotRegionFrac = { x: 0.6, y: 0.15, w: 0.3, h: 0.6 }; -const TOP: PlotRegionFrac = { x: 0.15, y: 0.03, w: 0.7, h: 0.2 }; -const BOTTOM: PlotRegionFrac = { x: 0.15, y: 0.55, w: 0.7, h: 0.25 }; - -const EMPTY_MAX = 20; -const POPULATED_MIN = 100; - -/** - * Sample `region` until `pred(pixels)` holds (the debounced update-redraw - * has landed) or the timeout elapses; returns the last sample either way - * so the caller's assertion reports the real value. - */ -async function pollPixels( - page: Page, - region: PlotRegionFrac, - pred: (n: number) => boolean, - timeoutMs = 8000, -): Promise { - const start = Date.now(); - let last = -1; - for (;;) { - last = await calibratePlotBaseline(page, { plotRegionFrac: region }); - if (pred(last) || Date.now() - start > timeoutMs) { - return last; - } - - await page.waitForTimeout(100); - } -} - -/** Remove every row whose `Order Date` is later than the `keepDistinct`th - * distinct date — shrinks the datetime category domain to a narrow - * leading window. */ -async function removeLaterDates(page: Page, keepDistinct: number) { - await page.evaluate(async (keep: number) => { - const worker = (window as any).__TEST_WORKER__; - const table = await worker.open_table("load-viewer-csv"); - const view = await table.view({ columns: ["Row ID", "Order Date"] }); - const cols = await view.to_columns(); - await view.delete(); - const dates = Array.from(new Set(cols["Order Date"] as number[])).sort( - (a, b) => a - b, - ); - const cutoff = dates[Math.min(keep, dates.length - 1)]; - const remove = []; - for (let i = 0; i < cols["Row ID"].length; i++) { - if (cols["Order Date"][i] > cutoff) { - remove.push(cols["Row ID"][i]); - } - } - - await table.remove(remove); - }, keepDistinct); -} - -/** Remove every row where `|column| > maxAbs` — collapses the value - * extent to a narrow band around zero. */ -async function removeExtremes(page: Page, column: string, maxAbs: number) { - await page.evaluate( - async ({ column, maxAbs }: { column: string; maxAbs: number }) => { - const worker = (window as any).__TEST_WORKER__; - const table = await worker.open_table("load-viewer-csv"); - const view = await table.view({ columns: ["Row ID", column] }); - const cols = await view.to_columns(); - await view.delete(); - const remove = []; - for (let i = 0; i < cols["Row ID"].length; i++) { - if (Math.abs(cols[column][i]) > maxAbs) { - remove.push(cols["Row ID"][i]); - } - } - - await table.remove(remove); - }, - { column, maxAbs }, - ); -} - -/** - * Replace the fixture table's rows IN PLACE (remove all + `update`) with a - * controlled shape. The retention scenarios sample fixed canvas bands, and - * the shared 99-row fixture defeats them as-is: its Profit domain - * (`[-1665, +299]`) is so asymmetric that zero sits INSIDE the top band (a - * correctly-RETAINED axis then keeps the band populated), and its 2 - * high-`Sales` points miss the right band entirely. Reshaping — rather - * than loading a second table — keeps the panel bound to the same table - * and schema, so the scenario drives only the update-redraw path. - */ -async function reshapeFixture( - page: Page, - rows: { "Order Date": string; Profit: number; Sales: number }[], -) { - await page.evaluate(async (rows) => { - const worker = (window as any).__TEST_WORKER__; - const table = await worker.open_table("load-viewer-csv"); - const view = await table.view({ columns: ["Row ID"] }); - const cols = await view.to_columns(); - await view.delete(); - await table.remove(cols["Row ID"]); - await table.update({ - "Row ID": rows.map((_, i) => 100000 + i), - "Order Date": rows.map((r) => r["Order Date"]), - Profit: rows.map((r) => r.Profit), - Sales: rows.map((r) => r.Sales), - }); - }, rows); -} - -/** `count` consecutive days from 2020-01-01, as `YYYY-MM-DD`. */ -function dates(count: number): string[] { - return Array.from({ length: count }, (_, i) => { - const d = new Date(Date.UTC(2020, 0, 1 + i)); - return d.toISOString().slice(0, 10); - }); -} - -test.describe("domain_mode axis scope", () => { - test.beforeEach(async ({ page }) => { - await gotoBasic(page); - }); - - test("Y Line: the datetime category axis fits after rows depart", async ({ - page, - }) => { - await restoreChart(page, { - plugin: "Y Line", - columns: ["Profit"], - group_by: ["Order Date"], - } as never); - await waitOneFrame(page); - - expect( - await pollPixels(page, RIGHT, (n) => n > POPULATED_MIN), - ).toBeGreaterThan(POPULATED_MIN); - - await removeLaterDates(page, 30); - expect( - await pollPixels(page, RIGHT, (n) => n > POPULATED_MIN), - ).toBeGreaterThan(POPULATED_MIN); - }); - - test("Y Line: the value axis retains its extent under expand, refits under fit", async ({ - page, - }) => { - await reshapeFixture( - page, - dates(40).map((d, i) => ({ - "Order Date": d, - Profit: - i === 19 ? 1000 : i === 20 ? -1000 : i % 2 === 0 ? 10 : -10, - Sales: 1, - })), - ); - - await restoreChart(page, { - plugin: "Y Line", - columns: ["Profit"], - group_by: ["Order Date"], - } as never); - await waitOneFrame(page); - - expect( - await pollPixels(page, TOP, (n) => n > EMPTY_MAX), - ).toBeGreaterThan(EMPTY_MAX); - - await removeExtremes(page, "Profit", 20); - expect(await pollPixels(page, TOP, (n) => n < EMPTY_MAX)).toBeLessThan( - EMPTY_MAX, - ); - - await restoreChart(page, { - plugin_config: { domain_mode: "fit" }, - } as never); - expect( - await pollPixels(page, TOP, (n) => n > EMPTY_MAX), - ).toBeGreaterThan(EMPTY_MAX); - }); - - test("X Bar: the category axis (Y) fits after rows depart", async ({ - page, - }) => { - await restoreChart(page, { - plugin: "X Bar", - columns: ["Profit"], - group_by: ["Order Date"], - } as never); - await waitOneFrame(page); - - expect( - await pollPixels(page, BOTTOM, (n) => n > POPULATED_MIN), - ).toBeGreaterThan(POPULATED_MIN); - - await removeLaterDates(page, 30); - expect( - await pollPixels(page, BOTTOM, (n) => n > POPULATED_MIN), - ).toBeGreaterThan(POPULATED_MIN); - }); - - test("X/Y Scatter: BOTH axes retain their extent under expand", async ({ - page, - }) => { - await reshapeFixture( - page, - dates(40).map((d, i) => ({ - "Order Date": d, - Profit: i === 2 ? 500 : i === 4 ? -500 : 0, - Sales: i * 25, - })), - ); - - await restoreChart(page, { - plugin: "X/Y Scatter", - columns: ["Sales", "Profit"], - } as never); - await waitOneFrame(page); - - expect( - await pollPixels(page, RIGHT, (n) => n > EMPTY_MAX), - ).toBeGreaterThan(EMPTY_MAX); - - await removeExtremes(page, "Sales", 500); - expect( - await pollPixels(page, RIGHT, (n) => n < EMPTY_MAX), - ).toBeLessThan(EMPTY_MAX); - - await restoreChart(page, { - plugin_config: { domain_mode: "fit" }, - } as never); - - expect( - await pollPixels(page, RIGHT, (n) => n > EMPTY_MAX), - ).toBeGreaterThan(EMPTY_MAX); - }); -}); diff --git a/packages/viewer-charts/test/ts/glyph-z-order.spec.ts b/packages/viewer-charts/test/ts/glyph-z-order.spec.ts deleted file mode 100644 index 19289e1afd..0000000000 --- a/packages/viewer-charts/test/ts/glyph-z-order.spec.ts +++ /dev/null @@ -1,144 +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). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { Page } from "@playwright/test"; -import { expect, test } from "@perspective-dev/test"; -import { gotoBasic, restoreChart, waitOneFrame } from "./helpers"; - -const SETTLE_MS = 500; - -const FIXTURE = { - plugin: "Y Bar", - group_by: ["Category"], - expressions: { b100: "100", l50: "50" }, - aggregates: { b100: "avg", l50: "avg" }, - columns_config: { - b100: { chart_type: "bar" }, - l50: { chart_type: "line" }, - }, -}; - -/** - * Dominant-color share of the widest fully-opaque row of the visible - * `.webgl-canvas` (colors quantized >>3 per channel to absorb AA). - */ -async function lineRowModeFraction(page: Page): Promise { - return await page.evaluate(() => { - const visit = ( - root: Document | ShadowRoot, - ): HTMLCanvasElement | null => { - const direct = root.querySelector( - ".webgl-canvas", - ) as HTMLCanvasElement | null; - if (direct) { - return direct; - } - - for (const el of Array.from(root.querySelectorAll("*"))) { - const sr = (el as Element & { shadowRoot?: ShadowRoot }) - .shadowRoot; - if (sr) { - const found = visit(sr); - if (found) { - return found; - } - } - } - - return null; - }; - - const canvas = visit(document); - if (!canvas || canvas.width === 0 || canvas.height === 0) { - throw new Error("glyph-z-order: no .webgl-canvas found"); - } - - const sampler = document.createElement("canvas"); - sampler.width = canvas.width; - sampler.height = canvas.height; - const ctx = sampler.getContext("2d", { willReadFrequently: true })!; - ctx.drawImage(canvas, 0, 0); - const { data } = ctx.getImageData(0, 0, sampler.width, sampler.height); - - const W = sampler.width; - const H = sampler.height; - - // Row with the most opaque pixels = the line's center row: the - // line crosses the entire plot width while bars cover only the - // band fraction, and the line's AA edge rows aren't opaque. - let bestY = -1; - let bestCount = 0; - for (let y = 0; y < H; y++) { - let count = 0; - for (let x = 0; x < W; x++) { - if (data[(y * W + x) * 4 + 3] > 200) { - count++; - } - } - - if (count > bestCount) { - bestCount = count; - bestY = y; - } - } - - if (bestY < 0 || bestCount < W * 0.3) { - throw new Error( - `glyph-z-order: no line row found (best ${bestCount}/${W})`, - ); - } - - const histogram = new Map(); - for (let x = 0; x < W; x++) { - const i = (bestY * W + x) * 4; - if (data[i + 3] <= 200) { - continue; - } - - const key = - ((data[i] >> 3) << 10) | - ((data[i + 1] >> 3) << 5) | - (data[i + 2] >> 3); - histogram.set(key, (histogram.get(key) ?? 0) + 1); - } - - let mode = 0; - for (const count of histogram.values()) { - mode = Math.max(mode, count); - } - - return mode / bestCount; - }); -} - -async function renderAndMeasure( - page: Page, - columns: string[], -): Promise { - await gotoBasic(page); - await restoreChart(page, { ...FIXTURE, columns } as never); - await page.waitForTimeout(SETTLE_MS); - await waitOneFrame(page); - return await lineRowModeFraction(page); -} - -test.describe("Mixed-glyph Z-order follows columns order", () => { - test("bar declared after line occludes it", async ({ page }) => { - const share = await renderAndMeasure(page, ["l50", "b100"]); - expect(share).toBeLessThan(0.85); - }); - - test("line declared after bar stays on top", async ({ page }) => { - const share = await renderAndMeasure(page, ["b100", "l50"]); - expect(share).toBeGreaterThan(0.9); - }); -}); diff --git a/rust/perspective-viewer/src/rust/components/viewer.rs b/rust/perspective-viewer/src/rust/components/viewer.rs index 4d4bd2f202..d3e304be54 100644 --- a/rust/perspective-viewer/src/rust/components/viewer.rs +++ b/rust/perspective-viewer/src/rust/components/viewer.rs @@ -339,19 +339,23 @@ impl Component for PerspectiveViewer { } => self.on_open_column_settings(ctx, locator, sender, toggle), ColumnSettingsPanelSizeUpdate(x) => self.on_column_settings_panel_size_update(x), ColumnSettingsPanelAutoWidth(w) => self.on_column_settings_panel_auto_width(w), - ToggleColumnSettingsPin => self.on_toggle_column_settings_pin(), + ToggleColumnSettingsPin => self.on_toggle_column_settings_pin(ctx), + ToggleColumnSettingsPinComplete(resolve) => { + self.on_toggle_column_settings_pin_complete(resolve) + }, ColumnSettingsTabChanged(tab) => self.on_column_settings_tab_changed(ctx, tab), ToggleDebug => self.on_toggle_debug(ctx), // Value-semantic snapshot plumbing (`snapshots.rs`) - UpdateSession(props) => self.on_update_session(*props), + UpdateSession(props) => self.on_update_session(ctx, *props), UpdateSessionStats(stats, has_table) => self.on_update_session_stats(stats, has_table), UpdateGlobalFilters => self.on_update_global_filters(ctx), UpdateRenderer(props) => self.on_update_renderer(*props), UpdatePresentation(props) => self.on_update_presentation(ctx, *props), UpdateSettingsOpen(open) => self.on_update_settings_open(open), UpdateIsWorkspace(is_workspace) => self.on_update_is_workspace(is_workspace), - UpdateColumnSettings(ocs) => self.on_update_column_settings(*ocs), + UpdateColumnSettings(ocs) => self.on_update_column_settings(ctx, *ocs), + UpdateColumnSettingsCommit(resolve) => self.on_update_column_settings_commit(resolve), UpdateDragDrop(props) => self.on_update_dragdrop(*props), UpdateInFlight(count) => self.on_update_in_flight(count), } diff --git a/rust/perspective-viewer/src/rust/components/viewer/msg.rs b/rust/perspective-viewer/src/rust/components/viewer/msg.rs index f1d54cd1f3..ac87599995 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/msg.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/msg.rs @@ -48,6 +48,7 @@ pub enum PerspectiveViewerMsg { ColumnSettingsPanelSizeUpdate(Option), ColumnSettingsPanelAutoWidth(f64), ToggleColumnSettingsPin, + ToggleColumnSettingsPinComplete(Sender<()>), ColumnSettingsTabChanged(ColumnSettingsTab), OpenColumnSettings { locator: Option, @@ -162,7 +163,16 @@ pub enum PerspectiveViewerMsg { UpdateIsWorkspace(bool), /// Update only `open_column_settings` in the presentation snapshot. + /// Handled in `settings.rs` (not `snapshots.rs`): a docked-drawer + /// mount/unmount defers the snapshot behind a presize sweep. UpdateColumnSettings(Box), + + /// Every visible panel has rendered at its post-transition box — NOW + /// apply the newest deferred `open_column_settings` target (the + /// latest-wins slot, not a copy captured at sweep spawn); the `Sender` + /// resolves on the render commit so the staged presents reveal in the + /// same paint (mirrors `ToggleSettingsComplete`). + UpdateColumnSettingsCommit(Sender<()>), UpdateDragDrop(Box), /// Update only stats-related fields of `session_props` without touching diff --git a/rust/perspective-viewer/src/rust/components/viewer/settings.rs b/rust/perspective-viewer/src/rust/components/viewer/settings.rs index 6a747508a2..ecd93ee027 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/settings.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/settings.rs @@ -25,7 +25,8 @@ use super::msg::PerspectiveViewerMsg::*; use crate::components::font_loader::FontLoaderStatus; use crate::components::settings_panel::SelectedTab; use crate::config::*; -use crate::presentation::{ColumnLocator, ColumnSettingsTab}; +use crate::presentation::{ColumnLocator, ColumnSettingsTab, OpenColumnSettings}; +use crate::queries::get_current_column_locator; use crate::tasks::*; /// The settings sidebar's geometry state, folded into one field on @@ -69,6 +70,19 @@ pub(super) struct SettingsGeometry { /// than FLOATING over the main panel (the default absolute overlay). /// Session UI state, not part of the saved config. pub column_settings_pinned: bool, + + /// Latest-wins deferred `open_column_settings` snapshot: the newest + /// target not yet applied while a docked-drawer presize sweep is in + /// flight. `UpdateColumnSettingsCommit` applies THIS slot (not a copy + /// captured at spawn), so a newer target arriving mid-sweep wins and + /// out-of-order commits are inexpressible. + pub column_settings_target: Option, + pub column_settings_commit_pending: bool, + + /// The docked drawer's span, cached whenever it is measurable (pin + /// toggle, docked unmount) - used to presize for a drawer that MOUNTS + /// directly into pinned mode, when it isn't in the DOM to measure. + pub column_settings_docked_width: Option, } impl PerspectiveViewer { @@ -402,9 +416,136 @@ impl PerspectiveViewer { } } - pub(super) fn on_toggle_column_settings_pin(&mut self) -> bool { + pub(super) fn on_toggle_column_settings_pin(&mut self, ctx: &Context) -> bool { + let is_pinned = self.settings_geometry.column_settings_pinned; + let delta_w = measure_column_settings_pin_delta(&ctx.props().elem, is_pinned); + if let Some(delta_w) = delta_w { + self.settings_geometry.column_settings_docked_width = Some(delta_w.abs()); + } + + self.presize_column_settings_shift(ctx, delta_w, ToggleColumnSettingsPinComplete); + false + } + + pub(super) fn on_toggle_column_settings_pin_complete(&mut self, resolve: Sender<()>) -> bool { self.settings_geometry.column_settings_pinned = !self.settings_geometry.column_settings_pinned; + self.on_rendered.push(resolve); + true + } + + /// Shared presize choreography for a column-settings geometry shift: + /// pre-size every visible panel by `delta_w` (`None` = no sweep, commit + /// only), send `commit_msg` to apply the deferred state on the render + /// commit, reveal the staged frames in that same paint, then reactively + /// finalize at the exact settled cells (I6). + fn presize_column_settings_shift( + &self, + ctx: &Context, + delta_w: Option, + commit_msg: fn(Sender<()>) -> super::msg::PerspectiveViewerMsg, + ) { + let workspace = ctx.props().workspace.clone(); + let elem = ctx.props().elem.clone(); + let callback = ctx.link().callback(commit_msg); + ApiFuture::spawn(async move { + let presents = match delta_w { + Some(delta_w) => { + presize_visible_panels_open(&workspace, &elem, delta_w, 0.0).await + }, + None => StagedPresents::default(), + }; + + let (notify, rendered) = channel::<()>(); + callback.emit(notify); + rendered.await?; + presents.reveal(); + resize_visible_panels(&workspace).await; + Ok(()) + }); + } + + /// Whether the drawer renders for this `open_column_settings` snapshot + /// (the `render()` mount predicate). Sensitive to the session snapshot + /// too — `snapshots.rs` re-evaluates it across an `UpdateSession` apply, + /// which can invalidate the open column's locator (e.g. a drag + /// replacing it in `columns`) and unmount the drawer without any + /// `UpdateColumnSettings` traffic. + pub(super) fn is_column_settings_mounted(&self, ocs: &OpenColumnSettings) -> bool { + get_current_column_locator( + ocs, + &self.active_renderer, + &self.session_props.config, + &self.session_props.metadata, + ) + .is_some() + } + + /// The `open_column_settings` snapshot handler: mount/unmount of the + /// DOCKED drawer moves `#main_panel_container`'s flex box just like the + /// pin toggle — and no `before-resize` fires for it — so those + /// transitions defer the snapshot behind the presize choreography. + /// Floating transitions and in-place locator changes apply synchronously + /// as before, unless a deferred commit is in flight (they queue into the + /// latest-wins slot to preserve commit order). + pub(super) fn on_update_column_settings( + &mut self, + ctx: &Context, + ocs: OpenColumnSettings, + ) -> bool { + let effective = self + .settings_geometry + .column_settings_target + .as_ref() + .unwrap_or(&self.presentation_props.open_column_settings); + if ocs == *effective { + return false; + } + + let was_mounted = + self.is_column_settings_mounted(&self.presentation_props.open_column_settings); + let will_mount = self.is_column_settings_mounted(&ocs); + let needs_presize = + self.settings_geometry.column_settings_pinned && was_mounted != will_mount; + if !needs_presize && !self.settings_geometry.column_settings_commit_pending { + self.presentation_props.open_column_settings = ocs; + return true; + } + + self.settings_geometry.column_settings_target = Some(ocs); + if self.settings_geometry.column_settings_commit_pending { + return false; + } + + self.settings_geometry.column_settings_commit_pending = true; + let delta_w = if !needs_presize { + None + } else if was_mounted { + // Unmounting - the docked drawer is still in the DOM, and its + // freed span is the same measurement as an unpin. + let delta = measure_column_settings_pin_delta(&ctx.props().elem, true); + if let Some(delta) = delta { + self.settings_geometry.column_settings_docked_width = Some(delta.abs()); + } + + delta + } else { + // Mounting directly into pinned mode - not yet in the DOM; the + // span cached at the last dock/unmount predicts the shrink. + self.settings_geometry.column_settings_docked_width + }; + + self.presize_column_settings_shift(ctx, delta_w, UpdateColumnSettingsCommit); + false + } + + pub(super) fn on_update_column_settings_commit(&mut self, resolve: Sender<()>) -> bool { + self.settings_geometry.column_settings_commit_pending = false; + self.on_rendered.push(resolve); + if let Some(ocs) = self.settings_geometry.column_settings_target.take() { + self.presentation_props.open_column_settings = ocs; + } + true } diff --git a/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs b/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs index 126641830d..380328ef9f 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/snapshots.rs @@ -15,17 +15,45 @@ //! subscriptions/callbacks) into the root's props fields, re-rendering only //! on an actual change. +use futures::channel::oneshot::channel; +use perspective_js::utils::ApiFuture; use yew::prelude::*; use super::PerspectiveViewer; -use crate::presentation::{DragDropProps, OpenColumnSettings, PresentationProps}; +use crate::presentation::{DragDropProps, PresentationProps}; +use crate::tasks::resize_visible_panels; use crate::renderer::RendererProps; use crate::session::{SessionProps, TableLoadState, ViewStats}; impl PerspectiveViewer { - pub(super) fn on_update_session(&mut self, props: SessionProps) -> bool { + /// A session snapshot can flip the column-settings drawer's mount + /// predicate without any `UpdateColumnSettings` traffic — e.g. a drag + /// replacing the open column in `columns` invalidates its locator, and + /// this snapshot's render unmounts the DOCKED drawer, moving + /// `#main_panel_container`. Unlike the presentation-driven transitions + /// (deferred + presized in `settings.rs`), the snapshot is applied + /// immediately — it feeds the column selector, stats, and the config + /// redraw already racing this render, so a staged frame would be + /// stale on arrival — and the panels are owed the reactive geometry + /// finalizer on this render's commit instead (I6). + pub(super) fn on_update_session(&mut self, ctx: &Context, props: SessionProps) -> bool { let changed = props != self.session_props; + let was_mounted = + self.is_column_settings_mounted(&self.presentation_props.open_column_settings); self.session_props = props; + let now_mounted = + self.is_column_settings_mounted(&self.presentation_props.open_column_settings); + if self.settings_geometry.column_settings_pinned && was_mounted != now_mounted { + let (notify, rendered) = channel::<()>(); + self.on_rendered.push(notify); + let workspace = ctx.props().workspace.clone(); + ApiFuture::spawn(async move { + rendered.await?; + resize_visible_panels(&workspace).await; + Ok(()) + }); + } + changed } @@ -105,12 +133,6 @@ impl PerspectiveViewer { changed } - pub(super) fn on_update_column_settings(&mut self, ocs: OpenColumnSettings) -> bool { - let changed = ocs != self.presentation_props.open_column_settings; - self.presentation_props.open_column_settings = ocs; - changed - } - pub(super) fn on_update_dragdrop(&mut self, props: DragDropProps) -> bool { let changed = props != self.dragdrop_props; self.dragdrop_props = props; diff --git a/rust/perspective-viewer/src/rust/tasks/presize_panels.rs b/rust/perspective-viewer/src/rust/tasks/presize_panels.rs index dcffc25584..239d23e09f 100644 --- a/rust/perspective-viewer/src/rust/tasks/presize_panels.rs +++ b/rust/perspective-viewer/src/rust/tasks/presize_panels.rs @@ -131,11 +131,14 @@ pub async fn presize_visible_panels_grown( presize_visible_panels_scaled(workspace, elem, width_ratio, height_ratio).await } -/// Pre-size each visible plugin to its SHRUNK post-open cell *before* the -/// settings pane mounts (P2), using the open-state geometry deltas cached at -/// the last close (`(layout_area.w − mpc.w, main_column.h − mpc.h)` — the pane -/// and divider width and the docked status-bar height, both stable across a -/// close/open cycle since the pane width persists in the override). +/// Pre-size each visible plugin to its cell SHRUNK by `(delta_w, delta_h)` — +/// GROWN when a delta is negative — *before* the layout change commits. Used +/// by the settings-pane open (P2, deltas `(layout_area.w − mpc.w, +/// main_column.h − mpc.h)` cached at the last close — the pane and divider +/// width and the docked status-bar height, both stable across a close/open +/// cycle since the pane width persists in the override) and by the +/// column-settings pin toggle and docked-drawer mount/unmount (signed delta +/// measured live at toggle time). pub async fn presize_visible_panels_open( workspace: &Workspace, elem: &web_sys::HtmlElement, @@ -199,6 +202,7 @@ async fn presize_visible_panels_scaled( return StagedPresents::default(); } + let mpc = shadow_rect(elem, "#main_panel_container"); let mut last_chrome: Option<(f64, f64)> = None; let targets = workspace .panels() @@ -222,6 +226,23 @@ async fn presize_visible_panels_scaled( last_chrome = chrome.or(last_chrome); let (cw, ch) = last_chrome.unwrap_or(super::CHROME_FALLBACK); + + // Per axis, chrome can never exceed the container's + // non-plugin remainder: with no sibling panel on that axis, + // `mpc − box` IS the exact chrome, while the frame-margin + // measurement overcounts (margins that overlap container + // padding) — the source of a ~2px target miss that pushed + // every commit past the plugin transport's ±0.5px resize + // dedupe, costing an extra corrective render. With siblings + // the remainder is large and the measured chrome wins. + let (cw, ch) = match &mpc { + Some(mpc) => ( + cw.min((mpc.width() - plugin_box.width()).max(0.0)), + ch.min((mpc.height() - plugin_box.height()).max(0.0)), + ), + None => (cw, ch), + }; + let w = ((plugin_box.width() + cw) * width_ratio - cw).max(0.0); let h = ((plugin_box.height() + ch) * height_ratio - ch).max(0.0); Some((panel, w, h)) @@ -300,6 +321,24 @@ pub fn measure_settings_open_deltas(elem: &web_sys::HtmlElement) -> Option<(f64, )) } +/// The signed `#main_panel_container` width delta a column-settings pin +/// toggle will cause: the docked `#modal_panel`'s flex box spans its drawer +/// child plus the overlapping divider (`drawer.right − divider.left`, the +/// same span in both modes since `.pinned` reparents nothing) — PINNING +/// takes it from the main column (positive, shrink), UNPINNING releases it +/// back (negative, grow). `None` when the drawer isn't mounted. +pub fn measure_column_settings_pin_delta( + elem: &web_sys::HtmlElement, + is_pinned: bool, +) -> Option { + let drawer = shadow_rect(elem, "#modal_panel > .split-panel-child:first-child")?; + let divider_left = shadow_rect(elem, "#modal_panel > .split-panel-divider") + .map(|r| r.left()) + .unwrap_or_else(|| drawer.left()); + let width = (drawer.right() - divider_left).max(0.0); + Some(if is_pinned { -width } else { width }) +} + /// The frame chrome a plugin's grid cell has that the plugin itself doesn't /// occupy, as `(width, height)` px: margin + border + titlebar. Measured live /// from a `regular-layout-frame` and its plugin — robust to theme/CSS changes — diff --git a/rust/perspective-viewer/test/js/column_settings/drawer.spec.ts b/rust/perspective-viewer/test/js/column_settings/drawer.spec.ts new file mode 100644 index 0000000000..82c56efc63 --- /dev/null +++ b/rust/perspective-viewer/test/js/column_settings/drawer.spec.ts @@ -0,0 +1,197 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "../helpers.ts"; +import type { Page } from "@playwright/test"; + +// Column-settings drawer window geometry: the pin/unpin CSS-positioning +// flip (floating overlay <-> docked flex sibling) and the plugin-resize +// contract every docked-drawer mount transition owes — pin toggle, +// drawer open/close, and config-driven locator invalidation. Tab-agnostic: +// nothing here depends on which drawer tab is selected. +test.describe("Column settings drawer", () => { + test.beforeEach(async ({ page }) => { + await page.goto( + "/rust/perspective-viewer/test/html/superstore-debug.html", + ); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + }); + + async function restoreDebugStyled(page: Page) { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Debug Styled", + settings: true, + columns: ["Row ID", "Sales"], + }); + }); + } + + // Count geometry notifications on the active plugin - `presize()` + // from the staged sweep and/or `resize()` from the reactive + // finalizer. The plugin's `resize`/`presize` resolve through the + // prototype chain to non-assignable properties, so a plain + // `plugin.resize = fn` is silently rejected - shadow with an own + // `defineProperty` data property instead. + async function wrapGeometryCounter(page: Page) { + await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer"); + const plugin = viewer.getPlugin(); + window["__geometry_calls__"] = 0; + for (const method of ["resize", "presize"]) { + const orig = plugin[method]; + if (typeof orig === "function") { + Object.defineProperty(plugin, method, { + configurable: true, + value: (...args) => { + window["__geometry_calls__"]++; + return orig.apply(plugin, args); + }, + }); + } + } + }); + } + + const pollGeometryCalls = (page: Page) => + expect + .poll(() => page.evaluate(() => window["__geometry_calls__"])) + .toBeGreaterThan(0); + + const resetGeometryCalls = (page: Page) => + page.evaluate(() => { + window["__geometry_calls__"] = 0; + }); + + test("pin button docks the drawer into the layout and back", async ({ + page, + }) => { + await restoreDebugStyled(page); + await page.click("#add-expression"); + + // Stage a draft first - the pin toggle must NOT remount the + // drawer (which would wipe it). + await page.fill("input.sidebar_header_title", "my draft"); + + const modal_style = async () => + await page.evaluate(() => { + const root = + document.querySelector("perspective-viewer").shadowRoot; + const modal = root.querySelector("#modal_panel"); + return { + position: getComputedStyle(modal).position, + pinned: modal.classList.contains("pinned"), + width: modal.getBoundingClientRect().width, + }; + }); + + const floating = await modal_style(); + expect(floating.position).toBe("absolute"); + expect(floating.pinned).toBe(false); + + // Pin: the drawer becomes a static flex sibling and stops spanning + // the whole main area. + await page.click("#column_settings_pin_button"); + await expect(page.locator("#modal_panel")).toHaveClass(/pinned/); + const pinned = await modal_style(); + expect(pinned.position).toBe("static"); + expect(pinned.pinned).toBe(true); + expect(pinned.width).toBeLessThan(floating.width); + + // The staged draft survived the toggle - no remount. + await expect(page.locator("input.sidebar_header_title")).toHaveValue( + "my draft", + ); + + // Unpin restores the overlay. + await page.click("#column_settings_pin_button"); + await expect(page.locator("#modal_panel")).not.toHaveClass(/pinned/); + const restored = await modal_style(); + expect(restored.position).toBe("absolute"); + expect(restored.pinned).toBe(false); + }); + + test("pin toggle notifies the plugin of its new dimensions", async ({ + page, + }) => { + await restoreDebugStyled(page); + await page.click("#add-expression"); + await wrapGeometryCounter(page); + await page.click("#column_settings_pin_button"); + await expect(page.locator("#modal_panel")).toHaveClass(/pinned/); + await pollGeometryCalls(page); + await resetGeometryCalls(page); + await page.click("#column_settings_pin_button"); + await expect(page.locator("#modal_panel")).not.toHaveClass(/pinned/); + await pollGeometryCalls(page); + }); + + test("closing and reopening a pinned drawer notifies the plugin", async ({ + page, + }) => { + await restoreDebugStyled(page); + await page.click("#add-expression"); + await page.click("#column_settings_pin_button"); + await expect(page.locator("#modal_panel")).toHaveClass(/pinned/); + await wrapGeometryCounter(page); + + // Closing a DOCKED drawer frees its flex span - the plugin must be + // notified of the grown main panel. + await page.click("#column_settings_close_button"); + await expect(page.locator("#modal_panel")).toBeHidden(); + await pollGeometryCalls(page); + await resetGeometryCalls(page); + + // Reopening mounts the drawer directly into pinned mode - the + // plugin must be notified of the shrunk main panel. + await page.click("#add-expression"); + await expect(page.locator("#modal_panel")).toHaveClass(/pinned/); + await pollGeometryCalls(page); + }); + + test("removing the open column closes a pinned drawer and notifies the plugin", async ({ + page, + }) => { + // "Debug Styled" declares `can_render_column_styles`, so table + // columns get an ENABLED edit button (a Style tab). + await restoreDebugStyled(page); + + // force: the hover-reveal border overlay intercepts pointer events + // (same workaround as the `PageView` model's `editBtn`). + await page.click( + '#active-columns .column-selector-column:has-text("Sales") .expression-edit-button:not(.disabled)', + { force: true }, + ); + await page.click("#column_settings_pin_button"); + await expect(page.locator("#modal_panel")).toHaveClass(/pinned/); + await wrapGeometryCounter(page); + + // Removing the open column from `columns` (the active row's + // leading deactivate toggle) changes the view config, invalidating + // the drawer's locator - the docked drawer unmounts via the + // session snapshot (never through `UpdateColumnSettings`) and the + // plugin must still receive a geometry pass for the grown main + // panel. + await page.click( + '#active-columns .column-selector-column:has-text("Sales") span.shift-alt-icon', + { force: true }, + ); + + await expect(page.locator("#modal_panel")).toBeHidden(); + await pollGeometryCalls(page); + }); +}); diff --git a/rust/perspective-viewer/test/js/windows.spec.ts b/rust/perspective-viewer/test/js/windows.spec.ts index d670668570..b91b3fc73e 100644 --- a/rust/perspective-viewer/test/js/windows.spec.ts +++ b/rust/perspective-viewer/test/js/windows.spec.ts @@ -849,64 +849,6 @@ test.describe("Window columns", () => { ); }); - test("pin button docks the drawer into the layout and back", async ({ - page, - }) => { - await page.evaluate(async () => { - const viewer = document.querySelector("perspective-viewer"); - await viewer.restore({ - plugin: "Debug", - settings: true, - columns: ["Row ID", "Sales"], - }); - }); - - await page.click("#add-expression"); - await page.click("#Window"); - - // Stage a draft first - the pin toggle must NOT remount the editor - // (which would wipe it). - await page.dragAndDrop( - '.column-selector-draggable:has-text("Sales")', - "#window-source", - ); - - const modal_style = async () => - await page.evaluate(() => { - const root = - document.querySelector("perspective-viewer").shadowRoot; - const modal = root.querySelector("#modal_panel"); - return { - position: getComputedStyle(modal).position, - pinned: modal.classList.contains("pinned"), - width: modal.getBoundingClientRect().width, - }; - }); - - const floating = await modal_style(); - expect(floating.position).toBe("absolute"); - expect(floating.pinned).toBe(false); - - // Pin: the drawer becomes a static flex sibling and stops spanning - // the whole main area. - await page.click("#column_settings_pin_button"); - const pinned = await modal_style(); - expect(pinned.position).toBe("static"); - expect(pinned.pinned).toBe(true); - expect(pinned.width).toBeLessThan(floating.width); - - // The staged draft survived the toggle - no remount. - await expect( - page.locator("#window-source .column-selector-draggable"), - ).toContainText("Sales"); - - // Unpin restores the overlay. - await page.click("#column_settings_pin_button"); - const restored = await modal_style(); - expect(restored.position).toBe("absolute"); - expect(restored.pinned).toBe(false); - }); - test("frame type dropdown selects a Rows frame", async ({ page }) => { await page.evaluate(async () => { const viewer = document.querySelector("perspective-viewer"); From 0ff51a638c80ec7b039745d9fca928abd0f748df Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Mon, 24 Aug 2026 20:46:38 -0400 Subject: [PATCH 11/14] Fix datagrid bar and label-bar render glitch Signed-off-by: Andrew Stein --- .../viewer-datagrid/src/css/regular_table.css | 77 +++++---------- .../src/ts/data_listener/format_cell.ts | 36 +------ .../src/ts/data_listener/index.ts | 1 - .../viewer-datagrid/src/ts/model/create.ts | 30 ------ .../src/ts/style_handlers/body.ts | 5 +- .../ts/style_handlers/table_cell/numeric.ts | 67 +++++++++++-- packages/viewer-datagrid/src/ts/types.ts | 7 -- .../test/js/presize_stability.spec.ts | 98 +++++++++++++++++++ 8 files changed, 192 insertions(+), 129 deletions(-) create mode 100644 packages/viewer-datagrid/test/js/presize_stability.spec.ts diff --git a/packages/viewer-datagrid/src/css/regular_table.css b/packages/viewer-datagrid/src/css/regular_table.css index ad279f50a8..f554bc0e07 100644 --- a/packages/viewer-datagrid/src/css/regular_table.css +++ b/packages/viewer-datagrid/src/css/regular_table.css @@ -343,67 +343,44 @@ regular-table:not(.flat-group-rollup-mode) { text-align: right; } -.psp-color-mode-bar { +.psp-color-mode-bar, +.psp-color-mode-label-bar { padding: 0 2px; + background-image: var(--psp-label-bar-color); + background-repeat: no-repeat; + background-origin: content-box; + background-size: var(--psp-bar-size, 0%) 80%; + background-position-x: var(--psp-bar-anchor, 0%); + background-position-y: center; } .psp-color-mode-label-bar { position: relative; - padding: 0 2px; - - .psp-bar { - isolation: isolate; - position: unset; - } - - .psp-bar:before { - color: transparent; - pointer-events: none; - content: var(--label); - display: inline-flex; - justify-content: flex-end; - align-items: center; - padding: 0 3px; - } - - .psp-bar:after { - color: var(--psp-label-bar-bg); - content: var(--label); - mix-blend-mode: difference; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: inline-flex; - justify-content: flex-end; - align-items: center; - padding: 0 5px; - } + isolation: isolate; } -.psp-label-bar { - inset: 0; - pointer-events: none; - display: flex; - align-items: center; - justify-content: flex-end; - padding: 0px; -} - -.psp-label-bar-fill { - position: absolute; - top: 10%; - height: 80%; - background: var(--psp-label-bar-color); - pointer-events: none; +.psp-color-mode-label-bar:before { + color: transparent; + content: attr(data-psp-label); + display: block; + height: 0; + white-space: nowrap; + padding: 0 3px; } -.psp-label-bar-text { - position: relative; +.psp-color-mode-label-bar:after { color: var(--psp-label-bar-bg); + content: attr(data-psp-label); mix-blend-mode: difference; - pointer-events: none; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: inline-flex; + justify-content: flex-end; + align-items: center; + padding: 0 5px; } .psp-align-left { diff --git a/packages/viewer-datagrid/src/ts/data_listener/format_cell.ts b/packages/viewer-datagrid/src/ts/data_listener/format_cell.ts index 54bc0e4490..0ea0a3941b 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/format_cell.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/format_cell.ts @@ -15,7 +15,6 @@ import type { DatagridModel, ColumnsConfig, ColumnConfig } from "../types.js"; import type { ColumnType } from "@perspective-dev/client"; const FORMAT_CACHE = new FormatterCache(); -const MAX_BAR_WIDTH_PCT = 1; export function format_raw( type: ColumnType, @@ -47,42 +46,11 @@ export function format_cell( if ( is_numeric && + !use_table_schema && (plugin?.number_fg_mode === "bar" || plugin?.number_fg_mode === "label-bar") ) { - const a = Math.max( - 0, - Math.min( - MAX_BAR_WIDTH_PCT, - Math.abs((val as number) / plugin.fg_gradient!) * - MAX_BAR_WIDTH_PCT, - ), - ); - - const anchor = (val as number) >= 0 ? "" : "justify-self:flex-end;"; - const pct = (a * 100).toFixed(2); - - if (plugin.number_fg_mode === "bar") { - const div = this._div_factory.get(); - div.className = "psp-bar"; - div.setAttribute( - "style", - `${anchor}width:${pct}%;height:80%;top:10%;pointer-events:none;background:var(--psp-label-bar-color)`, - ); - - return div; - } else { - const formatter = FORMAT_CACHE.get(type, plugin); - const label = formatter ? formatter.format(val) : (val as string); - - const div = this._div_factory.get(); - div.className = "psp-bar"; - div.setAttribute( - "style", - `--label:"${label}";${anchor}width:${pct}%;height:80%;top:10%;pointer-events:none;background:var(--psp-label-bar-color)`, - ); - return div; - } + return ""; } else if (plugin?.format === "link" && type === "string") { const anchor = document.createElement("a"); anchor.setAttribute("href", val as string); diff --git a/packages/viewer-datagrid/src/ts/data_listener/index.ts b/packages/viewer-datagrid/src/ts/data_listener/index.ts index d09562ab1e..fcd30d90b6 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/index.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/index.ts @@ -154,7 +154,6 @@ export function createDataListener( return acc; }, new Map()); } else { - this._div_factory.clear(); num_columns = await this._view.num_columns(); } diff --git a/packages/viewer-datagrid/src/ts/model/create.ts b/packages/viewer-datagrid/src/ts/model/create.ts index 306be4d513..68eca769e6 100644 --- a/packages/viewer-datagrid/src/ts/model/create.ts +++ b/packages/viewer-datagrid/src/ts/model/create.ts @@ -23,7 +23,6 @@ import { type DatagridPluginElement, type RegularTable, type Schema, - type ElemFactory, type EditMode, } from "../types.js"; import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; @@ -157,32 +156,6 @@ export function readThemeStyle(regular: HTMLElement): ThemeStyle { }; } -class ElemFactoryImpl implements ElemFactory { - private _name: string; - private _elements: HTMLElement[]; - private _index: number; - - constructor(name: string) { - this._name = name; - this._elements = []; - this._index = 0; - } - - clear(): void { - this._index = 0; - } - - get(): HTMLElement { - if (!this._elements[this._index]) { - this._elements[this._index] = document.createElement(this._name); - } - - const elem = this._elements[this._index]; - this._index += 1; - return elem; - } -} - export async function createModel( this: DatagridPluginElement, regular: RegularTable, @@ -310,9 +283,6 @@ export async function createModel( }), _series_color_map: new Map(), _series_color_seed: new Map(), - - // get_psp_type, - _div_factory: extend._div_factory || new ElemFactoryImpl("div"), }) as DatagridModel; regular.setDataListener( diff --git a/packages/viewer-datagrid/src/ts/style_handlers/body.ts b/packages/viewer-datagrid/src/ts/style_handlers/body.ts index 07c27cff83..7cb768002f 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/body.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/body.ts @@ -131,7 +131,9 @@ export function applyBodyCellStyles( const value_styled = (is_numeric && (plugin?.number_bg_mode === "gradient" || - plugin?.number_bg_mode === "pulse")) || + plugin?.number_bg_mode === "pulse" || + plugin?.number_fg_mode === "bar" || + plugin?.number_fg_mode === "label-bar")) || (type === "string" && (plugin?.string_color_mode === "series" || plugin?.format === "link")); @@ -279,6 +281,7 @@ export function applyBodyCellStyles( cell_style_numeric( model, c.plugin as any, + c.type, td, metadata as any, isSettingsOpen, diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts index f8824c3cdc..0156086412 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts @@ -11,11 +11,15 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { style_cell_flash } from "./cell_flash.js"; +import { format_raw } from "../../data_listener/format_cell.js"; import { rgbaToRgb, infer_foreground_from_background, } from "../../color_utils.js"; import type { DatagridModel, ColumnConfig, ColorRecord } from "../../types.js"; +import type { ColumnType } from "@perspective-dev/client"; + +const MAX_BAR_WIDTH_PCT = 1; interface CellMetaWithExtras { _is_hidden_by_aggregate_depth?: boolean; @@ -35,9 +39,59 @@ interface PluginWithColors neg_fg_color?: ColorRecord; } +/** + * Write the value-derived bar presentation onto the `` itself: bar + * length/anchor as the `--psp-bar-size`/`--psp-bar-anchor` custom properties + * (consumed by the `psp-color-mode-*` `background-image` rules) and the + * formatted label as the `data-psp-label` attribute (consumed via + * `content: attr(...)` by the `psp-color-mode-label-bar` pseudo-elements). + * This runs only at commit time against the mounted table - the + * `DataListener` returns `""` for these cells and holds no DOM references, + * so a staged `predraw()` can never repaint mounted cells (the panel-resize + * row-shift corruption). + */ +function style_cell_bar( + td: HTMLElement, + plugin: PluginWithColors, + type: ColumnType | undefined, + user: number | null | undefined, +): void { + if (user === null || user === undefined) { + td.style.removeProperty("--psp-bar-size"); + td.removeAttribute("data-psp-label"); + return; + } + + const a = Math.max( + 0, + Math.min( + MAX_BAR_WIDTH_PCT, + Math.abs(user / plugin.fg_gradient!) * MAX_BAR_WIDTH_PCT, + ), + ); + + const pct = Number.isFinite(a) ? (a * 100).toFixed(2) : "100"; + td.style.setProperty("--psp-bar-size", `${pct}%`); + td.style.setProperty("--psp-bar-anchor", user < 0 ? "100%" : "0%"); + if (plugin.number_fg_mode === "label-bar") { + const formatter = format_raw( + type ?? "float", + plugin as unknown as ColumnConfig, + ); + + td.setAttribute( + "data-psp-label", + formatter ? formatter.format(user) : String(user), + ); + } else { + td.removeAttribute("data-psp-label"); + } +} + export function cell_style_numeric( model: DatagridModel, plugin: PluginWithColors | undefined, + type: ColumnType | undefined, td: HTMLElement, metadata: CellMetaWithExtras, is_settings_open: boolean, @@ -154,6 +208,8 @@ export function cell_style_numeric( if (metadata._is_hidden_by_aggregate_depth) { td.style.backgroundColor = ""; td.style.color = ""; + td.style.removeProperty("--psp-bar-size"); + td.removeAttribute("data-psp-label"); } else if (plugin?.number_fg_mode === "disabled") { if (plugin?.number_bg_mode === "color") { const source = model._plugin_background as [number, number, number]; @@ -166,15 +222,14 @@ export function cell_style_numeric( } else { td.style.color = ""; } - } else if (plugin?.number_fg_mode === "bar") { - td.style.color = ""; - td.style.position = "relative"; - td.style.setProperty("--psp-label-bar-color", gradhex); - td.style.setProperty("--psp-label-bar-bg", hex); - } else if (plugin?.number_fg_mode === "label-bar") { + } else if ( + plugin?.number_fg_mode === "bar" || + plugin?.number_fg_mode === "label-bar" + ) { td.style.color = ""; td.style.setProperty("--psp-label-bar-color", gradhex); td.style.setProperty("--psp-label-bar-bg", hex); + style_cell_bar(td, plugin, type, metadata.user); } else if (plugin?.number_fg_mode === "color" || !plugin?.number_fg_mode) { td.style.color = hex; } diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts index 1276164027..a48a2f3c69 100644 --- a/packages/viewer-datagrid/src/ts/types.ts +++ b/packages/viewer-datagrid/src/ts/types.ts @@ -213,12 +213,6 @@ export interface DatagridPluginConfig { column_size_override?: Record; } -// Element factory for reusing DOM elements -export interface ElemFactory { - clear(): void; - get(): HTMLElement; -} - export type Schema = Record; // Model object stored on regular-table @@ -259,7 +253,6 @@ export interface DatagridModel { _row_header_types: ColumnType[]; _series_color_map: Map>; _series_color_seed: Map; - _div_factory: ElemFactory; _last_window?: ViewWindow; _is_old_viewport?: boolean; _reverse_columns?: Map; diff --git a/packages/viewer-datagrid/test/js/presize_stability.spec.ts b/packages/viewer-datagrid/test/js/presize_stability.spec.ts new file mode 100644 index 0000000000..9e71572f9f --- /dev/null +++ b/packages/viewer-datagrid/test/js/presize_stability.spec.ts @@ -0,0 +1,98 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +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)); + } + }); +} + +test.describe("Datagrid presize label-bar stability", () => { + test("a staged presize does not repaint mounted label-bar cells before commit", async ({ + page, + }) => { + await goto_ready(page); + const result = await page.evaluate(async () => { + const snapshot_all = (rt: HTMLElement) => + Array.from(rt.querySelectorAll("tbody tr")).map((tr) => + Array.from(tr.children) + .filter((c) => c.tagName === "TD") + .map( + (td) => + td.getAttribute("data-psp-label") ?? + td + .querySelector("div.psp-bar") + ?.getAttribute("style") + ?.match(/--label:\s*"([^"]*)"/)?.[1] ?? + "", + ) + .join("|"), + ); + + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ + columns: ["Sales", "Profit"], + sort: [["Row ID", "asc"]], + columns_config: { + Sales: { number_fg_mode: "label-bar", fg_gradient: 1000 }, + Profit: { number_fg_mode: "label-bar", fg_gradient: 100 }, + }, + }); + + await viewer.flush(); + await new Promise((x) => setTimeout(x, 500)); + + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + + const rt = datagrid.regular_table; + const before = snapshot_all(rt); + const rect = datagrid.getBoundingClientRect(); + const commit = await datagrid.presize( + rect.width, + rect.height + 200, + ); + + const staged = snapshot_all(rt); + if (typeof commit === "function") { + commit(); + } + + const after = snapshot_all(rt); + return { + before, + staged, + after, + committed: typeof commit === "function", + }; + }); + + expect(result.committed).toEqual(true); + expect(result.before.length).toBeGreaterThan(2); + expect(result.before[0]).toMatch(/\d/); + expect(result.staged).toEqual(result.before); + expect(result.after.slice(0, result.before.length)).toEqual( + result.before, + ); + + expect(result.after.length).toBeGreaterThan(result.before.length); + for (const row of result.after) { + expect(row).toMatch(/\d/); + } + }); +}); From 16ebfef19bdccb1087f700b6b0a5e157ba71dff8 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Mon, 24 Aug 2026 23:23:34 -0400 Subject: [PATCH 12/14] Fix column_paths viewport bug Signed-off-by: Andrew Stein --- .../src/rust/virtual_server/server.rs | 9 +- .../js/to_format/to_format_viewport.spec.js | 90 +++++++++++++++++++ .../cpp/perspective/src/cpp/view.cpp | 43 ++------- 3 files changed, 104 insertions(+), 38 deletions(-) diff --git a/rust/perspective-client/src/rust/virtual_server/server.rs b/rust/perspective-client/src/rust/virtual_server/server.rs index 166b8b7322..0900cda36f 100644 --- a/rust/perspective-client/src/rust/virtual_server/server.rs +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -304,7 +304,7 @@ impl VirtualServer { let resp = ViewExpressionSchemaResp { schema }; respond!(msg, ViewExpressionSchemaResp { ..resp }) }, - ViewColumnPathsReq(_) => { + ViewColumnPathsReq(view_column_paths_req) => { let config = self.view_configs.get(&msg.entity_id).unwrap(); let mut paths: Vec = self .handler @@ -318,6 +318,13 @@ impl VirtualServer { sort_column_paths(&mut paths, config); } + let start = view_column_paths_req.start_col.unwrap_or(0) as usize; + let end = view_column_paths_req + .end_col + .map_or(paths.len(), |x| x as usize); + + let paths = paths.into_iter().take(end).skip(start).collect::>(); + respond!(msg, ViewColumnPathsResp { paths }) }, ViewToArrowReq(view_to_arrow_req) => { diff --git a/rust/perspective-js/test/js/to_format/to_format_viewport.spec.js b/rust/perspective-js/test/js/to_format/to_format_viewport.spec.js index db9eee2dcc..52ca8eeba3 100644 --- a/rust/perspective-js/test/js/to_format/to_format_viewport.spec.js +++ b/rust/perspective-js/test/js/to_format/to_format_viewport.spec.js @@ -289,4 +289,94 @@ test.describe("to_format viewport", function () { table.delete(); }); }); + + // `column_paths` takes the same half-open column window as `to_columns`, + // and the datagrid's copy/export path (GH #3216) relies on the two + // agreeing: every path returned for a window must be a key of + // `to_columns` called with that window. + test.describe("column_paths viewport", function () { + test("0 sided", async function () { + const table = await perspective.table(data); + const view = await table.view({}); + const paths = await view.column_paths({ + start_col: 1, + end_col: 3, + }); + expect(paths).toEqual(["x", "y"]); + view.delete(); + table.delete(); + }); + + test("1 sided", async function () { + const table = await perspective.table(data); + const view = await table.view({ group_by: ["y"] }); + expect( + await view.column_paths({ start_col: 0, end_col: 1 }), + ).toEqual(["w"]); + expect( + await view.column_paths({ start_col: 1, end_col: 3 }), + ).toEqual(["x", "y"]); + view.delete(); + table.delete(); + }); + + test("2 sided", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + expect( + await view.column_paths({ start_col: 0, end_col: 1 }), + ).toEqual(["false|w"]); + expect( + await view.column_paths({ start_col: 1, end_col: 2 }), + ).toEqual(["false|x"]); + expect( + await view.column_paths({ start_col: 3, end_col: 5 }), + ).toEqual(["false|z", "true|w"]); + view.delete(); + table.delete(); + }); + + test("column only", async function () { + const table = await perspective.table(data); + const view = await table.view({ split_by: ["z"] }); + expect( + await view.column_paths({ start_col: 0, end_col: 1 }), + ).toEqual(["false|w"]); + view.delete(); + table.delete(); + }); + + test("matches to_columns keys for a single-cell viewport", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + + for (const start_col of [0, 1, 2]) { + const viewport = { + start_col, + end_col: start_col + 1, + start_row: 0, + end_row: 1, + }; + + const cols = await view.to_columns(viewport); + const paths = await view.column_paths({ + start_col: viewport.start_col, + end_col: viewport.end_col, + }); + + expect(paths).toEqual( + Object.keys(cols).filter((x) => x !== "__ROW_PATH__"), + ); + } + + view.delete(); + table.delete(); + }); + }); }); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/view.cpp b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp index dadecc3208..916e05e404 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/view.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp @@ -283,64 +283,33 @@ View::column_names_range( aggregate_names[i] = aggs[i].name(); } - auto col_count = m_ctx->unity_get_column_count(); - // start_col++; - // end_col++; - - t_uindex key = 0; - while (key < start_col && key < col_count) { - key++; + t_uindex visible = 0; + for (t_uindex key = 0, max = m_ctx->unity_get_column_count(); + key != max && visible < end_col; + ++key) { const std::string& name = aggregate_names[key % aggregate_names.size()]; + if (name == "psp_okey") { - start_col += 1; - end_col += 1; continue; } std::vector col_path = m_ctx->unity_get_column_path(key + 1); if (skip && !m_split_rollup && col_path.size() < static_cast(depth)) { - start_col += 1; - end_col += 1; continue; } if (!m_hidden_sort.empty()) { if (std::find(m_hidden_sort.begin(), m_hidden_sort.end(), name) != m_hidden_sort.end()) { - start_col += 1; - end_col += 1; continue; } } - } - - for (t_uindex max = std::min(end_col, col_count); key <= max; ++key) { - const std::string& name = aggregate_names[key % aggregate_names.size()]; - - if (name == "psp_okey") { - end_col += 1; - max = std::min(end_col, col_count); - continue; - } - std::vector col_path = m_ctx->unity_get_column_path(key + 1); - if (skip && !m_split_rollup - && col_path.size() < static_cast(depth)) { - end_col += 1; - max = std::min(end_col, col_count); + if (visible++ < start_col) { continue; } - if (!m_hidden_sort.empty()) { - if (std::find(m_hidden_sort.begin(), m_hidden_sort.end(), name) - != m_hidden_sort.end()) { - end_col += 1; - max = std::min(end_col, col_count); - continue; - } - } - std::vector new_path; for (auto path = col_path.rbegin(); path != col_path.rend(); ++path) { new_path.push_back(*path); From e7f08b58031aba2d45dfad4a6c22cdf7684f063d Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 20 Aug 2026 12:24:47 -0400 Subject: [PATCH 13/14] Palette and multi-step gradient selector Signed-off-by: Andrew Stein --- docs/md/how_to/javascript/save_restore.md | 99 +- examples/blocks/src/editable/index.html | 2 +- .../charts/candlestick/candlestick-render.ts | 8 + .../ts/charts/cartesian/cartesian-render.ts | 5 + .../src/ts/charts/cartesian/cartesian.ts | 4 + .../viewer-charts/src/ts/charts/chart-base.ts | 52 +- packages/viewer-charts/src/ts/charts/chart.ts | 5 + .../src/ts/charts/common/tree-chart.ts | 4 + .../src/ts/charts/heatmap/heatmap-interact.ts | 7 +- .../src/ts/charts/heatmap/heatmap-render.ts | 5 + .../src/ts/charts/heatmap/heatmap.ts | 4 + .../src/ts/charts/series/series-render.ts | 5 + .../src/ts/charts/series/series-type.ts | 65 + .../src/ts/charts/series/series.ts | 26 +- .../src/ts/charts/sunburst/sunburst-render.ts | 9 + .../src/ts/charts/treemap/treemap-render.ts | 5 + .../viewer-charts/src/ts/plugin/plugin.ts | 105 +- .../viewer-charts/src/ts/theme/gradient.ts | 242 +++- .../viewer-charts/src/ts/theme/overrides.ts | 76 ++ .../src/ts/transport/renderer-transport.ts | 1 + packages/viewer-charts/src/ts/utils/css.ts | 12 + .../src/ts/worker/renderer.worker.ts | 44 +- .../test/ts/column-style-colors.spec.ts | 262 ++++ .../viewer-datagrid/src/ts/color_utils.ts | 255 ++++ .../src/ts/custom_elements/datagrid.ts | 6 - .../src/ts/event_handlers/edit_focus.ts | 8 +- .../viewer-datagrid/src/ts/model/create.ts | 50 +- .../src/ts/plugin/column_config_schema.ts | 60 +- .../viewer-datagrid/src/ts/plugin/restore.ts | 57 +- .../src/ts/style_handlers/body.ts | 5 +- .../ts/style_handlers/table_cell/numeric.ts | 40 +- .../ts/style_handlers/table_cell/string.ts | 19 +- packages/viewer-datagrid/src/ts/types.ts | 35 +- .../test/js/column_settings.spec.ts | 44 +- .../test/js/column_style.spec.ts | 202 ++-- .../viewer-datagrid/test/js/restyle.spec.ts | 123 +- .../generic_sql_model/table_make_view.rs | 12 +- .../test/js/pivot_nulls.spec.js | 8 +- .../src/css/column-settings-panel.css | 14 +- .../src/css/column-style.css | 240 +++- .../perspective-viewer/src/css/dom/select.css | 3 +- .../src/css/form/code-editor.css | 8 +- .../src/rust/agent/tools.rs | 65 +- .../src/rust/components/column_selector.rs | 52 +- .../column_selector/active_column.rs | 4 +- .../column_selector/add_expression_button.rs | 6 +- .../column_selector/config_selector.rs | 8 + .../column_selector/expr_edit_button.rs | 14 +- .../column_selector/inactive_column.rs | 4 +- .../components/column_settings_sidebar.rs | 157 +-- .../column_settings_sidebar/style_tab.rs | 487 ++++---- .../style_tab/primitive_field.rs | 419 +++++-- .../src/rust/components/editable_header.rs | 229 ++-- .../src/rust/components/expression_editor.rs | 25 +- .../src/rust/components/form/code_editor.rs | 22 +- .../components/form/color_range_selector.rs | 137 --- .../src/rust/components/form/debug.rs | 1 + .../src/rust/components/form/mod.rs | 4 +- .../form/multi_stop_gradient_selector.rs | 476 ++++++++ .../components/form/named_value_picker.rs | 86 ++ .../rust/components/form/palette_selector.rs | 440 +++++++ .../src/rust/components/plugin_tab.rs | 32 +- .../src/rust/components/settings_panel.rs | 44 +- .../src/rust/components/viewer.rs | 10 +- .../src/rust/components/viewer/msg.rs | 34 +- .../src/rust/components/viewer/panels.rs | 1 + .../src/rust/components/viewer/render.rs | 11 +- .../src/rust/components/viewer/settings.rs | 47 +- .../src/rust/components/viewer/snapshots.rs | 24 +- .../src/rust/components/viewer/wiring.rs | 28 +- .../src/rust/config/column_config_schema.rs | 141 ++- .../src/rust/config/css_scale.rs | 882 ++++++++++++++ .../perspective-viewer/src/rust/config/mod.rs | 2 + .../src/rust/config/options.rs | 10 + .../src/rust/config/workspace_config.rs | 133 ++- .../src/rust/custom_elements/viewer.rs | 79 +- .../src/rust/custom_events.rs | 11 +- rust/perspective-viewer/src/rust/lib.rs | 2 + .../src/rust/presentation.rs | 167 ++- .../src/rust/presentation/column_locator.rs | 24 +- .../src/rust/queries/column_locator.rs | 118 +- .../src/rust/queries/mod.rs | 2 + .../src/rust/queries/palette_set.rs | 82 ++ .../src/rust/queries/plugin_column_styles.rs | 8 +- .../src/rust/renderer/plugin_config.rs | 356 +++++- .../src/rust/tasks/edit_expression.rs | 42 +- .../src/rust/tasks/edit_window.rs | 18 +- .../src/rust/tasks/pipeline.rs | 4 +- .../src/rust/tasks/restore_and_render.rs | 5 + .../src/rust/tasks/restore_panel.rs | 2 - .../src/rust/tasks/set_edit_mode.rs | 4 +- .../src/rust/tasks/sync_update_panels.rs | 12 +- .../src/rust/tasks/update_theme.rs | 1 + .../src/rust/utils/css_vars.rs | 26 + rust/perspective-viewer/src/rust/utils/mod.rs | 2 + rust/perspective-viewer/src/svg/lock.svg | 7 + .../src/themes/botanical.css | 10 + .../src/themes/defaults.css | 12 + .../perspective-viewer/src/themes/dracula.css | 17 + .../src/themes/gruvbox-dark.css | 20 + .../perspective-viewer/src/themes/gruvbox.css | 10 + rust/perspective-viewer/src/themes/icons.css | 1 + .../perspective-viewer/src/themes/monokai.css | 22 + .../src/themes/phosphor.css | 5 + .../src/themes/pro-dark.css | 10 + .../src/themes/solarized-dark.css | 7 + .../src/themes/solarized.css | 7 + .../src/themes/vaporwave.css | 20 + .../src/ts/perspective-viewer.ts | 1 + rust/perspective-viewer/src/ts/plugin.ts | 14 + .../test/html/debug_plugins.js | 49 +- .../js/column_settings/attributes_tab.spec.ts | 65 + .../column_settings/palette_gradient.spec.ts | 1055 +++++++++++++++++ .../test/js/column_settings/sidebar.spec.ts | 9 +- .../test/js/column_settings/target.spec.ts | 78 ++ .../test/js/dragdrop/cancel.spec.ts | 60 + .../test/js/dragdrop/dragdrop_test_utils.ts | 46 +- .../test/js/multi_panel/workspace.spec.ts | 4 +- .../js/viewer_config/localization.spec.ts | 2 +- tools/scripts/install_binaryen.mjs | 4 +- 120 files changed, 7404 insertions(+), 1367 deletions(-) create mode 100644 packages/viewer-charts/src/ts/theme/overrides.ts create mode 100644 packages/viewer-charts/test/ts/column-style-colors.spec.ts delete mode 100644 rust/perspective-viewer/src/rust/components/form/color_range_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/multi_stop_gradient_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/named_value_picker.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/palette_selector.rs create mode 100644 rust/perspective-viewer/src/rust/config/css_scale.rs create mode 100644 rust/perspective-viewer/src/rust/queries/palette_set.rs create mode 100644 rust/perspective-viewer/src/rust/utils/css_vars.rs create mode 100644 rust/perspective-viewer/src/svg/lock.svg create mode 100644 rust/perspective-viewer/test/js/column_settings/palette_gradient.spec.ts create mode 100644 rust/perspective-viewer/test/js/column_settings/target.spec.ts diff --git a/docs/md/how_to/javascript/save_restore.md b/docs/md/how_to/javascript/save_restore.md index 9d44a41d4d..367835de8a 100644 --- a/docs/md/how_to/javascript/save_restore.md +++ b/docs/md/how_to/javascript/save_restore.md @@ -115,6 +115,99 @@ 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). +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). + +## Colors, palettes and gradients + +Per-column color styling lives in a panel's `columns_config`, keyed by column +name, and every color-scale value is a string usable verbatim in CSS: + +| Kind | Value | +| -------- | ----------------------------------------------------------------------------------- | +| color | `"#rrggbb"` (`#rgb`, `rgb()` and `rgba()` are accepted on input) | +| palette | `"linear-gradient(to right, #rrggbb, #rrggbb, …)"` — N colors, **no** positions | +| gradient | `"linear-gradient(to right, #rrggbb 0%, #rrggbb 37.5%, …)"` — every stop positioned | + +Which reader applies is decided by the style control's kind (the datagrid's +`fg_colors`/`bg_colors` and the charts' `gradient` are gradients; `palette` is a +palette), never by inspecting the string — a position anywhere in a palette is +rejected, while a gradient may omit positions on input (the CSS +implicit-position rules fill them) and may carry any direction token, which is +normalized to `to right`. Values equal to the plugin's default are not +serialized. + +```javascript +await viewer.restore({ + plugin: "Datagrid", + columns_config: { + Profit: { + number_bg_mode: "gradient", + bg_colors: "linear-gradient(to right, #ff0000, #ffffff, #0000ff)", + }, + }, +}); +``` + +Any of these may instead be a reference to a CSS custom property of the same +kind — `"var(--psp-user--color-)"`, `"var(--psp-user--palette-)"` or +`"var(--psp-user--gradient-)"`. References are resolved when the config is +written, against the element's computed style: the `palette` of the last +`restoreWorkspace()` (below) takes precedence, then any `--psp-user--*` property +a theme or the page defines on the element. An unresolvable reference is dropped +(the plugin's default renders). Panels hold literals from then on — `save()` +always emits literals, and the column style tab always edits a literal. + +`saveWorkspace()` emits a **palette**: every color value in use across the +panels is written in `panels` as a `var()` reference, and the top-level +`palette` map (custom property name → value) carries each referenced definition +once. Names are stable — a value keeps the name the last `restoreWorkspace()` +gave it when the values match, reuses a theme entry's name when it matches one +(`--psp-user---1`, `-2`, … are discovered by contiguous numbering), and +otherwise takes a fresh `--psp-user---N`. `restoreWorkspace()` applies +`palette` to the element as inline custom properties (replacing any previously +restored palette) before the panels' references resolve — which also makes it +the way to inject a brand or theme variation for a workspace to draw on. + +By default only the values the panels reference are serialized; a restored +palette's unused entries, and values pinned during a session, are in-session +state. Pass `{ full_palette: true }` to emit the element's whole set — in-use +values unioned with the last restored palette and anything pinned since — for a +symmetric round trip: + +```javascript +const used_only = await elem.saveWorkspace(); +const everything = await elem.saveWorkspace({ full_palette: true }); +``` + +In the column style tab, each color field's **Load** control lists the element's +set (plus theme entries) for every panel and applies a chosen entry's value to +the field; **Pin** — offered while the field holds a value the restored set +lacks — adds that value to the set for the rest of the session. + +```javascript +await elem.restoreWorkspace({ + palette: { + "--psp-user--gradient-heat": + "linear-gradient(to right, #0366d6, #ff7f0e)", + "--psp-user--palette-brand": + "linear-gradient(to right, #2771a8, #8b86ff, #ff471e)", + }, + panels: { + sales: { + table: "superstore", + plugin: "Heatmap", + columns: ["Sales"], + columns_config: { + Sales: { gradient: "var(--psp-user--gradient-heat)" }, + }, + }, + }, +}); +``` + +A malformed `palette` entry (a key outside +`--psp-user--{gradient,palette,color}-`, or a value its kind rejects) fails the +whole `restoreWorkspace()` before any panel changes. diff --git a/examples/blocks/src/editable/index.html b/examples/blocks/src/editable/index.html index f4839789b9..2d372a8f7d 100644 --- a/examples/blocks/src/editable/index.html +++ b/examples/blocks/src/editable/index.html @@ -2,7 +2,7 @@ - + diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts index 8ae7150224..5eaa2be9ff 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts @@ -230,7 +230,15 @@ export function renderCandlestickFrame( chart._defer2D(() => renderCandlestickChromeOverlay(chart)); } +/** + * Draw axes chrome + (optional) tooltip onto the overlay canvas. + */ export function renderCandlestickChromeOverlay(chart: CandlestickChart): void { + paintCandlestickChromeOverlay(chart); + chart.presentOverlay(); +} + +function paintCandlestickChromeOverlay(chart: CandlestickChart): void { if ( !chart._chromeCanvas || !chart._lastLayout || diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts index 9d46cc6a2b..daccc0b2b1 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts @@ -699,6 +699,11 @@ function renderFacetedFrame( * Redraw the chrome canvas only. Used for lightweight hover updates. */ export function renderCartesianChromeOverlay(chart: CartesianChart): void { + paintCartesianChromeOverlay(chart); + chart.presentOverlay(); +} + +function paintCartesianChromeOverlay(chart: CartesianChart): void { if ( !chart._chromeCanvas || !chart._lastLayout || diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts index 6db0452e32..6854d23189 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts @@ -61,6 +61,10 @@ export class CartesianChart extends AbstractChart { this.glyph = glyph; } + protected override colorScaleColumn(): string | null { + return this._colorName || null; + } + /** * Rendering pipeline selector. `"cartesian"` is the default — * draws axes, gridlines, and ticks via the chrome canvas. diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 7d8bb2bc4b..1003de6797 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -44,6 +44,7 @@ import { import type { PerspectiveClickDetail } from "../event-detail"; import type { ViewConfig } from "@perspective-dev/client"; import { resolveThemeFromVars, type Theme } from "../theme/theme"; +import { applyColumnColorOverrides } from "../theme/overrides"; import { requestRender as scheduleRender } from "../render/scheduler"; // TODO I don't know if this is the behavior we want. On the plus side, this @@ -141,6 +142,7 @@ export abstract class AbstractChart implements ChartImplementation { _glManager: WebGLContextManager | null = null; _gridlineCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; _chromeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; + _overlayPresenter: (() => void) | null = null; /** * 2D-canvas draw closures collected during `_fullRender` (the GL @@ -259,12 +261,16 @@ export abstract class AbstractChart implements ChartImplementation { /** * Cached resolved theme — populated on first `_resolveTheme()` call, - * cleared by `invalidateTheme()` (driven from `plugin.restyle()`). - * `getComputedStyle` / `getPropertyValue` reads cost ~100µs each; - * zoom/hover dispatch redraws at 60Hz so we resolve once and reuse. + * cleared by `invalidateTheme()` (driven from `plugin.restyle()`) + * and by `setColumnsConfig` (per-column color overrides patch the + * resolved theme). `getComputedStyle` / `getPropertyValue` reads + * cost ~100µs each; zoom/hover dispatch redraws at 60Hz so we + * resolve once and reuse. */ _theme: Theme | null = null; + _themeColorColumn: string | null = null; + /** * On-demand single-row fetcher used by lazy tooltip column * lookups. Reset on every `setView` call; subclasses read @@ -287,6 +293,18 @@ export abstract class AbstractChart implements ChartImplementation { this._chromeCanvas = canvas; } + setOverlayPresenter(cb: () => void): void { + this._overlayPresenter = cb; + } + + /** + * Present a chrome-overlay repaint that ran outside the scheduler's + * frame chain. + */ + presentOverlay(): void { + this._overlayPresenter?.(); + } + setTheme(vars: Record): void { this._themeVars = vars; this._theme = null; @@ -434,6 +452,8 @@ export abstract class AbstractChart implements ChartImplementation { setColumnsConfig(cfg: Record): void { this._columnsConfig = cfg ?? {}; this._rebuildColumnFormatters(); + + this._theme = null; } /** @@ -599,16 +619,28 @@ export abstract class AbstractChart implements ChartImplementation { cfg.series_zoom_mode === "dynamic"; } + protected colorScaleColumn(): string | null { + return null; + } + /** - * Lazily decode the host-supplied theme vars. Subsequent calls hit - * the cache until `invalidateTheme()` clears it. Render-path - * callers should always read theme values through this method so - * the parsed `Theme` (gradient stops, palette, etc.) amortizes - * across an entire frame. + * Lazily decode the host-supplied theme vars, then patch in any + * per-column color-scale override for `colorScaleColumn()`. + * Subsequent calls hit the cache until `invalidateTheme()` / + * `setColumnsConfig` clears it or the color column changes. + * Render-path callers should always read theme values through this + * method so the parsed `Theme` (gradient stops, palette, etc.) + * amortizes across an entire frame. */ _resolveTheme(): Theme { - if (!this._theme) { - this._theme = resolveThemeFromVars(this._themeVars); + const colorColumn = this.colorScaleColumn(); + if (!this._theme || this._themeColorColumn !== colorColumn) { + this._themeColorColumn = colorColumn; + this._theme = applyColumnColorOverrides( + resolveThemeFromVars(this._themeVars), + this._columnsConfig, + colorColumn, + ); } return this._theme; diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index b4febf4303..33c55777bd 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -84,6 +84,11 @@ export interface ChartImplementation { */ setChromeCanvas?(canvas: HTMLCanvasElement | OffscreenCanvas): void; + /** + * Install the renderer's overlay-present hook. + */ + setOverlayPresenter?(cb: () => void): void; + /** * Hand the chart a pre-computed CSS-variable map produced on the * main thread via `snapshotThemeVars(el)`, which it can decode into diff --git a/packages/viewer-charts/src/ts/charts/common/tree-chart.ts b/packages/viewer-charts/src/ts/charts/common/tree-chart.ts index 40467afdda..cddf7fd826 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-chart.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-chart.ts @@ -40,6 +40,10 @@ export function firstNonMetadataColumn(columns: ColumnDataMap): string { * layout modules can read/write them without friction. */ export abstract class TreeChartBase extends AbstractChart { + protected override colorScaleColumn(): string | null { + return this._columnSlots[1] || null; + } + // Shared column-slot resolution _sizeName = ""; _colorName = ""; diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts index a38d429d52..bb849328f8 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts @@ -254,9 +254,10 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { const xPath = chart._xAxisMode.mode === "numeric" && xPositions - ? chart.getColumnFormatter(chart._groupBy[0], "value")( - xPositions[cell.xIdx], - ) + ? chart.getColumnFormatter( + chart._groupBy[0], + "value", + )(xPositions[cell.xIdx]) : formatHierarchicalPath(xLevels, cell.xIdx); const yPath = formatHierarchicalPath(yLevels, cell.yIdx); if (xPath) { diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts index b9c06b818f..c9aaef31c0 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts @@ -392,6 +392,11 @@ function drawCellsInstanced( * Chrome overlay: X axis + Y axis + color legend + (optional) tooltip. */ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { + paintHeatmapChromeOverlay(chart); + chart.presentOverlay(); +} + +function paintHeatmapChromeOverlay(chart: HeatmapChart): void { if (!chart._chromeCanvas) { return; } diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts index 490f675451..ee06f9e800 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts @@ -69,6 +69,10 @@ export interface HeatmapFacet { * scale and a single legend. */ export class HeatmapChart extends AbstractChart { + protected override colorScaleColumn(): string | null { + return this._columnSlots[0] || null; + } + _program: WebGLProgram | null = null; _locations: HeatmapLocations | null = null; _cornerBuffer: WebGLBuffer | null = null; diff --git a/packages/viewer-charts/src/ts/charts/series/series-render.ts b/packages/viewer-charts/src/ts/charts/series/series-render.ts index 731183ac7a..466cbef9ad 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-render.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -1088,6 +1088,11 @@ export function layoutForRecord( * Draw axes chrome + legend + tooltip onto the overlay canvas. */ export function renderBarChromeOverlay(chart: SeriesChart): void { + paintBarChromeOverlay(chart); + chart.presentOverlay(); +} + +function paintBarChromeOverlay(chart: SeriesChart): void { if ( !chart._chromeCanvas || !chart._lastLayout || diff --git a/packages/viewer-charts/src/ts/charts/series/series-type.ts b/packages/viewer-charts/src/ts/charts/series/series-type.ts index ec189c99c9..67e13919ac 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-type.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-type.ts @@ -10,6 +10,8 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import { parseCssColorList, parseCssColorStrict } from "../../theme/gradient"; + export type ChartType = "bar" | "line" | "scatter" | "area"; /** @@ -45,6 +47,18 @@ export interface ColumnChartConfig { * `"skip"`). Default `"skip"`. No effect on bar / scatter. */ interpolate?: InterpolateMode; + + /** + * Single series-color override (`#rrggbb`) — the no-`split_by` + * shape. + */ + color?: string; + + /** + * Discrete color palette cycled over this column's split-generated + * series — the `split_by` shape. + */ + palette?: string; } /** @@ -98,6 +112,57 @@ export function resolveAltAxis( return cfg?.[aggName]?.alt_axis === true; } +/** + * Resolve a per-series color override from `columns_config`, or `null` + * when the theme palette applies. + */ +export function resolveSeriesColorOverride( + series: { aggName: string; splitIdx: number }, + facetActive: boolean, + cfg: Record | undefined, +): [number, number, number] | null { + const entry = cfg?.[series.aggName]; + if (!entry) { + return null; + } + + const palette = + typeof entry.palette === "string" + ? parsePaletteCached(entry.palette) + : null; + if (palette && palette.length > 0) { + const idx = facetActive ? 0 : series.splitIdx % palette.length; + return palette[idx]; + } + + if (typeof entry.color === "string") { + return parseCssColorStrict(entry.color) ?? null; + } + + return null; +} + +const PALETTE_PARSE_CACHE = new Map< + string, + Array<[number, number, number]> | null +>(); + +function parsePaletteCached( + src: string, +): Array<[number, number, number]> | null { + let parsed = PALETTE_PARSE_CACHE.get(src); + if (parsed === undefined) { + parsed = parseCssColorList(src); + if (PALETTE_PARSE_CACHE.size > 256) { + PALETTE_PARSE_CACHE.clear(); + } + + PALETTE_PARSE_CACHE.set(src, parsed); + } + + return parsed; +} + /** * Resolve the interpolation mode for this aggregate. */ diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts index 3ec96b5af8..38ecc02d0a 100644 --- a/packages/viewer-charts/src/ts/charts/series/series.ts +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -41,6 +41,7 @@ import { showBarPinnedTooltipForSample, } from "./series-interact"; import { resolvePalette } from "../../theme/palette"; +import { resolveSeriesColorOverride } from "./series-type"; import { LineGlyph } from "./glyphs/draw-lines"; import { ScatterGlyph } from "./glyphs/draw-scatter"; import { AreaGlyph } from "./glyphs/draw-areas"; @@ -250,13 +251,16 @@ export class SeriesChart extends CategoricalYChart { /** * Cached palette + identity-keys for short-circuiting per-frame * resolution. Inputs (`seriesPalette` ref, `gradientStops` ref, - * `series.length`) only change on data load or `restyle()`. + * `series.length`, `_columnsConfig` ref — per-series color + * overrides) only change on data load, `restyle()`, or + * `setColumnsConfig`. */ _paletteCache: [number, number, number][] | null = null; _paletteCacheKey: { seriesPalette: [number, number, number][] | null; gradientStops: unknown; seriesLength: number; + columnsConfig: unknown; } | null = null; /** @@ -517,9 +521,7 @@ export class SeriesChart extends CategoricalYChart { const groupByValues: (string | number | null)[] = this._categoryAxisMode === "numeric" && this._categoryPositions ? [this._categoryPositions[b.catIdx] ?? null] - : this._rowPaths.map( - (level) => level.labels[b.catIdx] ?? null, - ); + : this._rowPaths.map((level) => level.labels[b.catIdx] ?? null); const splitKey = this._splitPrefixes[b.splitIdx] ?? ""; const splitByValues = this._splitBy.length > 0 && splitKey !== "" @@ -906,6 +908,7 @@ export function ensurePalette(chart: SeriesChart): boolean { const theme = chart._resolveTheme(); const seriesPalette = theme.seriesPalette; const gradientStops = theme.gradientStops; + const columnsConfig = chart._columnsConfig; const paletteCount = chart._facetActive ? Math.max(1, chart._aggregates.length) : chart._series.length; @@ -916,7 +919,8 @@ export function ensurePalette(chart: SeriesChart): boolean { key && key.seriesPalette === seriesPalette && key.gradientStops === gradientStops && - key.seriesLength === paletteCount + key.seriesLength === paletteCount && + key.columnsConfig === columnsConfig ) { return false; } @@ -931,12 +935,18 @@ export function ensurePalette(chart: SeriesChart): boolean { seriesPalette, gradientStops, seriesLength: paletteCount, + columnsConfig, }; for (let i = 0; i < chart._series.length; i++) { - chart._series[i].color = chart._facetActive - ? palette[chart._series[i].aggIdx] - : palette[i]; + const series = chart._series[i]; + const themed = chart._facetActive ? palette[series.aggIdx] : palette[i]; + series.color = + resolveSeriesColorOverride( + series, + chart._facetActive, + columnsConfig, + ) ?? themed; } return true; diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts index 889b2877ad..c4737bcc45 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts @@ -501,7 +501,16 @@ function drawArcs( // Chrome overlay (Canvas2D) +/** + * Render the chrome overlay (labels, center text, hover highlight + + * tooltip). + */ export function renderSunburstChromeOverlay(chart: SunburstChart): void { + paintSunburstChromeOverlay(chart); + chart.presentOverlay(); +} + +function paintSunburstChromeOverlay(chart: SunburstChart): void { if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) { return; } diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts index 6fe526e378..1fa6882a54 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts @@ -467,6 +467,11 @@ function emitRect( * tooltip + highlight on top. */ export function renderTreemapChromeOverlay(chart: TreemapChart): void { + paintTreemapChromeOverlay(chart); + chart.presentOverlay(); +} + +function paintTreemapChromeOverlay(chart: TreemapChart): void { if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) { return; } diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index b448ab6863..2f07e3a2f3 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import { colorsToCss, stopsToCss } from "../theme/gradient"; import type { View } from "@perspective-dev/client"; import type { HTMLPerspectiveViewerElement, @@ -27,6 +28,10 @@ import { import { RawEventForwarder } from "../interaction/raw-event-forwarder"; import { RendererTransport } from "../transport/renderer-transport"; import { RENDER_BLIT_MODE } from "../config"; +import { snapshotThemeVars } from "../theme/theme-snapshot"; +import { resolveThemeFromVars, type Theme } from "../theme/theme"; +import { resolvePalette } from "../theme/palette"; +import { vec3ToHexColor } from "../utils/css"; /** * Facet-rendering defaults shared by every chart. Per-chart overrides @@ -449,23 +454,79 @@ export class HTMLPerspectiveViewerWebGLPluginElement priority: 0, can_render_column_styles: !!this._chartType.default_chart_type || - this._chartType.category === "Cartesian Charts", + this._chartType.category === "Cartesian Charts" || + this._chartType.category === "Hierarchical Charts" || + this._chartType.category === "Map Charts", }; } column_config_schema( column_type: string, - _group: string | undefined, - _column_name: string, + group: string | undefined, + column_name: string, current_value: Record | null, - _viewer_config?: { group_by?: string[]; group_rollup_mode?: string }, + viewer_config?: { + columns?: (string | null)[]; + group_by?: string[]; + split_by?: string[]; + group_rollup_mode?: string; + }, ) { const fields: Array & { kind: string }> = []; + if (group === "Color") { + const numeric_gradient = + this._chartType.category === "Hierarchical Charts" + ? column_type === "integer" || + column_type === "float" || + column_type === "date" || + column_type === "datetime" + : column_type !== "string"; + if (numeric_gradient) { + fields.push({ + kind: "GradientStops", + key: "gradient", + default: this._themeGradientStopsSpec(), + }); + } else { + fields.push({ + kind: "Palette", + key: "palette", + default: this._themeSeriesPaletteHex(), + }); + } + } + // Y-series plugins expose the per-column chart_type picker; non-Y // plugins leave `default_chart_type` unset. const def = this._chartType.default_chart_type; if (def && (column_type === "integer" || column_type === "float")) { + const is_series_glyph = + def === "bar" || + def === "line" || + def === "scatter" || + def === "area"; + + if (is_series_glyph) { + const has_split = (viewer_config?.split_by?.length ?? 0) > 0; + if (has_split) { + fields.push({ + kind: "Palette", + key: "palette", + default: this._themeSeriesPaletteHex(), + }); + } else { + const slot = (viewer_config?.columns ?? []) + .filter((c): c is string => !!c) + .indexOf(column_name); + fields.push({ + kind: "Color", + key: "color", + default: this._themeSeriesColorHex(Math.max(0, slot)), + }); + } + } + fields.push({ kind: "Enum", key: "chart_type", @@ -493,12 +554,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement }); } - const is_series_glyph = - def === "bar" || - def === "line" || - def === "scatter" || - def === "area"; - if (is_series_glyph) { fields.push({ kind: "Bool", @@ -557,6 +612,36 @@ export class HTMLPerspectiveViewerWebGLPluginElement return { fields }; } + private _resolvedTheme(): Theme { + return resolveThemeFromVars(snapshotThemeVars(this)); + } + + private static readonly GRADIENT_PALETTE_FALLBACK_COUNT = 6; + + private _themeSeriesPaletteHex(): string { + const theme = this._resolvedTheme(); + const count = + theme.seriesPalette.length || + HTMLPerspectiveViewerWebGLPluginElement.GRADIENT_PALETTE_FALLBACK_COUNT; + return colorsToCss( + resolvePalette(theme.seriesPalette, theme.gradientStops, count), + ); + } + + private _themeSeriesColorHex(idx: number): string { + const theme = this._resolvedTheme(); + const count = Math.max(theme.seriesPalette.length, idx + 1); + return vec3ToHexColor( + resolvePalette(theme.seriesPalette, theme.gradientStops, count)[ + idx + ], + ); + } + + private _themeGradientStopsSpec(): string { + return stopsToCss(this._resolvedTheme().gradientStops); + } + async draw(view: View): Promise { // `draw` always indicates a view-level change (pivots, columns, // filters, sorts, schema, …) — invalidate the `domain_mode: diff --git a/packages/viewer-charts/src/ts/theme/gradient.ts b/packages/viewer-charts/src/ts/theme/gradient.ts index 067b65f65c..d93a88269f 100644 --- a/packages/viewer-charts/src/ts/theme/gradient.ts +++ b/packages/viewer-charts/src/ts/theme/gradient.ts @@ -10,7 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { parseCSSColorToVec3 } from "../utils/css"; +import { parseCSSColorToVec3, vec3ToHexColor } from "../utils/css"; /** * A single stop on a parsed CSS gradient. `offset` ∈ [0, 1]. @@ -178,6 +178,246 @@ export function parseCssGradient( return result; } +/** + * A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` or + * `rgba()` — as a `[0, 1]` RGB triple, or `null` for anything else. + */ +export function parseCssColorStrict( + src: string, +): [number, number, number] | null { + const s = src.trim(); + if (s.startsWith("#")) { + const hex = s.slice(1); + if (!/^[0-9a-f]+$/i.test(hex)) { + return null; + } + + if (hex.length === 3 || hex.length === 4) { + return [ + parseInt(hex[0] + hex[0], 16) / 255, + parseInt(hex[1] + hex[1], 16) / 255, + parseInt(hex[2] + hex[2], 16) / 255, + ]; + } + + if (hex.length === 6 || hex.length === 8) { + return [ + parseInt(hex.slice(0, 2), 16) / 255, + parseInt(hex.slice(2, 4), 16) / 255, + parseInt(hex.slice(4, 6), 16) / 255, + ]; + } + + return null; + } + + const m = s.match(/^rgba?\(([^)]*)\)$/i); + if (!m) { + return null; + } + + const tokens = m[1] + .split("/")[0] + .split(/[\s,]+/) + .filter((x) => x.length > 0); + if (tokens.length < 3 || tokens.length > 4) { + return null; + } + + const channel = (token: string): number | null => { + const pct = token.endsWith("%"); + const n = parseFloat(pct ? token.slice(0, -1) : token); + if (!isFinite(n)) { + return null; + } + + const v = pct ? (n / 100) * 255 : n; + return Math.max(0, Math.min(255, Math.round(v))) / 255; + }; + + const r = channel(tokens[0]); + const g = channel(tokens[1]); + const b = channel(tokens[2]); + return r === null || g === null || b === null ? null : [r, g, b]; +} + +/** + * The shared `linear-gradient(...)` tokenizer of the viewer's CSS-valued + * config grammar. + */ +export function tokenizeLinearGradient( + src: string, +): Array<[[number, number, number], number | null]> | null { + const m = src.trim().match(/^linear-gradient\s*\((.*)\)$/is); + if (!m) { + return null; + } + + const body = m[1]; + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "(") { + depth++; + } else if (ch === ")") { + depth--; + } else if (ch === "," && depth === 0) { + parts.push(body.substring(start, i)); + start = i + 1; + } + } + + parts.push(body.substring(start)); + const out: Array<[[number, number, number], number | null]> = []; + for (let i = 0; i < parts.length; i++) { + const piece = parts[i].trim(); + if (!piece) { + return null; + } + + const lower = piece.toLowerCase(); + if ( + i === 0 && + (lower.startsWith("to ") || + /^[-\d.]+(deg|rad|grad|turn)$/.test(lower)) + ) { + continue; + } + + const posMatch = piece.match(/\s([^\s)]+)$/); + let color = piece; + let offset: number | null = null; + if (posMatch) { + const tail = posMatch[1]; + if (tail.endsWith("%")) { + const n = parseFloat(tail.slice(0, -1)); + if (!isFinite(n)) { + return null; + } + + color = piece.substring(0, posMatch.index).trim(); + offset = n / 100; + } else if (/^[-\d.]/.test(tail)) { + return null; + } + } + + const rgb = parseCssColorStrict(color); + if (!rgb) { + return null; + } + + out.push([rgb, offset]); + } + + return out; +} + +/** + * Strict gradient reader: a `linear-gradient(...)` with ≥ 2 stops, + * positions optional, offsets clamped to `[0, 1]` and sorted, or `null` + * on malformed input. + */ +export function parseCssGradientStrict(src: string): GradientStop[] | null { + const entries = tokenizeLinearGradient(src); + if (!entries || entries.length < 2) { + return null; + } + + const offsets: Array = entries.map(([, p]) => p); + const last = offsets.length - 1; + if (offsets[0] === null) { + offsets[0] = 0; + } + + if (offsets[last] === null) { + offsets[last] = 1; + } + + for (let i = 1; i < last; i++) { + if (offsets[i] !== null) { + continue; + } + + let j = i + 1; + while (offsets[j] === null) { + j++; + } + + const before = offsets[i - 1]!; + const after = offsets[j]!; + const span = j - (i - 1); + for (let k = i; k < j; k++) { + offsets[k] = before + ((k - (i - 1)) / span) * (after - before); + } + + i = j - 1; + } + + const stops: GradientStop[] = entries.map(([rgb], i) => ({ + offset: Math.max(0, Math.min(1, offsets[i]!)), + color: [rgb[0], rgb[1], rgb[2], 1], + })); + + stops.sort((a, b) => a.offset - b.offset); + return stops; +} + +/** + * Strict palette reader: a `linear-gradient(...)` of ≥ 1 colors with no + * positions, or `null` on malformed input. + */ +export function parseCssColorList( + src: string, +): Array<[number, number, number]> | null { + const entries = tokenizeLinearGradient(src); + if (!entries || entries.length === 0) { + return null; + } + + const out: Array<[number, number, number]> = []; + for (const [rgb, offset] of entries) { + if (offset !== null) { + return null; + } + + out.push(rgb); + } + + return out; +} + +function formatPercent(offset: number): string { + const rounded = Math.round(Math.max(0, Math.min(1, offset)) * 1000) / 10; + return `${rounded}%`; +} + +/** + * The viewer's canonical gradient string for `stops`: `linear-gradient(to + * right, #rrggbb P%, …)`, sorted, positions at 0.1% resolution. + */ +export function stopsToCss(stops: GradientStop[]): string { + const body = [...stops] + .sort((a, b) => a.offset - b.offset) + .map( + (stop) => + `${vec3ToHexColor([stop.color[0], stop.color[1], stop.color[2]])} ${formatPercent(stop.offset)}`, + ) + .join(", "); + + return `linear-gradient(to right, ${body})`; +} + +/** + * The viewer's canonical palette string for `colors`: the same frame + * with bare `#rrggbb` entries and no positions. + */ +export function colorsToCss(colors: Array<[number, number, number]>): string { + return `linear-gradient(to right, ${colors.map(vec3ToHexColor).join(", ")})`; +} + /** * Piecewise-linear color sample at `t ∈ [0, 1]`. Returns RGBA in [0, 1]. * Clamps `t` to the gradient's first/last stop outside `[0, 1]`. diff --git a/packages/viewer-charts/src/ts/theme/overrides.ts b/packages/viewer-charts/src/ts/theme/overrides.ts new file mode 100644 index 0000000000..945c092d39 --- /dev/null +++ b/packages/viewer-charts/src/ts/theme/overrides.ts @@ -0,0 +1,76 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { + parseCssColorList, + parseCssGradientStrict, + type GradientStop, +} from "./gradient"; +import type { Theme } from "./theme"; +import type { Vec3 } from "./palette"; + +/** + * Per-column color-scale overrides from the viewer's `columns_config`, + * patched into the resolved `Theme`'s `seriesPalette` / `gradientStops` + * fields. + */ +export function applyColumnColorOverrides( + theme: Theme, + columnsConfig: Record | undefined, + colorColumn: string | null, +): Theme { + if (!colorColumn) { + return theme; + } + + const cfg = columnsConfig?.[colorColumn]; + if (!cfg) { + return theme; + } + + let out = theme; + const gradient = parseGradientOverride(cfg.gradient); + if (gradient) { + out = { ...out, gradientStops: gradient }; + } + + const palette = parsePaletteOverride(cfg.palette); + if (palette) { + out = { ...out, seriesPalette: palette }; + } + + return out; +} + +/** + * Parse a stored `palette` value into the `seriesPalette` shape, or + * `null` on anything malformed. + */ +export function parsePaletteOverride(raw: unknown): Vec3[] | null { + if (typeof raw !== "string") { + return null; + } + + return parseCssColorList(raw); +} + +/** + * Parse a stored `gradient` value into sorted `GradientStop[]`, or + * `null` on malformed input or fewer than 2 stops. + */ +export function parseGradientOverride(raw: unknown): GradientStop[] | null { + if (typeof raw !== "string") { + return null; + } + + return parseCssGradientStrict(raw); +} diff --git a/packages/viewer-charts/src/ts/transport/renderer-transport.ts b/packages/viewer-charts/src/ts/transport/renderer-transport.ts index 37a3d7be46..8926495e0c 100644 --- a/packages/viewer-charts/src/ts/transport/renderer-transport.ts +++ b/packages/viewer-charts/src/ts/transport/renderer-transport.ts @@ -316,6 +316,7 @@ export class RendererTransport { gridlinesOC = opts.gridlines.transferControlToOffscreen(); chromeOC = opts.chrome.transferControlToOffscreen(); } + const rect = opts.gl.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const themeVars = snapshotThemeVars(opts.gl); diff --git a/packages/viewer-charts/src/ts/utils/css.ts b/packages/viewer-charts/src/ts/utils/css.ts index a08f180520..35a2426b85 100644 --- a/packages/viewer-charts/src/ts/utils/css.ts +++ b/packages/viewer-charts/src/ts/utils/css.ts @@ -34,3 +34,15 @@ export function parseCSSColorToVec3( return [0.5, 0.5, 0.5]; } + +/** + * Format a `[0, 1]`-range RGB triple as a plain `#rrggbb` string. + */ +export function vec3ToHexColor(c: [number, number, number]): string { + const channel = (x: number) => + Math.round(Math.max(0, Math.min(1, x)) * 255) + .toString(16) + .padStart(2, "0"); + + return `#${channel(c[0])}${channel(c[1])}${channel(c[2])}`; +} diff --git a/packages/viewer-charts/src/ts/worker/renderer.worker.ts b/packages/viewer-charts/src/ts/worker/renderer.worker.ts index 007bf94d07..74dec56dce 100644 --- a/packages/viewer-charts/src/ts/worker/renderer.worker.ts +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -115,6 +115,11 @@ export class WorkerRenderer { */ private _composeCanvas: OffscreenCanvas | null = null; private _composeCtx: OffscreenCanvasRenderingContext2D | null = null; + + private _lastPlot: ImageBitmap | null = null; + + private _frameSerial = 0; + private _overlayPresentQueued = false; client: Client; view: View; @@ -203,9 +208,11 @@ export class WorkerRenderer { this.chrome = msg.chromeCanvas ?? new OffscreenCanvas(w, h); if (msg.renderMode === "blit") { this.glManager.setFrameCallback((bitmap) => { + this._frameSerial += 1; const frame = this._composeFrame(bitmap); this.post({ kind: "frameBitmap", bitmap: frame }, [frame]); }); + this.chartImpl.setOverlayPresenter?.(() => this._presentOverlay()); } this.cssWidth = msg.cssWidth; @@ -646,7 +653,11 @@ export class WorkerRenderer { } ctx.drawImage(plot, 0, 0); - plot.close(); + if (this._lastPlot !== plot) { + this._lastPlot?.close(); + this._lastPlot = plot; + } + if (this.chrome.width === w && this.chrome.height === h) { ctx.drawImage(this.chrome, 0, 0); } @@ -654,6 +665,35 @@ export class WorkerRenderer { return canvas.transferToImageBitmap(); } + private _presentOverlay(): void { + if (this._overlayPresentQueued) { + return; + } + + this._overlayPresentQueued = true; + const mark = this._frameSerial; + queueMicrotask(() => { + this._overlayPresentQueued = false; + const plot = this._lastPlot; + if (this._frameSerial !== mark || !plot) { + return; + } + + const w = Math.max(1, Math.round(this.cssWidth * this.dpr)); + const h = Math.max(1, Math.round(this.cssHeight * this.dpr)); + if (plot.width !== w || plot.height !== h) { + return; + } + + const frame = this._composeFrame(plot); + if (frame === plot) { + return; + } + + this.post({ kind: "frameBitmap", bitmap: frame }, [frame]); + }); + } + /** * Composite the three layers into a single PNG `Blob`. */ @@ -715,6 +755,8 @@ export class WorkerRenderer { // next-RAF `drain()` can't paint/present against a dead // context (the "scheduler: present failed" path). unregister(this.glManager); + this._lastPlot?.close(); + this._lastPlot = null; this.chartImpl.destroy(); this.glManager.destroy(); } diff --git a/packages/viewer-charts/test/ts/column-style-colors.spec.ts b/packages/viewer-charts/test/ts/column-style-colors.spec.ts new file mode 100644 index 0000000000..68d0675849 --- /dev/null +++ b/packages/viewer-charts/test/ts/column-style-colors.spec.ts @@ -0,0 +1,262 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { expect, test } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; +import { gotoBasic, restoreChart } from "./helpers"; + +async function countColorPixels( + page: Page, + hex: string, + tolerance = 24, +): Promise { + return await page.evaluate( + ({ hex, tolerance }) => { + const findCanvas = ( + root: Document | ShadowRoot, + ): HTMLCanvasElement | null => { + const direct = root.querySelector( + ".webgl-canvas", + ) as HTMLCanvasElement | null; + if (direct) { + return direct; + } + + for (const el of Array.from(root.querySelectorAll("*"))) { + const sr = (el as Element & { shadowRoot?: ShadowRoot }) + .shadowRoot; + if (sr) { + const found = findCanvas(sr); + if (found) { + return found; + } + } + } + + return null; + }; + + const canvas = findCanvas(document); + if (!canvas || canvas.width === 0 || canvas.height === 0) { + return 0; + } + + const sampler = document.createElement("canvas"); + sampler.width = canvas.width; + sampler.height = canvas.height; + const ctx = sampler.getContext("2d", { + willReadFrequently: true, + })!; + + ctx.drawImage(canvas, 0, 0); + const x0 = Math.round(canvas.width * 0.05); + const y0 = Math.round(canvas.height * 0.05); + const w = Math.round(canvas.width * 0.9); + const h = Math.round(canvas.height * 0.9); + const data = ctx.getImageData(x0, y0, w, h).data; + + const target = [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), + ]; + + let count = 0; + for (let i = 0; i < data.length; i += 4) { + if ( + data[i + 3] > 0 && + Math.abs(data[i] - target[0]) <= tolerance && + Math.abs(data[i + 1] - target[1]) <= tolerance && + Math.abs(data[i + 2] - target[2]) <= tolerance + ) { + count++; + } + } + + return count; + }, + { hex, tolerance }, + ); +} + +async function expectColorPresent(page: Page, hex: string, min = 500) { + await expect + .poll(async () => await countColorPixels(page, hex), { + timeout: 10000, + }) + .toBeGreaterThan(min); +} + +test.describe("columns_config color overrides", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("Y Bar series `color` override recolors the series", async ({ + page, + }) => { + const OVERRIDE = "#d604c1"; + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["State"], + }); + + expect(await countColorPixels(page, OVERRIDE)).toBe(0); + await restoreChart(page, { + columns_config: { Sales: { color: OVERRIDE } }, + } as any); + + await expectColorPresent(page, OVERRIDE); + }); + + test("Y Bar split `palette` override cycles over split series", async ({ + page, + }) => { + const PALETTE = ["#d604c1", "#04d69b", "#6b04d6"]; + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["State"], + split_by: ["Category"], + columns_config: { + Sales: { + palette: `linear-gradient(to right, ${PALETTE.join(", ")})`, + }, + }, + } as any); + + for (const color of PALETTE) { + await expectColorPresent(page, color, 200); + } + }); + + test("Heatmap numeric `gradient` override replaces the theme gradient", async ({ + page, + }) => { + const OVERRIDE = "#d604c1"; + await restoreChart(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["State"], + split_by: ["Category"], + columns_config: { + Sales: { + gradient: `linear-gradient(to right, ${OVERRIDE} 0%, ${OVERRIDE} 100%)`, + }, + }, + } as any); + + await expectColorPresent(page, OVERRIDE); + }); + + test("Treemap categorical `palette` override replaces the series palette", async ({ + page, + }) => { + const OVERRIDE = "#d604c1"; + await restoreChart(page, { + plugin: "Treemap", + columns: ["Sales", "Sub-Category"], + group_by: ["Category"], + aggregates: { "Sub-Category": "dominant" }, + columns_config: { + "Sub-Category": { + palette: `linear-gradient(to right, ${OVERRIDE})`, + }, + }, + } as any); + + await expectColorPresent(page, OVERRIDE); + }); + + test("Heatmap `gradient` as a var() reference resolves through a workspace palette", async ({ + page, + }) => { + const OVERRIDE = "#d604c1"; + await page.evaluate( + async ({ OVERRIDE }) => { + const viewer = document.querySelector( + "perspective-viewer", + ) as any; + const table = await viewer.getTable(); + await viewer.restoreWorkspace({ + palette: { + "--psp-user--gradient-flat": `linear-gradient(${OVERRIDE}, ${OVERRIDE})`, + }, + layout: { + type: "tab-layout", + tabs: ["main"], + selected: 0, + }, + panels: { + main: { + table: await table.get_name(), + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["State"], + split_by: ["Category"], + columns_config: { + Sales: { + gradient: "var(--psp-user--gradient-flat)", + }, + }, + }, + }, + }); + }, + { OVERRIDE }, + ); + + await expectColorPresent(page, OVERRIDE); + }); + + test("Y Bar split `palette` as a var() reference resolves through a page-authored property", async ({ + page, + }) => { + const PALETTE = ["#d604c1", "#04d69b", "#6b04d6"]; + await page.addStyleTag({ + content: `perspective-viewer { --psp-user--palette-1: linear-gradient(${PALETTE.join(", ")}); }`, + }); + + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["State"], + split_by: ["Category"], + columns_config: { + Sales: { palette: "var(--psp-user--palette-1)" }, + }, + } as any); + + for (const color of PALETTE) { + await expectColorPresent(page, color, 200); + } + }); + + test("clearing the override restores theme colors", async ({ page }) => { + const OVERRIDE = "#d604c1"; + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["State"], + columns_config: { Sales: { color: OVERRIDE } }, + } as any); + + await expectColorPresent(page, OVERRIDE); + await restoreChart(page, { columns_config: null } as any); + await expect + .poll(async () => await countColorPixels(page, OVERRIDE), { + timeout: 10000, + }) + .toBe(0); + }); +}); diff --git a/packages/viewer-datagrid/src/ts/color_utils.ts b/packages/viewer-datagrid/src/ts/color_utils.ts index 5db4f3a457..58fff6290f 100644 --- a/packages/viewer-datagrid/src/ts/color_utils.ts +++ b/packages/viewer-datagrid/src/ts/color_utils.ts @@ -101,6 +101,52 @@ export function rgbToHex([r, g, b]: RGB): string { return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } +/** + * One stop of a resolved color scale: an `RGB` triple at + * `offset` ∈ [0, 1]. + */ +export interface GradientStopRgb { + rgb: RGB; + offset: number; +} + +/** + * Piecewise-linear color sample over `stops` (sorted by offset) at + * `t` ∈ [0, 1], clamped to the first/last stop outside their offsets. + */ +export function sampleGradientRgb(stops: GradientStopRgb[], t: number): RGB { + if (stops.length === 0) { + return [0, 0, 0]; + } + + if (t <= stops[0].offset) { + return stops[0].rgb; + } + + const last = stops[stops.length - 1]; + if (t >= last.offset) { + return last.rgb; + } + + for (let i = 0; i + 1 < stops.length; i++) { + const a = stops[i]; + const b = stops[i + 1]; + if (t > b.offset) { + continue; + } + + const span = b.offset - a.offset; + const u = span > 0 ? (t - a.offset) / span : 0; + return [ + a.rgb[0] + (b.rgb[0] - a.rgb[0]) * u, + a.rgb[1] + (b.rgb[1] - a.rgb[1]) * u, + a.rgb[2] + (b.rgb[2] - a.rgb[2]) * u, + ]; + } + + return last.rgb; +} + /** Convert sRGB to HSL. Output `h` is in degrees `[0, 360)`; `s` and `l` are in `[0, 1]`. */ export function rgbToHsl([r, g, b]: RGB): HSL { const rn = r / 255, @@ -209,6 +255,215 @@ export function infer_foreground_from_background([r, g, b]: [ : "#ffffff"; } +/** + * A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` or + * `rgba()` — or `null` for anything else. + */ +export function parseColorStrict(input: string): RGB | null { + const s = input.trim(); + if (s.startsWith("#")) { + return /^#[0-9a-f]+$/i.test(s) ? parse_hex(s) : null; + } + + const m = s.match(/^rgba?\(([^)]*)\)$/i); + if (!m) { + return null; + } + + const tokens = m[1] + .split("/")[0] + .split(/[\s,]+/) + .filter((x) => x.length > 0); + if (tokens.length < 3 || tokens.length > 4) { + return null; + } + + const channel = (token: string): number | null => { + const pct = token.endsWith("%"); + const n = parseFloat(pct ? token.slice(0, -1) : token); + if (!isFinite(n)) { + return null; + } + + return Math.max( + 0, + Math.min(255, Math.round(pct ? (n / 100) * 255 : n)), + ); + }; + + const r = channel(tokens[0]); + const g = channel(tokens[1]); + const b = channel(tokens[2]); + return r === null || g === null || b === null ? null : [r, g, b]; +} + +/** + * The shared `linear-gradient(...)` tokenizer of the viewer's CSS-valued + * config grammar. + */ +export function tokenizeLinearGradient( + src: string, +): Array<[RGB, number | null]> | null { + const m = src.trim().match(/^linear-gradient\s*\((.*)\)$/is); + if (!m) { + return null; + } + + const body = m[1]; + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "(") { + depth++; + } else if (ch === ")") { + depth--; + } else if (ch === "," && depth === 0) { + parts.push(body.substring(start, i)); + start = i + 1; + } + } + + parts.push(body.substring(start)); + const out: Array<[RGB, number | null]> = []; + for (let i = 0; i < parts.length; i++) { + const piece = parts[i].trim(); + if (!piece) { + return null; + } + + const lower = piece.toLowerCase(); + if ( + i === 0 && + (lower.startsWith("to ") || + /^[-\d.]+(deg|rad|grad|turn)$/.test(lower)) + ) { + continue; + } + + const posMatch = piece.match(/\s([^\s)]+)$/); + let color = piece; + let offset: number | null = null; + if (posMatch) { + const tail = posMatch[1]; + if (tail.endsWith("%")) { + const n = parseFloat(tail.slice(0, -1)); + if (!isFinite(n)) { + return null; + } + + color = piece.substring(0, posMatch.index).trim(); + offset = n / 100; + } else if (/^[-\d.]/.test(tail)) { + return null; + } + } + + const rgb = parseColorStrict(color); + if (!rgb) { + return null; + } + + out.push([rgb, offset]); + } + + return out; +} + +/** + * Strict gradient reader (`fg_colors`/`bg_colors`): ≥ 2 stops, positions + * optional, clamped to `[0, 1]` and sorted, or `null` on malformed + * input. + */ +export function parseCssGradientStops(src: string): GradientStopRgb[] | null { + const entries = tokenizeLinearGradient(src); + if (!entries || entries.length < 2) { + return null; + } + + const offsets: Array = entries.map(([, p]) => p); + const last = offsets.length - 1; + if (offsets[0] === null) { + offsets[0] = 0; + } + + if (offsets[last] === null) { + offsets[last] = 1; + } + + for (let i = 1; i < last; i++) { + if (offsets[i] !== null) { + continue; + } + + let j = i + 1; + while (offsets[j] === null) { + j++; + } + + const before = offsets[i - 1]!; + const after = offsets[j]!; + const span = j - (i - 1); + for (let k = i; k < j; k++) { + offsets[k] = before + ((k - (i - 1)) / span) * (after - before); + } + + i = j - 1; + } + + const stops: GradientStopRgb[] = entries.map(([rgb], i) => ({ + rgb, + offset: Math.max(0, Math.min(1, offsets[i]!)), + })); + + stops.sort((a, b) => a.offset - b.offset); + return stops; +} + +/** + * Strict palette reader (`palette`): ≥ 1 colors with no positions, or + * `null` on malformed input. + */ +export function parseCssColorList(src: string): RGB[] | null { + const entries = tokenizeLinearGradient(src); + if (!entries || entries.length === 0) { + return null; + } + + const out: RGB[] = []; + for (const [rgb, offset] of entries) { + if (offset !== null) { + return null; + } + + out.push(rgb); + } + + return out; +} + +/** The viewer's canonical gradient string: `linear-gradient(to right, #rrggbb P%, …)`, sorted, 0.1% positions. */ +export function stopsToCss( + stops: Array<{ color: string; offset: number }>, +): string { + const body = [...stops] + .sort((a, b) => a.offset - b.offset) + .map((stop) => { + const pct = + Math.round(Math.max(0, Math.min(1, stop.offset)) * 1000) / 10; + return `${stop.color} ${pct}%`; + }) + .join(", "); + + return `linear-gradient(to right, ${body})`; +} + +/** The viewer's canonical palette string: `linear-gradient(to right, #rrggbb, …)` — no positions. */ +export function colorsToCss(colors: string[]): string { + return `linear-gradient(to right, ${colors.join(", ")})`; +} + /** Build a CSS `linear-gradient` that fans `rgb` ±15° in hue, used as the negative-value swatch in column color pickers. */ function make_gradient(rgb: RGB): string { const [h, s, l] = rgbToHsl(rgb); diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts index 7fd3d10e08..b9b2586056 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -14,7 +14,6 @@ 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"; @@ -382,11 +381,6 @@ export class HTMLPerspectiveViewerDatagridPluginElement } 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 { diff --git a/packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts b/packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts index 7f4435175a..f8717527ff 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/edit_focus.ts @@ -42,7 +42,10 @@ function is_cell_text_editable( const meta = table.getMeta(td); if ( meta?.type !== "body" || - !isEditableMode(model, undefined as unknown as HTMLPerspectiveViewerElement) + !isEditableMode( + model, + undefined as unknown as HTMLPerspectiveViewerElement, + ) ) { return false; } @@ -52,8 +55,7 @@ function is_cell_text_editable( } const type = get_psp_type(model, meta); - const plugins: ColumnsConfig = - (table as any)[PRIVATE_PLUGIN_SYMBOL] || {}; + const plugins: ColumnsConfig = (table as any)[PRIVATE_PLUGIN_SYMBOL] || {}; const column_name = meta.column_header?.[model._config.split_by.length]; const format = column_name ? plugins[column_name.toString()]?.format diff --git a/packages/viewer-datagrid/src/ts/model/create.ts b/packages/viewer-datagrid/src/ts/model/create.ts index 68eca769e6..45349bfbf4 100644 --- a/packages/viewer-datagrid/src/ts/model/create.ts +++ b/packages/viewer-datagrid/src/ts/model/create.ts @@ -11,7 +11,13 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { createDataListener } from "../data_listener/index.js"; -import { blend, make_color_record, parseColor } from "../color_utils.js"; +import { + blend, + make_color_record, + parseColor, + rgbToHex, + type RGB, +} from "../color_utils.js"; import type { ColumnType, Table, @@ -113,8 +119,34 @@ export type ThemeStyle = Pick< | "_neg_fg_color" | "_pos_bg_color" | "_neg_bg_color" + | "_default_bg_color_stops" + | "_series_palette" >; +function read_series_palette(regular: HTMLElement, accent: string): string[] { + const walk = (prefix: string): string[] => { + const out: string[] = []; + for (let i = 1; ; i++) { + const raw = get_rule(regular, `${prefix}${i}--color`, ""); + if (!raw) { + break; + } + + out.push(rgbToHex(parseColor(raw))); + } + + return out; + }; + + const own = walk("--psp-datagrid--series-"); + if (own.length > 0) { + return own; + } + + const charts = walk("--psp-charts--series-"); + return charts.length > 0 ? charts : [rgbToHex(parseColor(accent))]; +} + /** * Read the theme-derived style values off `regular`'s computed style, the * single source for the color/theme fields cached on `DatagridModel`. @@ -145,6 +177,20 @@ export function readThemeStyle(regular: HTMLElement): ThemeStyle { get_rule(regular, "--psp-active--color", "#ff0000"), ); + const _series_palette = read_series_palette(regular, _color[0]); + + const _default_bg_color_stops = [ + { + rgb: [_neg_bg_color[1], _neg_bg_color[2], _neg_bg_color[3]] as RGB, + offset: 0, + }, + { rgb: _plugin_background as RGB, offset: 0.5 }, + { + rgb: [_pos_bg_color[1], _pos_bg_color[2], _pos_bg_color[3]] as RGB, + offset: 1, + }, + ]; + return { _theme, _plugin_background, @@ -153,6 +199,8 @@ export function readThemeStyle(regular: HTMLElement): ThemeStyle { _neg_fg_color, _pos_bg_color, _neg_bg_color, + _default_bg_color_stops, + _series_palette, }; } diff --git a/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts index 4c83239a6b..95e3257b68 100644 --- a/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts +++ b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ColumnType } from "@perspective-dev/client"; +import { colorsToCss, rgbToHex, stopsToCss } from "../color_utils.js"; import type { ColumnConfig, DatagridPluginElement } from "../types.js"; interface ViewerConfigLike { @@ -84,12 +85,13 @@ export default function column_config_schema( const fg_mode = (current_value?.number_fg_mode as string) ?? "color"; if (fg_mode !== "disabled") { fields.push({ - kind: "ColorRange", - key_pos: "pos_fg_color" satisfies keyof ColumnConfig, - key_neg: "neg_fg_color" satisfies keyof ColumnConfig, - default_pos: pos_fg, - default_neg: neg_fg, - is_gradient: false, + kind: "GradientStops", + key: "fg_colors" satisfies keyof ColumnConfig, + default: stopsToCss([ + { color: neg_fg, offset: 0 }, + { color: pos_fg, offset: 1 }, + ]), + discrete: true, }); } @@ -116,14 +118,36 @@ export default function column_config_schema( const bg_mode = (current_value?.number_bg_mode as string) ?? "disabled"; if (bg_mode !== "disabled") { - fields.push({ - kind: "ColorRange", - key_pos: "pos_bg_color" satisfies keyof ColumnConfig, - key_neg: "neg_bg_color" satisfies keyof ColumnConfig, - default_pos: pos_bg, - default_neg: neg_bg, - is_gradient: bg_mode === "gradient" || bg_mode === "pulse", - }); + if (bg_mode === "color") { + fields.push({ + kind: "GradientStops", + key: "bg_colors" satisfies keyof ColumnConfig, + default: stopsToCss([ + { color: neg_bg, offset: 0 }, + { color: pos_bg, offset: 1 }, + ]), + discrete: true, + }); + } else { + fields.push({ + kind: "GradientStops", + key: "bg_colors" satisfies keyof ColumnConfig, + default: stopsToCss([ + { color: neg_bg, offset: 0 }, + { + color: rgbToHex( + this.model!._plugin_background as [ + number, + number, + number, + ], + ), + offset: 0.5, + }, + { color: pos_bg, offset: 1 }, + ]), + }); + } } if (bg_mode === "gradient") { @@ -176,7 +200,13 @@ export default function column_config_schema( }); const str_mode = (current_value?.string_color_mode as string) ?? "none"; - if (str_mode !== "none") { + if (str_mode === "series") { + fields.push({ + kind: "Palette", + key: "palette" satisfies keyof ColumnConfig, + default: colorsToCss(this.model!._series_palette), + }); + } else if (str_mode !== "none") { fields.push({ kind: "Color", key: "color" satisfies keyof ColumnConfig, diff --git a/packages/viewer-datagrid/src/ts/plugin/restore.ts b/packages/viewer-datagrid/src/ts/plugin/restore.ts index 5f77b3a520..6607709168 100644 --- a/packages/viewer-datagrid/src/ts/plugin/restore.ts +++ b/packages/viewer-datagrid/src/ts/plugin/restore.ts @@ -17,7 +17,13 @@ import { toggle_scroll_lock, } from "../model/toolbar.js"; import { PRIVATE_PLUGIN_SYMBOL } from "../model/index.js"; -import { make_color_record } from "../color_utils.js"; +import { + make_color_record, + parseCssColorList, + parseCssGradientStops, + rgbToHex, + type GradientStopRgb, +} from "../color_utils.js"; import type { DatagridPluginElement, ColumnOverrides, @@ -31,24 +37,33 @@ interface RestoreToken { scroll_lock?: boolean; } -// interface ColumnConfigWithColors { -// pos_fg_color?: string; -// neg_fg_color?: string; -// pos_bg_color?: string; -// neg_bg_color?: string; -// color?: string; -// [key: string]: unknown; -// } - interface StylesConfig { pos_fg_color?: ColorRecord; neg_fg_color?: ColorRecord; pos_bg_color?: ColorRecord; neg_bg_color?: ColorRecord; color?: ColorRecord; + bg_color_stops?: GradientStopRgb[]; + palette_colors?: string[]; [key: string]: unknown; } +function parse_stops(raw: unknown): GradientStopRgb[] | undefined { + if (typeof raw !== "string") { + return undefined; + } + + return parseCssGradientStops(raw) ?? undefined; +} + +function parse_palette(raw: unknown): string[] | undefined { + if (typeof raw !== "string") { + return undefined; + } + + return parseCssColorList(raw)?.map(rgbToHex) ?? undefined; +} + export function restore( this: DatagridPluginElement, token: RestoreToken, @@ -73,23 +88,25 @@ export function restore( const styles: Record = {}; if (columns) { for (const [col_name, controls] of Object.entries(columns)) { + const fg_stops = parse_stops(controls.fg_colors); + const bg_stops = parse_stops(controls.bg_colors); + const end = (stops: GradientStopRgb[], i: number) => + make_color_record(rgbToHex(stops[i].rgb)); styles[col_name] = { ...controls, - pos_fg_color: controls.pos_fg_color - ? make_color_record(controls.pos_fg_color) - : undefined, - neg_fg_color: controls.neg_fg_color - ? make_color_record(controls.neg_fg_color) - : undefined, - pos_bg_color: controls.pos_bg_color - ? make_color_record(controls.pos_bg_color) + pos_fg_color: fg_stops + ? end(fg_stops, fg_stops.length - 1) : undefined, - neg_bg_color: controls.neg_bg_color - ? make_color_record(controls.neg_bg_color) + neg_fg_color: fg_stops ? end(fg_stops, 0) : undefined, + pos_bg_color: bg_stops + ? end(bg_stops, bg_stops.length - 1) : undefined, + neg_bg_color: bg_stops ? end(bg_stops, 0) : undefined, color: controls.color ? make_color_record(controls.color) : undefined, + bg_color_stops: bg_stops, + palette_colors: parse_palette(controls.palette), }; } } diff --git a/packages/viewer-datagrid/src/ts/style_handlers/body.ts b/packages/viewer-datagrid/src/ts/style_handlers/body.ts index 7cb768002f..56f3b9286a 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/body.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/body.ts @@ -100,7 +100,10 @@ export function applyBodyCellStyles( const n_split_by = model._config.split_by.length; const group_by_len = model._config.group_by.length; const theme = model._pos_bg_color; - const menu_col = model._column_settings_selected_column; + const menu_col = + model._config.columns.length > 1 + ? model._column_settings_selected_column + : undefined; const col_states: Map = new Map(); const col_state = ( key: number, diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts index 0156086412..28e4b403fe 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts @@ -13,8 +13,11 @@ import { style_cell_flash } from "./cell_flash.js"; import { format_raw } from "../../data_listener/format_cell.js"; import { - rgbaToRgb, infer_foreground_from_background, + rgbaToRgb, + rgbToHex, + sampleGradientRgb, + type GradientStopRgb, } from "../../color_utils.js"; import type { DatagridModel, ColumnConfig, ColorRecord } from "../../types.js"; import type { ColumnType } from "@perspective-dev/client"; @@ -37,6 +40,12 @@ interface PluginWithColors neg_bg_color?: ColorRecord; pos_fg_color?: ColorRecord; neg_fg_color?: ColorRecord; + + /** + * Resolved `bg_colors` scale for the gradient sampler, written by + * `restore.ts` when the key is stored. + */ + bg_color_stops?: GradientStopRgb[]; } /** @@ -138,21 +147,26 @@ export function cell_style_numeric( td.style.animation = ""; td.style.backgroundColor = hex; } else if (plugin?.number_bg_mode === "gradient") { - const a = Math.max( - 0, - Math.min( - 1, - Math.abs((metadata.user ?? 0) / (plugin.bg_gradient ?? 1)), - ), - ); - const source = model._plugin_background as [number, number, number]; - const foreground = infer_foreground_from_background( - rgbaToRgb([r, g, b, a], source), - ); + const stops = + plugin.bg_color_stops ?? model._default_bg_color_stops; + + const t = + 0.5 + + 0.5 * + Math.max( + -1, + Math.min( + 1, + (metadata.user ?? 0) / (plugin.bg_gradient ?? 1), + ), + ); + + const sample = sampleGradientRgb(stops, t); + const foreground = infer_foreground_from_background(sample); td.style.animation = ""; td.style.color = foreground; - td.style.backgroundColor = `rgba(${r},${g},${b},${a})`; + td.style.backgroundColor = rgbToHex(sample); } else if (plugin?.number_bg_mode === "pulse") { style_cell_flash( model, diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts index 55d500cf84..491e556d64 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts @@ -11,12 +11,9 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { - hslToRgb, infer_foreground_from_background, parseColor, rgbaToRgb, - rgbToHex, - rgbToHsl, } from "../../color_utils.js"; import type { DatagridModel, ColumnConfig, ColorRecord } from "../../types.js"; @@ -28,6 +25,9 @@ interface CellMetaWithExtras { interface PluginWithColor extends Omit { color?: ColorRecord; + + /** `palette` parsed once at restore (`#rrggbb` entries). */ + palette_colors?: string[]; } export function cell_style_string( @@ -87,10 +87,15 @@ export function cell_style_string( } const color_seed = series_map.get(metadata.user!) ?? 0; - const [h, s, l] = rgbToHsl(parseColor(hex)); - const rotated = hslToRgb([h + ((color_seed * 150) % 360), s, l]); - const [r2, g2, b2] = rotated; - const hex2 = rgbToHex(rotated); + + const palette_colors = plugin?.palette_colors; + const palette = + palette_colors && palette_colors.length > 0 + ? palette_colors + : model._series_palette; + + const hex2 = palette[color_seed % palette.length]; + const [r2, g2, b2] = parseColor(hex2); const source = model._plugin_background as [number, number, number]; const foreground = infer_foreground_from_background( rgbaToRgb([r2, g2, b2, 1], source), diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts index a48a2f3c69..9eca94f3a7 100644 --- a/packages/viewer-datagrid/src/ts/types.ts +++ b/packages/viewer-datagrid/src/ts/types.ts @@ -27,6 +27,7 @@ import type { } from "@perspective-dev/viewer"; import type { RegularTableElement } from "regular-table"; import type { CellMetadata, DataResponse } from "regular-table/dist/esm/types"; +import type { GradientStopRgb } from "./color_utils.js"; // Re-export types from regular-table for use throughout the codebase export type { RegularTableElement as RegularTable }; @@ -108,17 +109,18 @@ export interface ColumnConfig { /** String / datetime columns: the applied color (CSS color). */ color?: string; - /** Numeric columns: positive-value foreground color (CSS color). */ - pos_fg_color?: string; - - /** Numeric columns: negative-value foreground color (CSS color). */ - neg_fg_color?: string; - - /** Numeric columns: positive-value background color (CSS color). */ - pos_bg_color?: string; + /** + * Numeric columns: foreground sign-split colors — a CSS + * `linear-gradient(to right, #rrggbb 0%, #rrggbb 100%)`, t-ordered + * (the first stop is the negative color, the last the positive). + */ + fg_colors?: string; - /** Numeric columns: negative-value background color (CSS color). */ - neg_bg_color?: string; + /** + * Numeric columns: background color scale, t-ordered with the + * sign pivot at offset 0.5. + */ + bg_colors?: string; /** * Numeric columns: the absolute value at which bar/gradient @@ -148,10 +150,17 @@ export interface ColumnConfig { /** * String columns: color mode (`"foreground"`, `"background"` or - * `"series"`), paired with `color`. + * `"series"`). `"foreground"` / `"background"` pair with `color`; + * `"series"` pairs with `palette`. */ string_color_mode?: string; + /** + * String columns, `"series"` mode: explicit palette assigned to + * distinct values in encounter order. + */ + palette?: string; + /** * Datetime columns: color mode (`"foreground"` or `"background"`), * paired with `color`. @@ -243,6 +252,10 @@ export interface DatagridModel { _neg_fg_color: ColorRecord; _pos_bg_color: ColorRecord; _neg_bg_color: ColorRecord; + + _default_bg_color_stops: GradientStopRgb[]; + + _series_palette: string[]; _column_paths: string[]; _column_types: ColumnType[]; _is_editable: boolean[]; diff --git a/packages/viewer-datagrid/test/js/column_settings.spec.ts b/packages/viewer-datagrid/test/js/column_settings.spec.ts index 4116b8cb8d..97f26a8a40 100644 --- a/packages/viewer-datagrid/test/js/column_settings.spec.ts +++ b/packages/viewer-datagrid/test/js/column_settings.spec.ts @@ -50,6 +50,9 @@ test.describe("Datagrid Column Styles", function () { ); }); + const width = (await view.save()).columns_config["Row ID"] + .column_size_override; + const editBtn = view.dataGrid.regularTable.editBtnRow .locator("th.psp-menu-enabled span") .first(); @@ -57,13 +60,14 @@ test.describe("Datagrid Column Styles", function () { await editBtn.click(); await view.columnSettingsSidebar.container.waitFor(); await page - .locator('div[data-value="Decimal"] select') + .locator("div.row", { has: page.locator("label#style-label") }) + .locator("select") .selectOption("Percent"); const token = await view.save(); test.expect(token.columns_config).toEqual({ "Row ID": { - column_size_override: 150, - number_string_format: { + column_size_override: width, + number_format: { style: "percent", }, }, @@ -371,4 +375,38 @@ runTests("Datagrid Column Styles - Split-by", () => { await btn.click(); await expect(headers).not.toBeAttached(); }); + + test("Datagrid Column Styles - Single column draws no body highlight", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/superstore-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const viewer = new PspViewer(page); + const table = viewer.dataGrid.regularTable; + const bodyHighlight = table.table.locator("tbody td.psp-menu-open"); + const headerHighlight = table.editBtnRow.locator("th.psp-menu-open"); + + await viewer.restore({ + columns: ["Sales", "Profit"], + group_by: [], + split_by: [], + settings: true, + }); + const btn = await table.getEditBtnByName("Sales"); + await btn.locator("span:not(.rt-column-resize)").click(); + await expect(headerHighlight).toHaveCount(1); + await expect(bodyHighlight.first()).toBeAttached(); + + await viewer.restore({ columns: ["Sales"] }); + await expect(headerHighlight).toHaveCount(1); + await expect(bodyHighlight).toHaveCount(0); + + await viewer.restore({ columns: ["Sales", "Profit"] }); + await expect(bodyHighlight.first()).toBeAttached(); + }); }); diff --git a/packages/viewer-datagrid/test/js/column_style.spec.ts b/packages/viewer-datagrid/test/js/column_style.spec.ts index f2848dad79..1e6c93f588 100644 --- a/packages/viewer-datagrid/test/js/column_style.spec.ts +++ b/packages/viewer-datagrid/test/js/column_style.spec.ts @@ -404,7 +404,6 @@ test.describe("Column Style Tests", () => { // ────────────────────────────────────────────────────────────────── // Sidebar should re-query schema and surface extra controls (the - // background `ColorRange` and gradient `Number` max) when // `number_bg_mode` is set to `gradient`. // ────────────────────────────────────────────────────────────────── test("Sidebar surfaces gradient controls when bg_mode = gradient", async ({ @@ -446,9 +445,6 @@ test.describe("Column Style Tests", () => { await page.mouse.click(x, y); - // The schema for `Profit` with bg_mode=gradient should emit a - // `ColorRange` (background-pos/neg) and a `Number` field for - // `bg_gradient`. Both are tab-section children in the StyleTab. await page .locator("perspective-viewer #column_settings_sidebar") .waitFor(); @@ -457,10 +453,12 @@ test.describe("Column Style Tests", () => { "perspective-viewer #column_settings_sidebar #style-tab", ); - // Background ColorRange ids derive from the `label` - // ("background") in the Datagrid schema. - await sidebar_locator.locator(".pos_bg_color").waitFor(); - await sidebar_locator.locator(".neg_bg_color").waitFor(); + const bg_field = sidebar_locator.locator("fieldset.style-control", { + has: page.locator("#bg_colors-label"), + }); + + await bg_field.locator(".gradient-stops-selector").waitFor(); + await expect(bg_field.locator(".gradient-stop-handle")).toHaveCount(3); // Snapshot the sidebar's style-tab DOM as a holistic check. const contents = await sidebar_locator.innerHTML(); @@ -680,15 +678,13 @@ test.describe("Column Style Tests", () => { }); }); - // Regression: the sidebar's ColorRange control emits a sparse config — - // only the side(s) that differ from the theme default are written. A - // one-sided fg pair (e.g. `pos_fg_color` alone) used to crash - // `cell_style_numeric` on negative cells ("d is not iterable"), and the - // mirror case (`neg_fg_color` alone) was silently ignored. Each side - // must fall back to the theme color independently, like the bg pair. - test("pos_fg_color alone renders negatives with theme fallback (no crash)", async ({ - page, - }) => { + // ────────────────────────────────────────────────────────────────── + // ────────────────────────────────────────────────────────────────── + + async function gradient_cells( + page: Page, + columns_config: Record, + ): Promise> { await page.goto("/tools/test/src/html/basic-test.html"); await page.evaluate(async () => { while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { @@ -696,24 +692,14 @@ test.describe("Column Style Tests", () => { } }); - const { cells, rejections } = await page.evaluate(async () => { - const rejections: string[] = []; - window.addEventListener("unhandledrejection", (e) => { - rejections.push(String(e.reason)); - }); - + return await page.evaluate(async (columns_config) => { const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", columns: ["Profit"], sort: [["Profit", "asc"]], - columns_config: { - Profit: { - number_fg_mode: "color", - pos_fg_color: "#00ff00", - }, - }, - }); + columns_config, + } as any); await viewer.flush(); await new Promise((x) => setTimeout(x, 100)); @@ -723,25 +709,14 @@ test.describe("Column Style Tests", () => { ).shadowRoot.querySelectorAll("regular-table tbody td"), ); - return { - rejections, - cells: tds.map((td: any) => ({ - text: td.textContent.trim(), - color: td.style.color, - })), - }; - }); - - expect(rejections).toEqual([]); - const negatives = cells.filter((c) => c.text.startsWith("-")); - expect(negatives.length).toBeGreaterThan(0); - for (const cell of negatives) { - expect(cell.color).not.toEqual(""); - expect(cell.color).not.toEqual("rgb(0, 255, 0)"); - } - }); + return tds.map((td: any) => ({ + text: td.textContent.trim(), + bg: td.style.backgroundColor, + })); + }, columns_config); + } - test("neg_fg_color alone renders negatives with the custom color", async ({ + test("string series mode cycles the theme's --psp-datagrid--series-N--color palette", async ({ page, }) => { await page.goto("/tools/test/src/html/basic-test.html"); @@ -751,48 +726,129 @@ test.describe("Column Style Tests", () => { } }); - const errors: string[] = []; - page.on("pageerror", (e) => errors.push(e.message)); - - const cells = await page.evaluate(async () => { + const { cells, palette } = await page.evaluate(async () => { const viewer = document.querySelector("perspective-viewer")!; await viewer.restore({ plugin: "Datagrid", - columns: ["Profit"], - sort: [["Profit", "asc"]], - columns_config: { - Profit: { - number_fg_mode: "color", - neg_fg_color: "#ff0000", - }, - }, - }); + columns: ["Category"], + columns_config: { Category: { string_color_mode: "series" } }, + } as any); await viewer.flush(); + await new Promise((x) => setTimeout(x, 100)); + const style = getComputedStyle(viewer); + const palette: string[] = []; + for (let i = 1; ; i++) { + const raw = style + .getPropertyValue(`--psp-datagrid--series-${i}--color`) + .trim(); + if (!raw) { + break; + } + + const probe = document.createElement("div"); + probe.style.color = raw; + document.body.appendChild(probe); + palette.push(getComputedStyle(probe).color); + probe.remove(); + } + const tds = Array.from( ( viewer.querySelector("perspective-viewer-datagrid") as any ).shadowRoot.querySelectorAll("regular-table tbody td"), ); - return tds.map((td: any) => ({ - text: td.textContent.trim(), - color: td.style.color, - })); + return { + palette, + cells: tds.map((td: any) => ({ + text: td.textContent.trim(), + bg: td.style.backgroundColor, + })), + }; }); - expect(errors).toEqual([]); - const negatives = cells.filter((c) => c.text.startsWith("-")); - expect(negatives.length).toBeGreaterThan(0); - for (const cell of negatives) { - expect(cell.color).toEqual("rgb(255, 0, 0)"); + expect(palette.length).toBeGreaterThan(1); + const filled = cells.filter((c) => c.text !== ""); + expect(filled.length).toBeGreaterThan(0); + for (const cell of filled) { + expect(palette).toContain(cell.bg); + } + + expect(filled[0].bg).toBe(palette[0]); + const by_value = new Map(); + for (const cell of filled) { + const prior = by_value.get(cell.text); + expect(prior === undefined || prior === cell.bg).toBe(true); + by_value.set(cell.text, cell.bg); + } + }); + + test("bg gradient renders full-scale end colors beyond the domain", async ({ + page, + }) => { + const cells = await gradient_cells(page, { + Profit: { + number_bg_mode: "gradient", + bg_gradient: 100, + bg_colors: + "linear-gradient(to right, #ff0000 0%, #ffffff 50%, #0000ff 100%)", + }, + }); + + const saturated = cells.filter( + (c) => + c.text !== "" && + Math.abs(parseFloat(c.text.replace(/,/g, ""))) > 100, + ); + expect(saturated.length).toBeGreaterThan(0); + for (const cell of saturated) { + expect(cell.bg).toEqual( + cell.text.startsWith("-") ? "rgb(255, 0, 0)" : "rgb(0, 0, 255)", + ); + } + }); + + test("bg gradient samples interior stops", async ({ page }) => { + const cells = await gradient_cells(page, { + Profit: { + number_bg_mode: "gradient", + bg_gradient: 1e12, + bg_colors: + "linear-gradient(to right, #ff0000 0%, #123456 50%, #0000ff 100%)", + }, + }); + + const filled = cells.filter((c) => c.text !== ""); + expect(filled.length).toBeGreaterThan(0); + for (const cell of filled) { + expect(cell.bg).toEqual("rgb(18, 52, 86)"); } + }); + test("bg_colors under color mode renders the END colors by sign", async ({ + page, + }) => { + const cells = await gradient_cells(page, { + Profit: { + number_bg_mode: "color", + bg_colors: "linear-gradient(#ff0000, #123456, #0000ff)", + }, + }); + + const negatives = cells.filter((c) => c.text.startsWith("-")); const positives = cells.filter( (c) => !c.text.startsWith("-") && c.text !== "", ); + + expect(negatives.length).toBeGreaterThan(0); + expect(positives.length).toBeGreaterThan(0); + for (const cell of negatives) { + expect(cell.bg).toEqual("rgb(255, 0, 0)"); + } + for (const cell of positives) { - expect(cell.color).not.toEqual("rgb(255, 0, 0)"); + expect(cell.bg).toEqual("rgb(0, 0, 255)"); } }); @@ -907,7 +963,9 @@ test.describe("Column Style Tests", () => { return (await viewer.save()).columns_config ?? {}; }); - expect(saved2[col_b]?.pos_fg_color).toEqual("#00ff00"); + expect(saved2[col_b]?.fg_colors).toMatch( + /^linear-gradient\(to right, #00ff00 0%, #[0-9a-f]{6} 100%\)$/, + ); expect(saved2[col_a]).toBeUndefined(); }); diff --git a/packages/viewer-datagrid/test/js/restyle.spec.ts b/packages/viewer-datagrid/test/js/restyle.spec.ts index 1866409dad..aa15eb06ed 100644 --- a/packages/viewer-datagrid/test/js/restyle.spec.ts +++ b/packages/viewer-datagrid/test/js/restyle.spec.ts @@ -72,7 +72,105 @@ async function read_neg_cell_color(page: any) { }); } +async function read_max_cell_background(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"); + const sidebar = document + .querySelector("perspective-viewer")! + .shadowRoot!.querySelector("#column_settings_sidebar"); + const handles = sidebar?.querySelectorAll( + "#bg_colors-label ~ * .gradient-stop-handle input, fieldset:has(#bg_colors-label) .gradient-stop-handle input", + ); + const last = handles?.[handles.length - 1] as + | HTMLInputElement + | undefined; + return { + cell: norm(td.style.backgroundColor), + theme_var: norm(datagrid.model._pos_bg_color[0]), + sidebar_pos_stop: last ? norm(last.value) : undefined, + }; + }); +} + test.describe("Datagrid restyle()", () => { + test("default gradient background follows 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", + settings: true, + sort: [["Row ID", "desc"]], + }); + }); + + const { x, y } = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + const editBtn = ( + viewer.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector( + "#psp-column-edit-buttons th.psp-menu-enabled:nth-child(1) span", + ); + + const rect = editBtn.getBoundingClientRect(); + return { + x: Math.floor(rect.left + rect.width / 2), + y: Math.floor(rect.top + rect.height / 2), + }; + }); + + await page.mouse.click(x, y); + const sidebar = page.locator( + "perspective-viewer #column_settings_sidebar", + ); + await sidebar.waitFor(); + await sidebar + .locator("fieldset.style-control", { + has: page.locator("#number_bg_mode-label"), + }) + .locator("select") + .selectOption("gradient"); + + await expect + .poll(async () => (await read_max_cell_background(page)).cell) + .toEqual((await read_max_cell_background(page)).theme_var); + const light = await read_max_cell_background(page); + + await page.evaluate(async (theme: string) => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ theme }); + }, "Pro Dark"); + + const dark = await read_max_cell_background(page); + expect(dark.theme_var).not.toEqual(light.theme_var); + + expect(dark.cell).toEqual(dark.theme_var); + + await expect + .poll( + async () => + (await read_max_cell_background(page)).sidebar_pos_stop, + { timeout: 5000 }, + ) + .toEqual(dark.theme_var); + }); + test("theme change repaints cells with the new theme's colors", async ({ page, }) => { @@ -111,12 +209,6 @@ test.describe("Datagrid restyle()", () => { 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 () => { @@ -151,13 +243,28 @@ test.describe("Datagrid restyle()", () => { ) 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); + await expect + .poll(async () => { + return await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + + datagrid._persist_column_sizes(); + const viewer = document.querySelector( + "perspective-viewer", + ) as any; + + const token = await viewer.save(); + return token.columns_config?.Profit?.column_size_override; + }); + }) + .toEqual(300); // Proof the restyle actually ran (the bracket was exercised, not // skipped): the model captured the new theme. diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs index 824a2567e9..f38823cd2d 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs @@ -86,10 +86,14 @@ fn window_over_clause(w: &WindowSpec, frame: Option<&str>, order_expr: Option<&s Some(expr) => expr.to_string(), None => format!("\"{}\"", quote_ident(&order_by.0)), }; - parts.push(format!("ORDER BY {} {} NULLS FIRST", key, match order_by.1 { - WindowSortDir::Asc => "ASC", - WindowSortDir::Desc => "DESC", - })) + parts.push(format!( + "ORDER BY {} {} NULLS FIRST", + key, + match order_by.1 { + WindowSortDir::Asc => "ASC", + WindowSortDir::Desc => "DESC", + } + )) }, None => parts.push("ORDER BY rowid ASC".to_string()), } diff --git a/rust/perspective-js/test/js/pivot_nulls.spec.js b/rust/perspective-js/test/js/pivot_nulls.spec.js index 49d68f3c55..17d85c6552 100644 --- a/rust/perspective-js/test/js/pivot_nulls.spec.js +++ b/rust/perspective-js/test/js/pivot_nulls.spec.js @@ -336,7 +336,7 @@ import perspective from "./perspective_client"; test("sum does not accumulate when an indexed row flips between null and a value", async function () { const table = await perspective.table( { ticker: "string", pnl: "integer" }, - { index: "ticker" } + { index: "ticker" }, ); await table.update([ @@ -377,7 +377,7 @@ import perspective from "./perspective_client"; test("float sum does not accumulate when an indexed row flips between null and a value", async function () { const table = await perspective.table( { ticker: "string", pnl: "float" }, - { index: "ticker" } + { index: "ticker" }, ); await table.update([ @@ -418,7 +418,7 @@ import perspective from "./perspective_client"; test("sum is unchanged by a partial update which omits the column", async function () { const table = await perspective.table( { ticker: "string", pnl: "integer", qty: "integer" }, - { index: "ticker" } + { index: "ticker" }, ); await table.update([ @@ -446,7 +446,7 @@ import perspective from "./perspective_client"; test("sum is unchanged by removing a row whose value is null", async function () { const table = await perspective.table( { ticker: "string", pnl: "integer" }, - { index: "ticker" } + { index: "ticker" }, ); await table.update([ diff --git a/rust/perspective-viewer/src/css/column-settings-panel.css b/rust/perspective-viewer/src/css/column-settings-panel.css index 753f88eadd..f18ea14956 100644 --- a/rust/perspective-viewer/src/css/column-settings-panel.css +++ b/rust/perspective-viewer/src/css/column-settings-panel.css @@ -150,15 +150,15 @@ content: var(--psp-label--number-fg-mode--content, "Foreground"); } - label#pos_fg_color-label:before { + label#fg_colors-label:before { content: var(--psp-label--number-fg-mode--content, "Foreground"); } label#number_bg_mode-label:before { - content: var(--psp-label--number-fg-mode--content, "Background"); + content: var(--psp-label--number-bg-mode--content, "Background"); } - label#pos_bg_color-label:before { + label#bg_colors-label:before { content: var(--psp-label--pos-bg-color--content, "Background"); } @@ -187,8 +187,12 @@ content: var(--psp-label--series--content, "Series"); } - label#color-range-label:before { - content: var(--psp-label--color-range--content, "Color Range"); + label#palette-label:before { + content: var(--psp-label--palette--content, "Palette"); + } + + label#gradient-label:before { + content: var(--psp-label--gradient--content, "Gradient"); } label#style-label:before { diff --git a/rust/perspective-viewer/src/css/column-style.css b/rust/perspective-viewer/src/css/column-style.css index 0ae53ee328..7eb09f3362 100644 --- a/rust/perspective-viewer/src/css/column-style.css +++ b/rust/perspective-viewer/src/css/column-style.css @@ -221,34 +221,224 @@ align-items: center; flex-wrap: nowrap; flex: 1 1 100%; - .color-thermometer { + } + + .palette-selector { + .palette-swatches { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; flex: 1 1 auto; + width: 0px; + max-height: 84px; + overflow-y: auto; + overflow-x: hidden; + padding: 0px 0 4px 0; + } + + .palette-swatch { + position: relative; + width: 24px; height: 24px; + touch-action: none; + + input[type="color"] { + width: 24px; + height: 24px; + border-radius: 3px; + } + + &.dragging { + opacity: 0.5; + outline: 1px dashed var(--psp--color); + outline-offset: -1px; + border-radius: 3px; + } + + .palette-swatch-remove { + position: absolute; + top: -2px; + right: -7px; + width: 14px; + height: 14px; + cursor: pointer; + display: none; + background-color: var(--psp--color); + -webkit-mask-size: cover; + mask-size: cover; + -webkit-mask-image: var(--psp-icon--close--mask-image); + mask-image: var(--psp-icon--close--mask-image); + } + + &:hover .palette-swatch-remove { + display: inline-block; + } + } + + .palette-swatches.reordering .palette-swatch-remove { + display: none; + } + + .palette-add { + width: 22px; + height: 22px; + border: 1px dashed var(--psp-inactive--color); + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:before { + content: "+"; + font-size: 14px; + line-height: 1; + color: var(--psp-inactive--color); + } + + &:hover { + border-color: var(--psp--color); + + &:before { + color: var(--psp--color); + } + } } } - .color-selector { - display: grid; + .gradient-stops-selector { + padding: 12px 0 16px 0; - input { - grid-column: 1; - grid-row: 1; + .gradient-stops-bar { + cursor: copy; + position: relative; + flex: 1 1 auto; + height: 24px; + border-radius: 2px; } - .color-label { + + .gradient-stops-bar.discrete { + cursor: auto; + position: relative; + flex: 1 1 auto; + height: 24px; + border-radius: 2px; + } + + .gradient-stop-handle { + position: absolute; + top: 0; + transform: translateX(-50%); display: flex; - justify-content: center; + flex-direction: column; align-items: center; - font-size: 14px; - grid-column: 1; - grid-row: 1; - margin: 0; - font-family: var(--psp-button--font-family, inherit); - color: var(--sign--color, white); - width: 36px; + width: 14px; + } + + .gradient-stop-grip { + width: 14px; height: 24px; - text-align: center; - z-index: 1; - pointer-events: none; + cursor: ew-resize; + touch-action: none; + + &.disabled { + cursor: default; + } + + &:before { + content: ""; + display: block; + width: 0px; + height: 100%; + margin: 0 auto; + border: 1px solid var(--psp--background-color, #fff); + } + } + + .gradient-stop-remove { + position: absolute; + top: -14px; + width: 14px; + height: 13px; + cursor: pointer; + display: inline-block; + background-color: var(--psp--color); + -webkit-mask-size: cover; + mask-size: cover; + -webkit-mask-image: var(--psp-icon--close--mask-image); + mask-image: var(--psp-icon--close--mask-image); + + &.disabled { + cursor: default; + background-color: var(--psp-inactive--color); + } + } + + .gradient-stop-lock { + position: absolute; + top: -14px; + width: 14px; + height: 13px; + display: inline-block; + background-color: var(--psp--color); + -webkit-mask-size: cover; + mask-size: cover; + -webkit-mask-image: var(--psp-icon--lock--mask-image); + mask-image: var(--psp-icon--lock--mask-image); + } + + .gradient-stop-handle input[type="color"] { + width: 14px; + height: 14px; + margin-top: 2px; + border-radius: 3px; + } + } + + .named-value-controls { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + flex: 1 1 100%; + height: 0; + margin-top: 10px; + margin-bottom: -10px; + margin-right: 24px; + position: relative; + z-index: 1; + font-size: var(--label--font-size, 0.75em); + color: var(--psp-inactive--color); + + select.named-value-select { + height: 15px; + padding: 0; + font-size: var(--label--font-size, 0.75em); + color: inherit; + background: none; + border: none; + cursor: pointer; + + &:hover { + color: var(--psp--color, inherit); + } + } + + .named-value-pin { + cursor: pointer; + user-select: none; + line-height: 15px; + + &:before { + font-family: var(--psp-button--font-family, inherit); + color: var(--psp-inactive--color, #666); + content: var(--psp-label--pin-button--content, "Save"); + } + + &:hover:before { + color: var(--psp--color, inherit); + } } } @@ -259,23 +449,11 @@ padding: 0; font-family: inherit; overflow: hidden; - border-radius: 12px; + border-radius: 3px; &:hover { opacity: 0.8; } - - &.pos-color-param { - width: 36px; - border-radius: 12px 0 0 12px; - margin: 0 1px 0 0; - } - - &.neg-color-param { - width: 36px; - border-radius: 0 12px 12px 0; - margin: 0 0 0 1px; - } } ::-webkit-color-swatch-wrapper { diff --git a/rust/perspective-viewer/src/css/dom/select.css b/rust/perspective-viewer/src/css/dom/select.css index 4b1f577bff..77d92e8944 100644 --- a/rust/perspective-viewer/src/css/dom/select.css +++ b/rust/perspective-viewer/src/css/dom/select.css @@ -16,12 +16,13 @@ position: relative; flex: 1 1 auto; height: 100%; + font-size: 12px; &:after { content: attr(data-value) " "; visibility: hidden; white-space: nowrap; - padding-right: 17px; + padding-right: 20px; } select { diff --git a/rust/perspective-viewer/src/css/form/code-editor.css b/rust/perspective-viewer/src/css/form/code-editor.css index 4cf7b08df9..24897236bb 100644 --- a/rust/perspective-viewer/src/css/form/code-editor.css +++ b/rust/perspective-viewer/src/css/form/code-editor.css @@ -12,6 +12,10 @@ */ :host { + #editor.contained { + contain: inline-size; + } + #editor { display: flex; flex: 1 1 auto; @@ -65,10 +69,6 @@ white-space: pre; overflow: hidden; - span { - /* display: contents; */ - } - .comment { color: var(--psp-code-editor--comment--color, orange); } diff --git a/rust/perspective-viewer/src/rust/agent/tools.rs b/rust/perspective-viewer/src/rust/agent/tools.rs index 098279ace3..7d1dae3c21 100644 --- a/rust/perspective-viewer/src/rust/agent/tools.rs +++ b/rust/perspective-viewer/src/rust/agent/tools.rs @@ -630,24 +630,55 @@ fn control_schema_entries(spec: &ControlSpec) -> Vec<(String, Value)> { }, ControlSpec::Color { key, default } => vec![( key.clone(), - json!({ "type": "string", "format": "color", "default": default }), + json!({ + "type": "string", + "description": "A CSS color (`#rrggbb`, `rgb()`), or a host-defined named color `var(--psp-user--color-)`", + "default": default, + }), )], - ControlSpec::ColorRange { - key_pos, - key_neg, - default_pos, - default_neg, - .. - } => vec![ - ( - key_pos.clone(), - json!({ "type": "string", "format": "color", "default": default_pos }), - ), - ( - key_neg.clone(), - json!({ "type": "string", "format": "color", "default": default_neg }), - ), - ], + ControlSpec::Palette { key, default, max } => { + let mut description = "Ordered discrete color palette, cycled over the column's \ + categories/series: a CSS `linear-gradient(to right, #rrggbb, \ + #rrggbb, …)` of N colors WITHOUT positions, or a host-defined \ + named palette `var(--psp-user--palette-)`" + .to_owned(); + if let Some(max) = max { + description.push_str(&format!(" (at most {max} colors)")); + } + + vec![( + key.clone(), + json!({ + "type": "string", + "description": description, + "default": default, + }), + )] + }, + ControlSpec::GradientStops { + key, + default, + discrete, + } => { + let mut description = "Multi-stop color gradient: a CSS `linear-gradient(to right, \ + #rrggbb P%, …)` with positioned stops (direction normalized to \ + `to right`; stops[0] maps to the most negative value), or a \ + host-defined named gradient `var(--psp-user--gradient-)`" + .to_owned(); + + if *discrete { + description.push_str(" — exactly 2 stops (the negative and positive colors)"); + } + + vec![( + key.clone(), + json!({ + "type": "string", + "description": description, + "default": default, + }), + )] + }, ControlSpec::DatetimeFormat => vec![( "date_format".to_owned(), json!({ "description": "Datetime display format: a style preset or custom format fields" }), diff --git a/rust/perspective-viewer/src/rust/components/column_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector.rs index 3655cd0fce..93fa7b39b7 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector.rs @@ -29,7 +29,7 @@ use std::rc::Rc; pub use column_selector_column_row::*; pub use empty_column::*; pub use invalid_column::*; -use perspective_client::config::ViewConfig; +use perspective_client::config::{ViewConfig, ViewConfigUpdate}; pub use pivot_column::*; use web_sys::*; use yew::prelude::*; @@ -43,8 +43,10 @@ use super::containers::split_panel::{Orientation, SplitPanel}; use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal}; use crate::components::containers::scroll_panel_item::ScrollPanelItem; use crate::config::PluginStaticConfig; -use crate::presentation::{ColumnLocator, DragDropContainer, Presentation}; -use crate::queries::{ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet}; +use crate::presentation::{ColumnLocator, ColumnSettingsTarget, DragDropContainer, Presentation}; +use crate::queries::{ + ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet, get_current_column_locator, +}; use crate::renderer::*; use crate::session::drag_drop_update::*; use crate::session::*; @@ -54,7 +56,7 @@ use crate::utils::*; #[derive(Properties)] pub struct ColumnSelectorProps { /// Fires when the expression/config column is open. - pub on_open_expr_panel: Callback, + pub on_open_expr_panel: Callback, /// This is passed to the add_expression_button for styling. pub selected_column: Option, @@ -137,6 +139,32 @@ pub struct ColumnSelector { on_reset: Rc>, } +fn close_column_settings_if_displaced( + presentation: &Presentation, + renderer: &Renderer, + metadata: &SessionMetadata, + view_config: &ViewConfig, + update: &ViewConfigUpdate, +) { + let Some(columns) = &update.columns else { + return; + }; + + let ocs = presentation.get_open_column_settings(); + if get_current_column_locator(&ocs, renderer, view_config, metadata).is_none() { + return; + } + + let next_config = ViewConfig { + columns: columns.clone(), + ..view_config.clone() + }; + + if get_current_column_locator(&ocs, renderer, &next_config, metadata).is_none() { + presentation.set_open_column_settings(None); + } +} + impl Component for ColumnSelector { type Message = ColumnSelectorMsg; type Properties = ColumnSelectorProps; @@ -224,6 +252,14 @@ impl Component for ColumnSelector { ctx.props().metadata.get_features().unwrap(), ); + close_column_settings_if_displaced( + &ctx.props().presentation, + &ctx.props().renderer, + &ctx.props().metadata, + &ctx.props().view_config, + &update, + ); + let session = ctx.props().session.clone(); let renderer = ctx.props().renderer.clone(); if let Ok(task) = apply_and_render(&session, &renderer, update) { @@ -249,6 +285,14 @@ impl Component for ColumnSelector { ctx.props().metadata.get_features().unwrap(), ); + close_column_settings_if_displaced( + &ctx.props().presentation, + &ctx.props().renderer, + &ctx.props().metadata, + &ctx.props().view_config, + &update, + ); + let session = ctx.props().session.clone(); let renderer = ctx.props().renderer.clone(); if let Ok(task) = apply_and_render(&session, &renderer, update) { diff --git a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs index 0e129adc0e..5c800d65a6 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs @@ -23,7 +23,7 @@ use super::expr_edit_button::*; use crate::components::column_dropdown::ColumnDropDownElement; use crate::components::column_selector::{EmptyColumn, InvalidColumn}; use crate::config::ColumnSelectMode; -use crate::presentation::{ColumnLocator, Presentation}; +use crate::presentation::{ColumnSettingsTarget, Presentation}; use crate::queries::*; use crate::renderer::*; use crate::session::*; @@ -51,7 +51,7 @@ pub struct ActiveColumnProps { pub onselect: Callback<()>, /// Fires when this component's expression/config button is clicked. - pub on_open_expr_panel: Callback, + pub on_open_expr_panel: Callback, /// Is this column in a grouped context (does the aggregate selector /// need to be visible)? diff --git a/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs b/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs index 08bc7b25b4..a5bcf056c5 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs @@ -12,12 +12,12 @@ use yew::prelude::*; -use super::ColumnLocator; +use super::{ColumnLocator, ColumnSettingsTarget}; #[derive(Clone, PartialEq, Properties)] pub struct AddExpressionButtonProps { // Fires when this button is clicked. - pub on_open_expr_panel: Callback, + pub on_open_expr_panel: Callback, /// The column this butotn will open configuration for. pub selected_column: Option, @@ -47,7 +47,7 @@ pub fn AddExpressionButton(p: &AddExpressionButtonProps) -> Html { let onmousedown = p .on_open_expr_panel - .reform(|_| ColumnLocator::NewExpression); + .reform(|_| ColumnSettingsTarget::NewExpression); let class = if *is_mouseover || matches!(p.selected_column, Some(ColumnLocator::NewExpression)) { diff --git a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs index abdd59ada8..1824e08f44 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs @@ -279,6 +279,14 @@ impl Component for ConfigSelector { ctx.props().metadata.get_features().unwrap(), ); + super::close_column_settings_if_displaced( + &ctx.props().presentation, + &ctx.props().renderer, + &ctx.props().metadata, + &ctx.props().view_config, + &update, + ); + { let session = ctx.props().session.clone(); let renderer = ctx.props().renderer.clone(); diff --git a/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs b/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs index 0fe2f53490..a15c818238 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs @@ -12,7 +12,7 @@ use yew::prelude::*; -use super::ColumnLocator; +use super::ColumnSettingsTarget; #[derive(PartialEq, Clone, Properties)] pub struct ExprEditButtonProps { @@ -27,7 +27,7 @@ pub struct ExprEditButtonProps { pub is_window: bool, /// Fires when the config/expresison button is clicked. - pub on_open_expr_panel: Callback, + pub on_open_expr_panel: Callback, /// Is the expression/config panel open? pub is_editing: bool, @@ -42,14 +42,8 @@ pub struct ExprEditButtonProps { #[function_component] pub fn ExprEditButton(p: &ExprEditButtonProps) -> Html { let onmousedown = yew::use_callback(p.clone(), |_, p| { - let name = if p.is_window { - ColumnLocator::Window(p.name.clone()) - } else if p.is_expression { - ColumnLocator::Expression(p.name.clone()) - } else { - ColumnLocator::Table(p.name.clone()) - }; - p.on_open_expr_panel.emit(name) + p.on_open_expr_panel + .emit(ColumnSettingsTarget::Column(p.name.clone())) }); let class = if p.is_disabled { diff --git a/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs index 643098ad30..e27cb572b3 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs @@ -18,7 +18,7 @@ use yew::prelude::*; use super::expr_edit_button::*; use crate::components::type_icon::TypeIcon; use crate::config::ColumnSelectMode; -use crate::presentation::{ColumnLocator, Presentation}; +use crate::presentation::{ColumnSettingsTarget, Presentation}; use crate::renderer::*; use crate::session::*; use crate::tasks::apply_and_render; @@ -61,7 +61,7 @@ pub struct InactiveColumnProps { pub onselect: Callback<()>, /// Fires when this column's expression/config button is clicked. - pub on_open_expr_panel: Callback, + pub on_open_expr_panel: Callback, // State pub presentation: Presentation, diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs index 3f46f29969..40133f16eb 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs @@ -42,6 +42,7 @@ use crate::tasks::{ delete_expr, delete_window, save_expr, save_window, update_expr, update_window, }; use crate::utils::PtrEqRc; +use crate::workspace::Workspace; #[derive(Clone, Derivative, Properties)] #[derivative(Debug)] @@ -91,12 +92,13 @@ pub struct ColumnSettingsPanelProps { #[derivative(Debug = "ignore")] pub session: Session, + + #[derivative(Debug = "ignore")] + pub workspace: Workspace, } -impl ColumnSettingsPanelProps { - /// Everything EXCEPT the trap-door `auto_width`: the props whose change - /// invalidates the drafts that `initialize` rebuilds. - fn identity_eq(&self, other: &Self) -> bool { +impl PartialEq for ColumnSettingsPanelProps { + fn eq(&self, other: &Self) -> bool { self.selected_column == other.selected_column && self.selected_tab == other.selected_tab && self.plugin_name == other.plugin_name @@ -104,14 +106,46 @@ impl ColumnSettingsPanelProps { && self.view_config == other.view_config && self.column_stats == other.column_stats && self.selected_theme == other.selected_theme + && self.auto_width == other.auto_width + && self.is_pinned == other.is_pinned } } -impl PartialEq for ColumnSettingsPanelProps { - fn eq(&self, other: &Self) -> bool { - self.identity_eq(other) - && self.auto_width == other.auto_width - && self.is_pinned == other.is_pinned +#[derive(Clone, PartialEq)] +struct Initials { + column_name: String, + expr: Rc, + header: Option, + window: Option, +} + +impl Initials { + fn of(props: &ColumnSettingsPanelProps) -> Self { + let column_name = props + .metadata + .locator_name_or_default(&props.selected_column); + + let expr = props + .metadata + .get_expression_by_alias(&column_name) + .or_else(|| props.view_config.expressions.get(&column_name).cloned()) + .unwrap_or_default(); + + let expr = Rc::new(expr); + let header = (*expr != column_name).then_some(column_name.clone()); + + let window = props + .selected_column + .name() + .and_then(|name| props.view_config.windows.get(name)) + .cloned(); + + Self { + column_name, + expr, + header, + window, + } } } @@ -172,22 +206,23 @@ impl Component for ColumnSettingsPanel { initial_window_value: None, window_value: None, tabs: vec![], - on_input: Callback::default(), - on_save: Callback::default(), - on_validate: Callback::default(), + on_input: ctx.link().callback(ColumnSettingsPanelMsg::SetExprValue), + on_save: ctx + .link() + .callback(ColumnSettingsPanelMsg::OnSaveAttributes), + on_validate: ctx.link().callback(ColumnSettingsPanelMsg::SetExprValid), }; - this.initialize(ctx); + this.reset_to(ctx, Initials::of(ctx.props())); this } fn changed(&mut self, ctx: &yew::prelude::Context, old_props: &Self::Properties) -> bool { - // Only reached when props are unequal. Re-`initialize` (which wipes - // in-progress expression/window drafts) only on IDENTITY changes - - // a trap-door `auto_width` change re-renders the `Sidebar` sizer - // alone. - if !ctx.props().identity_eq(old_props) { - self.initialize(ctx); + let next = Initials::of(ctx.props()); + if ctx.props().selected_column != old_props.selected_column || next != self.initials() { + self.reset_to(ctx, next); + } else { + self.refresh_derived(ctx); } true @@ -353,9 +388,9 @@ impl Component for ColumnSettingsPanel { }; let header_props = props!(EditableHeaderProps { + value: self.header_value.clone(), initial_value: self.initial_header_value.clone(), placeholder: header_placeholder, - reset_count: self.reset_count, editable: (ctx.props().selected_column.is_expr() && matches!( ctx.props().selected_tab, @@ -374,7 +409,6 @@ impl Component for ColumnSettingsPanel { ] }), metadata: ctx.props().metadata.clone(), - session: &ctx.props().session }); let expr_editor = props!(ExpressionEditorProps { @@ -382,6 +416,7 @@ impl Component for ColumnSettingsPanel { on_save: self.on_save.clone(), on_validate: self.on_validate.clone(), alias: ctx.props().selected_column.name().cloned(), + initial_expr: self.initial_expr_value.clone(), disabled: !ctx.props().selected_column.is_expr(), reset_count: self.reset_count, metadata: ctx.props().metadata.clone(), @@ -457,6 +492,7 @@ impl Component for ColumnSettingsPanel { presentation: ctx.props().presentation.clone(), renderer: ctx.props().renderer.clone(), session: ctx.props().session.clone(), + workspace: ctx.props().workspace.clone(), }; let tab_children = self.tabs.iter().map(|tab| match tab { @@ -506,38 +542,22 @@ impl ColumnSettingsPanel { self.save_enabled = changed && valid; } - fn initialize(&mut self, ctx: &yew::prelude::Context) { - let column_name = ctx - .props() - .metadata - .locator_name_or_default(&ctx.props().selected_column); - - let initial_expr_value = ctx - .props() - .metadata - .get_expression_by_alias(&column_name) - .unwrap_or_default(); - - let initial_expr_value = Rc::new(initial_expr_value); - let initial_header_value = - (*initial_expr_value != column_name).then_some(column_name.clone()); + fn initials(&self) -> Initials { + Initials { + column_name: self.column_name.clone(), + expr: self.initial_expr_value.clone(), + header: self.initial_header_value.clone(), + window: self.initial_window_value.clone(), + } + } - let maybe_ty = ctx + fn refresh_derived(&mut self, ctx: &yew::prelude::Context) { + self.maybe_ty = ctx .props() .metadata .locator_view_type(&ctx.props().selected_column); - // Specs are unnamed (the `windows` map key names them, and the - // editable header owns naming in the UI), so drafts and saved specs - // compare directly. - let initial_window_value = ctx - .props() - .selected_column - .name() - .and_then(|name| ctx.props().view_config.windows.get(name)) - .cloned(); - - let tabs = { + self.tabs = { let mut tabs = vec![]; let is_new_expr = ctx.props().selected_column.is_new_expr(); let show_styles = !is_new_expr @@ -571,28 +591,27 @@ impl ColumnSettingsPanel { tabs }; + } - let on_input = ctx.link().callback(ColumnSettingsPanelMsg::SetExprValue); - let on_save = ctx - .link() - .callback(ColumnSettingsPanelMsg::OnSaveAttributes); - - let on_validate = ctx.link().callback(ColumnSettingsPanelMsg::SetExprValid); - *self = Self { + fn reset_to(&mut self, ctx: &yew::prelude::Context, initials: Initials) { + let Initials { column_name, - expr_value: initial_expr_value.clone(), - initial_expr_value, - header_value: initial_header_value.clone(), - initial_header_value, - maybe_ty, - window_value: initial_window_value.clone(), - initial_window_value, - tabs, - header_valid: true, - on_input, - on_save, - on_validate, - ..*self - } + expr, + header, + window, + } = initials; + + self.column_name = column_name; + self.expr_value = expr.clone(); + self.initial_expr_value = expr; + self.header_value = header.clone(); + self.initial_header_value = header; + self.window_value = window.clone(); + self.initial_window_value = window; + self.header_valid = true; + self.save_enabled = false; + self.reset_enabled = false; + self.reset_count = self.reset_count.wrapping_add(1); + self.refresh_derived(ctx); } } diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs index f3f0b4d577..ddc296173c 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs @@ -15,6 +15,7 @@ pub(crate) mod primitive_field; mod symbol; use std::collections::HashMap; +use std::rc::Rc; use itertools::Itertools; use perspective_client::config::ColumnType; @@ -22,7 +23,7 @@ use yew::{Html, Properties, function_component, html}; use self::agg_depth_selector::*; use self::primitive_field::{ - BoolField, ColorField, ColorRangeField, EnumField, NumberFieldPrimitive, + BoolField, ColorField, EnumField, GradientStopsField, NumberFieldPrimitive, PaletteField, }; use crate::components::column_settings_sidebar::style_tab::symbol::SymbolStyle; use crate::components::datetime_column_style::DatetimeColumnStyle; @@ -30,15 +31,16 @@ use crate::components::number_series_style::NumberSeriesStyle; use crate::components::string_column_style::StringColumnStyle; use crate::components::style_controls::CustomNumberFormat; use crate::config::{ - ControlSpec, CustomNumberFormatConfig, DatetimeColumnStyleConfig, NumberSeriesStyleConfig, - StringColumnStyleConfig, + ControlSpec, CssKind, CustomNumberFormatConfig, DatetimeColumnStyleConfig, + NumberSeriesStyleConfig, StringColumnStyleConfig, }; use crate::presentation::Presentation; -use crate::queries::{fetch_column_abs_max, get_column_config_schema}; +use crate::queries::{fetch_column_abs_max, get_column_config_schema, named_values}; use crate::renderer::Renderer; use crate::session::Session; use crate::tasks::send_column_config; use crate::utils::PtrEqRc; +use crate::workspace::Workspace; #[derive(Clone, PartialEq, Properties)] pub struct StyleTabProps { @@ -62,6 +64,7 @@ pub struct StyleTabProps { pub presentation: Presentation, pub renderer: Renderer, pub session: Session, + pub workspace: Workspace, } #[function_component] @@ -93,6 +96,33 @@ pub fn StyleTab(props: &StyleTabProps) -> Html { }); let raw_config = props.renderer.get_columns_config(&props.column_name); + + let named_for = |kind| Rc::new(named_values(&props.workspace, &props.presentation, kind)); + let named_gradients = named_for(CssKind::Gradient); + let named_palettes = named_for(CssKind::Palette); + let named_colors = named_for(CssKind::Color); + + let restored = Rc::new(props.presentation.palette()); + let on_pin = { + let presentation = props.presentation.clone(); + let revision = revision.clone(); + yew::Callback::from(move |(kind, literal): (CssKind, String)| { + if let Err(error) = presentation.pin_style(kind, &literal) { + tracing::warn!("Failed to pin named {kind:?}: {error:?}"); + } + + revision.set(*revision + 1); + }) + }; + let schema = get_column_config_schema( + &props.renderer, + &props.view_config, + &props.metadata, + &props.column_name, + raw_config.as_ref(), + abs_max, + ); + let on_change = { let state = props.clone(); let column_name = props.column_name.clone(); @@ -110,231 +140,238 @@ pub fn StyleTab(props: &StyleTabProps) -> Html { .and_then(|m| serde_json::from_value::(serde_json::Value::Object(m.clone())).ok()) } - let components = get_column_config_schema( - &props.renderer, - &props.view_config, - &props.metadata, - &props.column_name, - raw_config.as_ref(), - abs_max, - ) - .map(|schema| { - schema - .fields - .into_iter() - .filter_map(|spec| { - let keys: Vec = spec - .serialized_keys() - .into_iter() - .map(|s| s.to_string()) - .collect(); - let component = match spec { - ControlSpec::AggregateDepth => { - let aggregate_depth = raw_config - .as_ref() - .and_then(|m| m.get("aggregate_depth")) - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; - html! { - - } - }, - ControlSpec::NumberSeriesStyle { - default: default_config, - } => { - let config: Option = deser_sub(&raw_config); - html! { - - } - }, - ControlSpec::DatetimeFormat => { - let config: Option = deser_sub(&raw_config); - let enable_time_config = props.ty.unwrap() == ColumnType::Datetime; - html! { - - } - }, - ControlSpec::StringFormat => { - let config: Option = deser_sub(&raw_config); - html! { - - } - }, - ControlSpec::Symbols { - default: default_config, - } => { - let restored_config: HashMap = raw_config - .as_ref() - .and_then(|m| m.get("symbols")) - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let components = schema + .map(|schema| { + schema + .fields + .into_iter() + .filter_map(|spec| { + let keys: Vec = spec + .serialized_keys() + .into_iter() + .map(|s| s.to_string()) + .collect(); + let component = match spec { + ControlSpec::AggregateDepth => { + let aggregate_depth = raw_config + .as_ref() + .and_then(|m| m.get("aggregate_depth")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) + as u32; + html! { + + } + }, + ControlSpec::NumberSeriesStyle { + default: default_config, + } => { + let config: Option = deser_sub(&raw_config); + html! { + + } + }, + ControlSpec::DatetimeFormat => { + let config: Option = deser_sub(&raw_config); + let enable_time_config = props.ty.unwrap() == ColumnType::Datetime; + html! { + + } + }, + ControlSpec::StringFormat => { + let config: Option = deser_sub(&raw_config); + html! { + + } + }, + ControlSpec::Symbols { + default: default_config, + } => { + let restored_config: HashMap = raw_config + .as_ref() + .and_then(|m| m.get("symbols")) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); - html! { - - } - }, - ControlSpec::NumberFormat => { - let restored_config: CustomNumberFormatConfig = raw_config - .as_ref() - .and_then(|m| m.get("number_format")) - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + html! { + + } + }, + ControlSpec::NumberFormat => { + let restored_config: CustomNumberFormatConfig = raw_config + .as_ref() + .and_then(|m| m.get("number_format")) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); - html! { - - } - }, - ControlSpec::Enum { - key, - variants, - default, - } => { - let current = raw_config - .as_ref() - .and_then(|m| m.get(&key)) - .and_then(|v| v.as_str().map(|s| s.to_string())); + html! { + + } + }, + ControlSpec::Enum { + key, + variants, + default, + } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_str().map(|s| s.to_string())); - html! { - - } - }, - ControlSpec::Bool { key, default } => { - let current = raw_config - .as_ref() - .and_then(|m| m.get(&key)) - .and_then(|v| v.as_bool()); - html! { - - } - }, - ControlSpec::Color { key, default } => { - let current = raw_config - .as_ref() - .and_then(|m| m.get(&key)) - .and_then(|v| v.as_str().map(|s| s.to_string())); - html! { - - } - }, - ControlSpec::ColorRange { - key_pos, - key_neg, - default_pos, - default_neg, - is_gradient, - } => { - let current_pos = raw_config - .as_ref() - .and_then(|m| m.get(&key_pos)) - .and_then(|v| v.as_str().map(|s| s.to_string())); - let current_neg = raw_config - .as_ref() - .and_then(|m| m.get(&key_neg)) - .and_then(|v| v.as_str().map(|s| s.to_string())); - html! { - - } - }, - ControlSpec::Number { - key, - default, - min, - max, - step, - include, - } => { - let current = raw_config - .as_ref() - .and_then(|m| m.get(&key)) - .and_then(|v| v.as_f64()); - html! { - - } - }, - // String primitive has no caller yet — wire when a - // plugin emits one. - ControlSpec::String { .. } => { - return None; - }, - }; + html! { + + } + }, + ControlSpec::Bool { key, default } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_bool()); + html! { + + } + }, + ControlSpec::Color { key, default } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_str().map(|s| s.to_string())); + html! { + + } + }, + ControlSpec::Palette { key, default, max } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_str().map(|s| s.to_string())); + html! { + + } + }, + ControlSpec::GradientStops { + key, + default, + discrete, + } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_str().map(|s| s.to_string())); + html! { + + } + }, + ControlSpec::Number { + key, + default, + min, + max, + step, + include, + } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_f64()); + html! { + + } + }, + ControlSpec::String { .. } => { + return None; + }, + }; - let key = format!("{}::{}", props.column_name, keys.join("+")); - Some(html! {
{ component }
}) - }) - .collect_vec() - }) - .unwrap_or_else(|error| { - tracing::error!("{}", error); - vec![] - }); + let key = format!("{}::{}", props.column_name, keys.join("+")); + Some(html! {
{ component }
}) + }) + .collect_vec() + }) + .unwrap_or_else(|error| { + tracing::error!("{}", error); + vec![] + }); html! {
diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs index 2634b50ab7..c7628b9972 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs @@ -17,20 +17,27 @@ //! [`ColorSelector`]) so that they visually match the rich Yew widgets in //! the same sidebar. +use std::collections::BTreeMap; use std::rc::Rc; use itertools::Itertools; use serde_json::Value; use wasm_bindgen::JsCast; use web_sys::{HtmlInputElement, MouseEvent}; -use yew::{Callback, Html, Properties, classes, function_component, html, use_callback}; +use yew::{Callback, Html, Properties, function_component, html, use_callback}; use crate::components::containers::select::{Select, SelectItem}; -use crate::components::form::color_range_selector::ColorRangeSelector; use crate::components::form::color_selector::ColorSelector; +use crate::components::form::multi_stop_gradient_selector::MultiStopGradientSelector; +use crate::components::form::named_value_picker::NamedValuePicker; use crate::components::form::number_field::NumberField; use crate::components::form::optional_field::OptionalField; -use crate::config::{ColumnConfigFieldUpdate, EnumVariant}; +use crate::components::form::palette_selector::PaletteSelector; +use crate::config::{ + ColumnConfigFieldUpdate, CssColor, CssGradient, CssKind, CssPalette, EnumVariant, + GradientStopSpec, NamedValue, canonicalize_css_color, discrete_pair, gradient_to_css, + palette_name_for, +}; fn emit(on_change: &Callback, key: &str, value: Option) { let mut map = serde_json::Map::new(); @@ -44,28 +51,6 @@ fn emit(on_change: &Callback, key: &str, value: Option< }); } -fn emit_color_range( - on_change: &Callback, - key_pos: &str, - key_neg: &str, - default_pos: &str, - default_neg: &str, - new_pos: &str, - new_neg: &str, -) { - let mut value = serde_json::Map::new(); - if new_pos != default_pos { - value.insert(key_pos.to_owned(), Value::String(new_pos.to_owned())); - } - if new_neg != default_neg { - value.insert(key_neg.to_owned(), Value::String(new_neg.to_owned())); - } - on_change.emit(ColumnConfigFieldUpdate { - keys: vec![key_pos.to_owned(), key_neg.to_owned()], - value, - }); -} - #[derive(Properties, PartialEq)] pub struct EnumFieldProps { pub field_key: String, @@ -239,106 +224,242 @@ pub fn NumberFieldPrimitive(props: &NumberFieldPrimitiveProps) -> Html { } } +#[allow(clippy::too_many_arguments)] +fn css_field_controls( + kind: CssKind, + named: &Rc>, + restored: &BTreeMap, + current: &str, + is_modified: bool, + on_select: Callback, + on_pin: Callback<()>, +) -> Html { + let can_pin = is_modified && palette_name_for(restored, kind, current).is_none(); + if named.is_empty() && !can_pin { + return html! {}; + } + + html! { } +} + +fn named_literal(named: &[NamedValue], name: &str) -> Option { + named + .iter() + .find(|entry| entry.name == name) + .map(|entry| entry.value.clone()) +} + #[derive(Properties, PartialEq)] -pub struct ColorRangeFieldProps { - pub field_key_pos: String, - pub field_key_neg: String, - pub default_pos: String, - pub default_neg: String, - pub current_pos: Option, - pub current_neg: Option, - pub is_gradient: bool, +pub struct PaletteFieldProps { + pub field_key: String, + /// Canonical palette string. + pub default: String, + pub max: Option, + /// Canonical palette string. + pub current: Option, pub on_change: Callback, + + /// Named palettes (workspace set ∪ theme), for the loader. + #[prop_or_default] + pub named: Rc>, + + /// The restored palette — the set Pin adds to. + #[prop_or_default] + pub restored: Rc>, + + /// Pin the field's `(kind, literal)` into the restored palette. + #[prop_or_default] + pub on_pin: Callback<(CssKind, String)>, +} + +fn palette_colors(src: &str, default: &str) -> Vec { + let literal = |css: &str| match CssPalette::parse(css) { + Ok(CssPalette::Literal(colors)) => Some(colors), + _ => None, + }; + + literal(src) + .or_else(|| literal(default)) + .unwrap_or_default() } #[function_component] -pub fn ColorRangeField(props: &ColorRangeFieldProps) -> Html { - let pos = props - .current_pos - .clone() - .unwrap_or_else(|| props.default_pos.clone()); - let neg = props - .current_neg - .clone() - .unwrap_or_else(|| props.default_neg.clone()); - let is_modified = (props.current_pos.is_some() - && props.current_pos.as_deref() != Some(props.default_pos.as_str())) - || (props.current_neg.is_some() - && props.current_neg.as_deref() != Some(props.default_neg.as_str())); - - // Multi-key emit: write whichever side(s) differ from default, - // clear the others. Mirrors the apply semantics of - // `ColumnConfigFieldUpdate { keys, value }` with both keys owned. - let on_pos_color = use_callback( - ( - props.field_key_pos.clone(), - props.field_key_neg.clone(), - props.default_pos.clone(), - props.default_neg.clone(), - props.on_change.clone(), - neg.clone(), - ), - |new_pos: String, (key_pos, key_neg, default_pos, default_neg, on_change, neg)| { - emit_color_range( - on_change, - key_pos, - key_neg, - default_pos, - default_neg, - &new_pos, - neg, - ); - }, - ); +pub fn PaletteField(props: &PaletteFieldProps) -> Html { + let current = props.current.as_deref().unwrap_or(&props.default); + let values = palette_colors(current, &props.default); + let is_modified = props.current.is_some() && props.current.as_ref() != Some(&props.default); + let emit_css = { + let key = props.field_key.clone(); + let default = props.default.clone(); + let on_change = props.on_change.clone(); + move |css: String| { + if css == default { + emit(&on_change, &key, None); + } else { + emit(&on_change, &key, Some(Value::String(css))); + } + } + }; + + let on_change_palette = { + let emit_css = emit_css.clone(); + Callback::from(move |values: Vec| { + emit_css(CssPalette::Literal(values).to_css()); + }) + }; + + let on_select = { + let emit_css = emit_css.clone(); + let named = props.named.clone(); + Callback::from(move |name: String| { + if let Some(literal) = named_literal(&named, &name) { + emit_css(literal); + } + }) + }; - let on_neg_color = use_callback( - ( - props.field_key_pos.clone(), - props.field_key_neg.clone(), - props.default_pos.clone(), - props.default_neg.clone(), - props.on_change.clone(), - pos.clone(), - ), - |new_neg: String, (key_pos, key_neg, default_pos, default_neg, on_change, pos)| { - emit_color_range( - on_change, - key_pos, - key_neg, - default_pos, - default_neg, - pos, - &new_neg, - ); - }, + let on_reset = use_callback( + (props.field_key.clone(), props.on_change.clone()), + |_: (), (key, on_change)| emit(on_change, key, None), ); + let on_pin = { + let on_pin = props.on_pin.clone(); + let literal = current.to_owned(); + Callback::from(move |()| on_pin.emit((CssKind::Palette, literal.clone()))) + }; + + html! { +
+ { css_field_controls( + CssKind::Palette, + &props.named, + &props.restored, + current, + is_modified, + on_select, + on_pin, + ) } + +
+ } +} + +#[derive(Properties, PartialEq)] +pub struct GradientStopsFieldProps { + pub field_key: String, + /// Canonical gradient string. + pub default: String, + pub discrete: bool, + /// Canonical gradient string. + pub current: Option, + pub on_change: Callback, + + /// Named gradients (workspace set ∪ theme), for the loader. + #[prop_or_default] + pub named: Rc>, + + /// The restored palette — the set Pin adds to. + #[prop_or_default] + pub restored: Rc>, + + /// Pin the field's `(kind, literal)` into the restored palette. + #[prop_or_default] + pub on_pin: Callback<(CssKind, String)>, +} + +fn gradient_stops(src: &str, default: &str) -> Vec { + let literal = |css: &str| match CssGradient::parse(css) { + Ok(CssGradient::Literal(stops)) => Some(stops), + _ => None, + }; + + literal(src) + .or_else(|| literal(default)) + .unwrap_or_default() +} + +#[function_component] +pub fn GradientStopsField(props: &GradientStopsFieldProps) -> Html { + let current = props.current.as_deref().unwrap_or(&props.default); + let stops = gradient_stops(current, &props.default); + let is_modified = props.current.is_some() && props.current.as_ref() != Some(&props.default); + let emit_css = { + let key = props.field_key.clone(); + let default = props.default.clone(); + let on_change = props.on_change.clone(); + move |css: String| { + if css == default { + emit(&on_change, &key, None); + } else { + emit(&on_change, &key, Some(Value::String(css))); + } + } + }; + + let on_change_stops = { + let emit_css = emit_css.clone(); + Callback::from(move |stops: Vec| { + emit_css(gradient_to_css(&stops)); + }) + }; + + let on_select = { + let emit_css = emit_css.clone(); + let named = props.named.clone(); + let discrete = props.discrete; + Callback::from(move |name: String| { + let Some(literal) = named_literal(&named, &name) else { + return; + }; + + let literal = match CssGradient::parse(&literal) { + Ok(CssGradient::Literal(stops)) if discrete && stops.len() > 2 => { + gradient_to_css(&discrete_pair(stops)) + }, + _ => literal, + }; + + emit_css(literal); + }) + }; + let on_reset = use_callback( - ( - props.field_key_pos.clone(), - props.field_key_neg.clone(), - props.on_change.clone(), - ), - |_: (), (key_pos, key_neg, on_change)| { - on_change.emit(ColumnConfigFieldUpdate { - keys: vec![key_pos.clone(), key_neg.clone()], - value: serde_json::Map::new(), - }); - }, + (props.field_key.clone(), props.on_change.clone()), + |_: (), (key, on_change)| emit(on_change, key, None), ); + let on_pin = { + let on_pin = props.on_pin.clone(); + let literal = current.to_owned(); + Callback::from(move |()| on_pin.emit((CssKind::Gradient, literal.clone()))) + }; + html! {
-
} @@ -347,42 +468,88 @@ pub fn ColorRangeField(props: &ColorRangeFieldProps) -> Html { #[derive(Properties, PartialEq)] pub struct ColorFieldProps { pub field_key: String, + /// Canonical `#rrggbb`. pub default: String, + /// Canonical `#rrggbb`. pub current: Option, pub on_change: Callback, + + /// Named colors (workspace set ∪ theme), for the loader. + #[prop_or_default] + pub named: Rc>, + + /// The restored palette — the set Pin adds to. + #[prop_or_default] + pub restored: Rc>, + + /// Pin the field's `(kind, literal)` into the restored palette. + #[prop_or_default] + pub on_pin: Callback<(CssKind, String)>, } #[function_component] pub fn ColorField(props: &ColorFieldProps) -> Html { - let color = props - .current - .clone() - .unwrap_or_else(|| props.default.clone()); + let current = props.current.as_deref().unwrap_or(&props.default); + let color = match CssColor::parse(current) { + Ok(CssColor::Literal(color)) => color, + _ => props.default.clone(), + }; + let is_modified = props.current.as_deref() != Some(props.default.as_str()) && props.current.is_some(); - let on_color = use_callback( - ( - props.field_key.clone(), - props.default.clone(), - props.on_change.clone(), - ), - |value: String, (key, default, on_change)| { - if value == *default { - emit(on_change, key, None); + let emit_css = { + let key = props.field_key.clone(); + let default = props.default.clone(); + let on_change = props.on_change.clone(); + move |css: String| { + if css == default { + emit(&on_change, &key, None); } else { - emit(on_change, key, Some(Value::String(value))); + emit(&on_change, &key, Some(Value::String(css))); } - }, - ); + } + }; + + let on_color = { + let emit_css = emit_css.clone(); + Callback::from(move |value: String| { + emit_css(canonicalize_css_color(&value).unwrap_or(value)); + }) + }; + + let on_select = { + let emit_css = emit_css.clone(); + let named = props.named.clone(); + Callback::from(move |name: String| { + if let Some(literal) = named_literal(&named, &name) { + emit_css(literal); + } + }) + }; let on_reset = use_callback( (props.field_key.clone(), props.on_change.clone()), |_: (), (key, on_change)| emit(on_change, key, None), ); + let on_pin = { + let on_pin = props.on_pin.clone(); + let literal = current.to_owned(); + Callback::from(move |()| on_pin.emit((CssKind::Color, literal.clone()))) + }; + html! {
+ { css_field_controls( + CssKind::Color, + &props.named, + &props.restored, + current, + is_modified, + on_select, + on_pin, + ) } , pub on_change: Callback<(Option, bool)>, pub editable: bool, + + /// The owner's draft. + pub value: Option, + + /// The saved name the draft is measured against. pub initial_value: Option, pub placeholder: Rc, - // TODO remove this pattern - #[prop_or_default] - pub reset_count: u8, - #[prop_or_default] pub update_on_input: bool, /// Session metadata snapshot — threaded from `SessionProps`. pub metadata: SessionMetadataRc, - - // State - pub session: Session, } impl EditableHeaderProps { @@ -55,152 +53,95 @@ impl EditableHeaderProps { Some((idx, _)) => split[..idx].to_owned(), } } -} - -pub enum EditableHeaderMsg { - SetNewValue(String), - OnClick(()), -} -#[derive(Debug)] -pub struct EditableHeader { - noderef: NodeRef, - edited: bool, - valid: bool, - value: Option, - placeholder: String, -} + fn is_valid(&self, value: &Option, placeholder: &str) -> bool { + let Some(value) = value else { + return true; + }; -impl Component for EditableHeader { - type Message = EditableHeaderMsg; - type Properties = EditableHeaderProps; - - fn create(ctx: &yew::prelude::Context) -> Self { - Self { - value: ctx.props().initial_value.clone(), - placeholder: ctx.props().split_placeholder(), - valid: true, - noderef: NodeRef::default(), - edited: false, + if value == placeholder || Some(value) == self.initial_value.as_ref() { + return true; } - } - fn changed(&mut self, ctx: &yew::prelude::Context, old_props: &Self::Properties) -> bool { - if ctx.props().reset_count != old_props.reset_count { - self.value.clone_from(&ctx.props().initial_value); - } - if ctx.props().initial_value != old_props.initial_value { - self.edited = false; - self.value.clone_from(&ctx.props().initial_value); - } - if !ctx.props().editable { - self.edited = false; - } - self.placeholder = ctx.props().split_placeholder(); - ctx.props() != old_props - } + let metadata = &self.metadata; + let Some(table_columns) = metadata.get_table_columns() else { + return true; + }; - fn update(&mut self, ctx: &yew::prelude::Context, msg: Self::Message) -> bool { - match msg { - EditableHeaderMsg::SetNewValue(new_value) => { - let maybe_value = (!new_value.is_empty()).then_some(new_value.clone()); - self.edited = ctx.props().initial_value != maybe_value; - - self.valid = (|| -> Option { - if maybe_value - .as_ref() - .map(|v| v == &self.placeholder) - .unwrap_or(true) - { - return Some(true); - } - if !self.edited { - return Some(true); - } - let metadata = &ctx.props().metadata; - let expressions = metadata.get_expression_columns(); - let windows = metadata.get_window_columns(); - let found = metadata - .get_table_columns()? - .iter() - .chain(expressions) - .chain(windows) - .contains(&new_value); - Some(!found) - })() - .unwrap_or(true); - - self.value.clone_from(&maybe_value); - ctx.props().on_change.emit((maybe_value, self.valid)); - true - }, - EditableHeaderMsg::OnClick(()) => { - self.noderef - .cast::() - .unwrap() - .focus() - .unwrap(); - false - }, - } + !table_columns + .iter() + .chain(metadata.get_expression_columns()) + .chain(metadata.get_window_columns()) + .contains(value) } +} - fn view(&self, ctx: &yew::prelude::Context) -> Html { - let mut classes = classes!("sidebar_header_contents"); - if ctx.props().editable { - classes.push("editable"); - } - - if !self.valid { - classes.push("invalid"); - } - - if self.edited { - classes.push("edited"); - } +#[function_component(EditableHeader)] +pub fn editable_header(props: &EditableHeaderProps) -> Html { + let noderef = use_node_ref(); + let placeholder = props.split_placeholder(); + let edited = props.value != props.initial_value; + let valid = props.is_valid(&props.value, &placeholder); + let mut classes = classes!("sidebar_header_contents"); + if props.editable { + classes.push("editable"); + } - let onkeyup = ctx.link().callback(|e: KeyboardEvent| { - let value = e.target_unchecked_into::().value(); - EditableHeaderMsg::SetNewValue(value) - }); + if !valid { + classes.push("invalid"); + } - let onblur = ctx.link().callback(|e: FocusEvent| { - let value = e.target_unchecked_into::().value(); - EditableHeaderMsg::SetNewValue(value) - }); + if edited { + classes.push("edited"); + } - let update_on_input = ctx.props().update_on_input; - let oninput = ctx.link().batch_callback(move |e: yew::InputEvent| { + let on_value = { + let props = props.clone(); + let placeholder = placeholder.clone(); + Callback::from(move |value: String| { + let value = (!value.is_empty()).then_some(value); + let valid = props.is_valid(&value, &placeholder); + props.on_change.emit((value, valid)); + }) + }; + + let onkeyup = + on_value.reform(|e: KeyboardEvent| e.target_unchecked_into::().value()); + let onblur = + on_value.reform(|e: FocusEvent| e.target_unchecked_into::().value()); + let oninput = { + let update_on_input = props.update_on_input; + let on_value = on_value.clone(); + Callback::from(move |e: InputEvent| { if update_on_input { - let value = e.target_unchecked_into::().value(); - vec![EditableHeaderMsg::SetNewValue(value)] - } else { - vec![] + on_value.emit(e.target_unchecked_into::().value()); } - }); - - html! { -
- if let Some(icon) = ctx.props().icon_type { } - -
- } + }) + }; + + let onclick = { + let noderef = noderef.clone(); + Callback::from(move |_: MouseEvent| { + if let Some(input) = noderef.cast::() { + let _ = input.focus(); + } + }) + }; + + html! { +
+ if let Some(icon) = props.icon_type { } + +
} } - -#[derive(Default, Debug, PartialEq, Copy, Clone)] -pub enum ValueState { - #[default] - Unedited, - Edited, -} diff --git a/rust/perspective-viewer/src/rust/components/expression_editor.rs b/rust/perspective-viewer/src/rust/components/expression_editor.rs index 295c3af34c..44ae6d8bee 100644 --- a/rust/perspective-viewer/src/rust/components/expression_editor.rs +++ b/rust/perspective-viewer/src/rust/components/expression_editor.rs @@ -16,7 +16,7 @@ use perspective_client::{ExprValidationError, clone}; use yew::prelude::*; use super::form::code_editor::*; -use crate::session::{Session, SessionMetadata, SessionMetadataRc}; +use crate::session::{Session, SessionMetadataRc}; use crate::tasks::{ExprValidation, validate_expression}; #[derive(Properties, PartialEq, Clone)] @@ -27,6 +27,9 @@ pub struct ExpressionEditorProps { pub alias: Option, pub disabled: bool, + /// The saved expression text this editor edits against. + pub initial_expr: Rc, + #[prop_or_default] pub reset_count: u8, @@ -69,7 +72,7 @@ impl Component for ExpressionEditor { fn create(ctx: &Context) -> Self { let oninput = ctx.link().callback(ExpressionEditorMsg::SetExpr); - let expr = initial_expr(&ctx.props().metadata, &ctx.props().alias); + let expr = ctx.props().initial_expr.clone(); ctx.link() .send_message(Self::Message::SetExpr(expr.clone())); @@ -156,24 +159,14 @@ impl Component for ExpressionEditor { fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { if ctx.props().alias != old_props.alias || ctx.props().reset_count != old_props.reset_count - || (ctx.props().alias.is_some() && ctx.props().metadata != old_props.metadata) + || ctx.props().initial_expr != old_props.initial_expr { - ctx.link() - .send_message(ExpressionEditorMsg::SetExpr(initial_expr( - &ctx.props().metadata, - &ctx.props().alias, - ))); + ctx.link().send_message(ExpressionEditorMsg::SetExpr( + ctx.props().initial_expr.clone(), + )); false } else { true } } } - -fn initial_expr(metadata: &SessionMetadata, alias: &Option) -> Rc { - alias - .as_ref() - .and_then(|alias| metadata.get_expression_by_alias(alias)) - .unwrap_or_default() - .into() -} diff --git a/rust/perspective-viewer/src/rust/components/form/code_editor.rs b/rust/perspective-viewer/src/rust/components/form/code_editor.rs index 8dbdeb7a80..45c105e81a 100644 --- a/rust/perspective-viewer/src/rust/components/form/code_editor.rs +++ b/rust/perspective-viewer/src/rust/components/form/code_editor.rs @@ -45,6 +45,9 @@ pub struct CodeEditorProps { #[prop_or_default] pub error: Option, + #[prop_or_default] + pub contained: bool, + /// Selected theme name, threaded for PortalModal consumers. #[prop_or_default] pub theme: String, @@ -165,20 +168,27 @@ pub fn code_editor(props: &CodeEditorProps) -> Html { )) .collect::(); - let class = if props.wordwrap { - "wordwrap scrollable" - } else { - "scrollable" + let mut outer_class = classes!("scrollable"); + let mut inner_class = classes!("editor-inner", "scrollable"); + + if props.wordwrap { + outer_class.push("wordwrap"); + inner_class.push("wordwrap"); }; + + if props.contained { + outer_class.push("contained") + } + clone!(props.disabled); html! { <> -
+
{ line_numbers }
, - pub on_neg_color: Callback, - pub on_reset: Callback<()>, - pub is_modified: bool, -} - -#[function_component(ColorRangeSelector)] -pub fn color_chooser_component(props: &ColorRangeProps) -> Html { - let on_pos_color = use_callback( - props.on_pos_color.clone(), - |event: InputEvent, on_pos_color| { - let color = event - .target() - .unwrap() - .unchecked_into::() - .value(); - on_pos_color.emit(color); - }, - ); - - let on_neg_color = use_callback( - props.on_neg_color.clone(), - |event: InputEvent, on_neg_color| { - let color = event - .target() - .unwrap() - .unchecked_into::() - .value(); - on_neg_color.emit(color); - }, - ); - - let fg_pos = infer_fg(&props.pos_color); - let fg_neg = infer_fg(&props.neg_color); - let style = if props.is_gradient { - format!( - "background:linear-gradient(to right, {} 0%, transparent 50%, {} 100%)", - props.pos_color, props.neg_color - ) - } else { - format!( - "background:linear-gradient(to right, {} 0%, {} 50%, {} 50%, {} 100%)", - props.pos_color, props.pos_color, props.neg_color, props.neg_color - ) - }; - - let on_reset = use_callback(props.clone(), |_: MouseEvent, deps| deps.on_reset.emit(())); - - let pos_class = classes!("parameter", "pos-color-param", props.pos_class.clone()); - let neg_class = classes!("parameter", "neg-color-param", props.neg_class.clone()); - - html! { - <> -