diff --git a/examples/blocks/index.mjs b/examples/blocks/index.mjs index c4f5f20e33..43b1b6213c 100644 --- a/examples/blocks/index.mjs +++ b/examples/blocks/index.mjs @@ -19,19 +19,23 @@ import { execSync } from "child_process"; const version = JSON.parse(fs.readFileSync("./package.json")).version; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); -// TODO jsdelivr has slightly different logic for trailing '/' that causes -// the wasm assets to not load correctly when using aliases, hence we must link -// directly to the assets. -const replacements = { - "/node_modules/": `https://cdn.jsdelivr.net/npm/`, - "@perspective-dev/client/dist/cdn/perspective.js": `@perspective-dev/client@${version}/dist/cdn/perspective.js`, - "@perspective-dev/viewer/dist/cdn/perspective-viewer.js": `@perspective-dev/viewer@${version}/dist/cdn/perspective-viewer.js`, - "@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js": `@perspective-dev/viewer-datagrid@${version}/dist/cdn/perspective-viewer-datagrid.js`, - "@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js": `@perspective-dev/viewer-charts@${version}/dist/cdn/perspective-viewer-charts.js`, - "@perspective-dev/server/dist/cdn/perspective.cpp.wasm": `@perspective-dev/client@${version}/dist/cdn/perspective.cpp.wasm`, - "@perspective-dev/viewer/dist/cdn/perspective.rx.wasm": `@perspective-dev/viewer@${version}/dist/cdn/perspective.rx.wasm`, - "@perspective-dev/client/dist/cdn/perspective.worker.js": `@perspective-dev/client@${version}/dist/cdn/perspective.worker.js`, -}; +const superstore_version = JSON.parse( + fs.readFileSync(`${__dirname}/node_modules/superstore-arrow/package.json`), +).version; + +export function rewrite_cdn_urls(filecontents) { + return filecontents + .replace( + /\/node_modules\/@perspective-dev\/([A-Za-z0-9_-]+)\//g, + (_, pkg) => + `https://cdn.jsdelivr.net/npm/@perspective-dev/${pkg}@${version}/`, + ) + .replace( + /\/node_modules\/superstore-arrow\//g, + `https://cdn.jsdelivr.net/npm/superstore-arrow@${superstore_version}/`, + ) + .replace(/\/node_modules\//g, `https://cdn.jsdelivr.net/npm/`); +} export async function dist_examples( outpath = `${__dirname}/../../docs/static/blocks`, @@ -56,15 +60,11 @@ export async function dist_examples( filename.endsWith(".js") || filename.endsWith(".html") ) { - let filecontents = fs - .readFileSync(`${__dirname}/src/${name}/${filename}`) - .toString(); - for (const pattern of Object.keys(replacements)) { - filecontents = filecontents.replace( - new RegExp(pattern, "g"), - replacements[pattern], - ); - } + const filecontents = rewrite_cdn_urls( + fs + .readFileSync(`${__dirname}/src/${name}/${filename}`) + .toString(), + ); fs.writeFileSync( `${outpath}/${name}/${filename}`, filecontents, diff --git a/packages/react/test/js/react.spec.tsx b/packages/react/test/js/react.spec.tsx index 4e3d87f478..891ce595da 100644 --- a/packages/react/test/js/react.spec.tsx +++ b/packages/react/test/js/react.spec.tsx @@ -63,7 +63,9 @@ test.describe("Perspective React", () => { const name = "abcdef"; const comp = await mount(); const addViewer = comp.locator("button.add-viewer"); - const settingsBtn = comp.locator(`perspective-viewer #settings_button`); + const settingsBtn = comp.locator( + `perspective-viewer perspective-viewer-tab .psp-tab-settings`, + ); await settingsBtn.waitFor(); await addViewer.waitFor(); diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts index 782f0151bb..f035137cc9 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts @@ -92,15 +92,16 @@ export class CandlestickChart extends CategoricalYChart { _yDomain: { min: number; max: number } = { min: 0, max: 1 }; /** - * `domain_mode: "expand"` accumulators. Hold the running union of - * the value-axis (and, in numeric-category mode, category-axis) - * extent across data loads. Cleared in `resetExpandedDomain` — - * wired from the worker's `resetAllZooms` and from view-config - * mutations on `AbstractChart`. `null` whenever the option is - * `"fit"` or the accumulator has just been cleared. + * `domain_mode: "expand"` accumulator — the VALUE (Y) axis only. + * The category axis has NO accumulator on band charts (see + * `SeriesChart._expandedLeftDomain`): a numeric/datetime `group_by` + * axis always fits the current data. Cleared in + * `resetExpandedDomain` — wired from the worker's `resetAllZooms` + * and from view-config mutations on `AbstractChart`. `null` + * whenever the option is `"fit"` or the accumulator has just been + * cleared. */ _expandedYDomain: { min: number; max: number } | null = null; - _expandedCategoryDomain: { min: number; max: number } | null = null; /** * Numeric category-axis state (single non-string group_by). @@ -267,22 +268,17 @@ export class CandlestickChart extends CategoricalYChart { scratchCandles: this._candles, }); // `domain_mode: "expand"` post-build union — mirrors the series - // pipeline. `expandDomainInPlace` mutates `result.*` so the - // assignments below pick up the grown extent automatically. + // pipeline: VALUE (Y) axis only, never the category axis (see + // `SeriesChart.uploadAndRender`). `expandDomainInPlace` mutates + // `result.yDomain` so the assignment below picks up the grown + // extent automatically. if (this._pluginConfig.domain_mode === "expand") { this._expandedYDomain = expandDomainInPlace( this._expandedYDomain, result.yDomain, ); - if (result.numericCategoryDomain) { - this._expandedCategoryDomain = expandDomainInPlace( - this._expandedCategoryDomain, - result.numericCategoryDomain, - ); - } } else { this._expandedYDomain = null; - this._expandedCategoryDomain = null; } this._rowPaths = result.rowPaths; @@ -320,7 +316,6 @@ export class CandlestickChart extends CategoricalYChart { override resetExpandedDomain(): void { this._expandedYDomain = null; - this._expandedCategoryDomain = null; } protected destroyInternal(): void { diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index 135419a8be..b4febf4303 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -265,8 +265,19 @@ export interface PluginConfig { * Faceting strategy when `split_by` is non-empty. * * - `"grid"` — one small-multiple sub-plot per split group. - * - `"overlay"` — single plot with split groups differentiated by - * color. Synced into `_facetConfig.facet_mode`. + * - `"overlay"` — a single plot: cartesian charts differentiate + * split groups by color; the categorical band pipeline (series + * charts) stacks splits within each aggregate's band slot. + * Synced into `_facetConfig.facet_mode`. + * + * The default differs by family via + * `ChartTypeConfig.plugin_field_defaults`: cartesian / density / + * map chart types default to `"grid"`, the series / financial + * band-pipeline types to `"overlay"` (their historical split + * rendering). Series charts REBUILD on a mode change — grid mode + * keys the stack ladder per split (`facetSplits` in + * `buildSeriesPipeline`) so each facet grows from its own + * baseline. */ facet_mode: "grid" | "overlay"; @@ -302,15 +313,23 @@ export interface PluginConfig { /** * Domain accumulation policy across successive `View` updates. * - * - `"fit"` — every update recomputes the rendered domain (and on - * cartesian charts, the X/Y range and color/size scales) from + * - `"fit"` — every update recomputes the affected domains from * the current data extent. Can grow or shrink frame-to-frame. - * - `"expand"` — the rendered domain monotonically *grows*: each + * - `"expand"` — the affected domains monotonically *grow*: each * update unions the new data extent with the previously rendered * extent, so once a value is in scope it stays in scope. Reset * by the "Reset Zoom" button, view-config changes (group_by / * split_by / column-slot / column-type), or toggling back to * `"fit"`. + * + * AXIS SCOPE differs by family: cartesian charts (X/Y Scatter, + * X/Y Line, Density, Maps) apply it to BOTH axes plus the + * color/size scales (categorical string axes opt out — slot + * indices are frame-local); the categorical band pipeline (series + * / financial) applies it to the VALUE axis only — Y for the + * Y-family, X for X Bar — while the category axis always fits, so + * a streaming numeric/datetime `group_by` axis releases departed + * categories instead of pinning to its history. */ domain_mode: "fit" | "expand"; 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 a4d449b489..4048772a29 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 @@ -157,7 +157,11 @@ function formatLevelValue( valid: boolean, levelType: string, ): string { - if (!valid) { + // `value` can be `undefined` even when `valid` claims otherwise — an + // out-of-bounds read when a row window outran the delivered Arrow + // (belt to `loadAndRender`'s delivered-rows clamp) — treat it as + // null rather than crash the label formatters. + if (!valid || value == null || Number.isNaN(value)) { return ""; } diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts index 7a2d8608ff..caf739c357 100644 --- a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts @@ -187,7 +187,8 @@ export class AreaGlyph { /** * Bind persistent strip buffers and dispatch one TRIANGLE_STRIP per - * series-run. Skips hidden series. + * series-run. Skips hidden series. `splitFilter` (faceted frames) + * draws only the series whose `splitIdx` matches. */ draw( chart: SeriesChart, @@ -196,6 +197,7 @@ export class AreaGlyph { projLeft: Float32Array, projRight: Float32Array, opacity: number, + splitFilter?: number, ): void { const buf = this._buffers; const cache = this._program; @@ -212,6 +214,13 @@ export class AreaGlyph { continue; } + if ( + splitFilter !== undefined && + chart._series[s.seriesId].splitIdx !== splitFilter + ) { + continue; + } + gl.uniformMatrix4fv( cache.u_projection, false, diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts index a17ceb2199..93d38a3549 100644 --- a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts @@ -30,13 +30,20 @@ type GL = WebGL2RenderingContext | WebGLRenderingContext; /** * Draw the bar-typed subset of `chart._bars` as instanced quads. Assumes * the caller has already `useProgram`'d the bar shader and set uniforms. + * + * `range` draws a contiguous instance sub-range instead of the full + * uploaded set — the faceted renderer uploads instances split-major + * (see `uploadBarInstances`) and dispatches one range per facet. */ export function drawBars( chart: SeriesChart, gl: GL, glManager: WebGLContextManager, + range?: { start: number; count: number }, ): void { - if (chart._uploadedBars === 0) { + const first = range?.start ?? 0; + const count = range?.count ?? chart._uploadedBars; + if (chart._uploadedBars === 0 || count === 0) { return; } @@ -62,6 +69,7 @@ export function drawBars( loc.a_x_center, "bar_x", 1, + first, ) && bindInstancedFloatAttr( glManager, @@ -69,15 +77,31 @@ export function drawBars( loc.a_half_width, "bar_hw", 1, + first, + ) && + bindInstancedFloatAttr( + glManager, + instancing, + loc.a_y0, + "bar_y0", + 1, + first, + ) && + bindInstancedFloatAttr( + glManager, + instancing, + loc.a_y1, + "bar_y1", + 1, + first, ) && - bindInstancedFloatAttr(glManager, instancing, loc.a_y0, "bar_y0", 1) && - bindInstancedFloatAttr(glManager, instancing, loc.a_y1, "bar_y1", 1) && bindInstancedFloatAttr( glManager, instancing, loc.a_color, "bar_color", 3, + first, ) && bindInstancedFloatAttr( glManager, @@ -85,6 +109,7 @@ export function drawBars( loc.a_series_id, "bar_sid", 1, + first, ) && bindInstancedFloatAttr( glManager, @@ -92,15 +117,11 @@ export function drawBars( loc.a_axis, "bar_axis", 1, + first, ); if (ok) { - instancing.drawArraysInstanced( - gl.TRIANGLE_STRIP, - 0, - 4, - chart._uploadedBars, - ); + instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count); } setDivisor(loc.a_x_center, 0); diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts index cc99a3891a..3ed76b9fa5 100644 --- a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts @@ -271,7 +271,8 @@ export class LineGlyph { * Bind the persistent vertex buffers and dispatch one instanced draw * per series. Skips hidden series via `_hiddenSeries`. Gap / * transparency rendering is governed by `u_interp_alpha`, set per - * series. + * series. `splitFilter` (faceted frames) draws only the series + * whose `splitIdx` matches — one call per facet. */ draw( chart: SeriesChart, @@ -279,6 +280,7 @@ export class LineGlyph { glManager: WebGLContextManager, projLeft: Float32Array, projRight: Float32Array, + splitFilter?: number, ): void { const buf = this._buffers; const cache = this._program; @@ -310,6 +312,13 @@ export class LineGlyph { continue; } + if ( + splitFilter !== undefined && + chart._series[s.seriesId].splitIdx !== splitFilter + ) { + continue; + } + gl.uniformMatrix4fv( cache.u_projection, false, diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts index 673ac641a6..a8a35e1ad6 100644 --- a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts @@ -34,10 +34,22 @@ interface ScatterProgramCache { * Persistent scatter glyph state — left/right axis position + color * buffers built once at data load. Pan/zoom redraws rebind without * uploading. + * + * `seriesRanges` records each visible series' contiguous run within + * its axis bucket (`first` is bucket-relative). The fill loop appends + * series in order, so runs are contiguous by construction; the + * faceted draw path uses them to dispatch per-facet sub-ranges via + * `drawArrays(POINTS, first, count)`. */ interface ScatterBuffers { leftCount: number; rightCount: number; + seriesRanges: { + seriesId: number; + axis: 0 | 1; + first: number; + count: number; + }[]; } /** @@ -170,6 +182,9 @@ export class ScatterGlyph { // Fill left bucket from `[0, leftCount)`, right bucket from // `[leftCount, total)` — single typed-array allocation each. + // Series runs are contiguous within their bucket; record each + // run's bucket-relative `[first, count)` for the faceted draw. + const seriesRanges: ScatterBuffers["seriesRanges"] = []; let leftWrite = 0; let rightWrite = leftCount; for (const s of scatterSeries) { @@ -177,6 +192,7 @@ export class ScatterGlyph { continue; } + const runFirst = s.axis === 1 ? rightWrite - leftCount : leftWrite; const r = s.color[0]; const g = s.color[1]; const b = s.color[2]; @@ -204,6 +220,16 @@ export class ScatterGlyph { leftWrite++; } } + + const runEnd = s.axis === 1 ? rightWrite - leftCount : leftWrite; + if (runEnd > runFirst) { + seriesRanges.push({ + seriesId: s.seriesId, + axis: s.axis, + first: runFirst, + count: runEnd - runFirst, + }); + } } if (leftCount > 0) { @@ -236,12 +262,15 @@ export class ScatterGlyph { ); } - this._buffers = { leftCount, rightCount }; + this._buffers = { leftCount, rightCount, seriesRanges }; } /** * Bind the persistent left/right buffers and issue up to two draw - * calls. No per-frame allocations or buffer uploads. + * calls. No per-frame allocations or buffer uploads. `splitFilter` + * (faceted frames) instead draws each matching series' contiguous + * bucket sub-range — one `drawArrays(POINTS, first, count)` per + * series of the facet. */ draw( chart: SeriesChart, @@ -249,6 +278,7 @@ export class ScatterGlyph { glManager: WebGLContextManager, projLeft: Float32Array, projRight: Float32Array, + splitFilter?: number, ): void { const buf = this._buffers; const cache = this._program; @@ -267,6 +297,28 @@ export class ScatterGlyph { chart._pluginConfig.point_size_px * dpr, ); + if (splitFilter !== undefined) { + for (const r of buf.seriesRanges) { + if (chart._series[r.seriesId].splitIdx !== splitFilter) { + continue; + } + + drawBucket( + gl, + cache, + r.axis === 1 ? cache.posRightBuffer : cache.posLeftBuffer, + r.axis === 1 + ? cache.colorRightBuffer + : cache.colorLeftBuffer, + r.count, + r.axis === 1 ? projRight : projLeft, + r.first, + ); + } + + return; + } + drawBucket( gl, cache, @@ -309,6 +361,7 @@ function drawBucket( colBuf: WebGLBuffer, count: number, proj: Float32Array, + first = 0, ): void { if (count === 0) { return; @@ -324,5 +377,5 @@ function drawBucket( gl.enableVertexAttribArray(cache.a_color); gl.vertexAttribPointer(cache.a_color, 3, gl.FLOAT, false, 0, 0); - gl.drawArrays(gl.POINTS, 0, count); + gl.drawArrays(gl.POINTS, first, count); } 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 c5332a3c64..95c094e730 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-build.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -285,6 +285,18 @@ export interface SeriesPipelineInput { */ includeZero: boolean; + /** + * `facet_mode: "grid"` geometry: key the stack ladder by + * `(catIdx, aggIdx, splitIdx)` instead of `(catIdx, aggIdx)`, so + * splits never stack on each other — each facet's bars / areas + * grow from their own zero baseline. Slot geometry (`xCenter` / + * `halfWidth`) is unchanged: facets separate splits by projection + * + scissor, not by X offset, so every split's records share the + * same band-slot coordinates. Value extents still accumulate + * across ALL splits — the facet grid renders a shared value axis. + */ + facetSplits?: boolean; + /** * Reusable scratch — pipeline writes records into these in place * and zero-fills the stack ladder. Pass the previous build's @@ -404,6 +416,7 @@ export function buildSeriesPipeline( bandInnerFrac, barInnerPad, includeZero, + facetSplits, scratchBars, scratchPosStack, scratchNegStack, @@ -529,9 +542,12 @@ export function buildSeriesPipeline( const N = numCategories; const S = series.length; - // Stacking ladder, keyed by (catIdx, aggIdx). Reuse chart-owned + // Stacking ladder, keyed by (catIdx, aggIdx) — or per-split + // (catIdx, aggIdx, splitIdx) in facet-grid mode, where each split + // renders in its own facet and cross-split stacking would lift + // every facet after the first off its baseline. Reuse chart-owned // scratch when sized; else allocate. Active prefix is zero-filled. - const stackLen = N * M; + const stackLen = facetSplits ? N * M * P : N * M; const posStack = ensureFloat64Scratch(scratchPosStack ?? null, stackLen); const negStack = ensureFloat64Scratch(scratchNegStack ?? null, stackLen); posStack.fill(0, 0, stackLen); @@ -922,7 +938,9 @@ export function buildSeriesPipeline( } } - const stackIdx = catI * M + k; + const stackIdx = facetSplits + ? (catI * M + k) * P + p + : catI * M + k; let y0: number; let y1: number; if (v >= 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 f2f42af366..9d56cc9386 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-interact.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-interact.ts @@ -21,6 +21,7 @@ import { uploadBarInstances, rebuildGlyphBuffers, rightAxisDataToPixel, + layoutForRecord, } from "./series-render"; const POINT_HIT_RADIUS_PX = 10; @@ -49,6 +50,12 @@ export function getHoveredBar(chart: SeriesChart): SeriesChartRecord | null { * Handle mouse-move across all glyph types. Tests (in reverse paint order * so top glyphs win): scatter points → line points → bars → areas. * Updates `_hoveredBarIdx` or `_hoveredSample` and re-renders on change. + * + * Faceted frames first resolve the cell under the cursor: that cell's + * layout supplies the pixel→data mapping and its index becomes the + * `splitFilter` — records of other splits share the same band-slot + * coordinates by construction, so an unfiltered hit-test would match + * a bar from a different facet. */ export function handleBarHover( chart: SeriesChart, @@ -59,7 +66,33 @@ export function handleBarHover( return; } - const layout = chart._lastLayout; + let layout = chart._lastLayout; + let splitFilter: number | undefined; + if (chart._facetGrid) { + const cells = chart._facetGrid.cells; + let found = -1; + for (let i = 0; i < cells.length; i++) { + const p = cells[i].layout.plotRect; + if ( + mx >= p.x && + mx <= p.x + p.width && + my >= p.y && + my <= p.y + p.height + ) { + found = i; + break; + } + } + + if (found < 0) { + clearHover(chart); + return; + } + + layout = cells[found].layout; + splitFilter = found; + } + const plot = layout.plotRect; if ( @@ -130,6 +163,7 @@ export function handleBarHover( pxPerDataX, pxPerDataYLeft, pxPerDataYRight, + splitFilter, ); // 2. Line points (still above bars; treat as point hits). @@ -143,6 +177,7 @@ export function handleBarHover( pxPerDataX, pxPerDataYLeft, pxPerDataYRight, + splitFilter, ); } @@ -157,6 +192,7 @@ export function handleBarHover( const by1 = bars.y1; const ax = bars.axis; const hidden = chart._hiddenSeries; + const P = chart._splitPrefixes.length; for (let i = 0; i < bars.count; i++) { if (ct[i] !== BAR_TYPE_BAR) { continue; @@ -166,6 +202,10 @@ export function handleBarHover( continue; } + if (splitFilter !== undefined && sid[i] % P !== splitFilter) { + continue; + } + const xc = xC[i]; const halfW = hw[i]; if (dataX < xc - halfW || dataX > xc + halfW) { @@ -186,7 +226,13 @@ export function handleBarHover( // 4. Areas (strip hit — stacked records via `_bars`, unstacked via samples). if (nextBarIdx < 0 && !nextSample) { - const areaHit = hitTestAreas(chart, dataX, dataYLeft, dataYRight); + const areaHit = hitTestAreas( + chart, + dataX, + dataYLeft, + dataYRight, + splitFilter, + ); if (areaHit) { if (areaHit.idx >= 0) { nextBarIdx = areaHit.idx; @@ -208,6 +254,7 @@ function hitTestPoints( pxPerDataX: number, pxPerDataYLeft: number, pxPerDataYRight: number, + splitFilter?: number, ): SeriesChartRecord | null { const N = chart._numCategories; const S = chart._series.length; @@ -232,6 +279,10 @@ function hitTestPoints( continue; } + if (splitFilter !== undefined && s.splitIdx !== splitFilter) { + continue; + } + const dataY = s.axis === 1 ? dataYRight : dataYLeft; const pyPerData = s.axis === 1 ? pxPerDataYRight : pxPerDataYLeft; @@ -288,6 +339,7 @@ function hitTestAreas( dataX: number, dataYLeft: number, dataYRight: number, + splitFilter?: number, ): { idx: number; bar: SeriesChartRecord | null } | null { // Closest category to the mouse; an area covers every [cat - 0.5, cat + 0.5] // slot, so use `round(dataX)` as the candidate index. @@ -312,6 +364,7 @@ function hitTestAreas( const ax = bars.axis; const by0 = bars.y0; const by1 = bars.y1; + const P = chart._splitPrefixes.length; for (let i = 0; i < bars.count; i++) { if (ct[i] !== BAR_TYPE_AREA) { continue; @@ -325,6 +378,10 @@ function hitTestAreas( continue; } + if (splitFilter !== undefined && sid[i] % P !== splitFilter) { + continue; + } + const dy = ax[i] === 0 ? dataYLeft : dataYRight; const y0 = by0[i]; const y1 = by1[i]; @@ -345,6 +402,10 @@ function hitTestAreas( continue; } + if (splitFilter !== undefined && s.splitIdx !== splitFilter) { + continue; + } + const idx = cat * S + s.seriesId; if (!((valid[idx >> 3] >> (idx & 7)) & 1)) { continue; @@ -411,6 +472,10 @@ function applyHover( /** * Handle a click on the legend area. Returns true when the click hit a * legend entry (the caller should then treat the event as consumed). + * + * An entry may own several series (facet-grid mode groups the legend + * by aggregate): the toggle is all-or-nothing — any visible member + * hides the whole group, a fully-hidden group shows every member. */ export function handleBarLegendClick( chart: SeriesChart, @@ -429,10 +494,15 @@ export function handleBarLegendClick( my >= r.y && my <= r.y + r.height ) { - if (chart._hiddenSeries.has(entry.seriesId)) { - chart._hiddenSeries.delete(entry.seriesId); - } else { - chart._hiddenSeries.add(entry.seriesId); + const anyVisible = entry.seriesIds.some( + (sid) => !chart._hiddenSeries.has(sid), + ); + for (const sid of entry.seriesIds) { + if (anyVisible) { + chart._hiddenSeries.add(sid); + } else { + chart._hiddenSeries.delete(sid); + } } // Hidden-series change affects which bars contribute to @@ -570,7 +640,11 @@ function pinTooltip(chart: SeriesChart, b: SeriesChartRecord): void { return; } - const layout = chart._lastLayout; + // Faceted frames anchor in the record's own cell. + const layout = layoutForRecord(chart, b); + if (!layout) { + return; + } // Anchor at the bar midpoint for bar glyphs (tooltip reads against // the body); at the point itself (`y1`) for line / scatter / area. @@ -581,7 +655,7 @@ function pinTooltip(chart: SeriesChart, b: SeriesChartRecord): void { ? chart._isHorizontal ? layout.dataToPixel(anchorV, b.xCenter) : layout.dataToPixel(b.xCenter, anchorV) - : rightAxisDataToPixel(chart, b.xCenter, anchorV); + : rightAxisDataToPixel(chart, b.xCenter, anchorV, layout); const lines = buildBarTooltipLines(chart, b); if (lines.length === 0) { 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 8fdf458973..64028f78c4 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-render.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -19,12 +19,20 @@ import { } from "./series"; import type { PlotRect } from "../../layout/plot-layout"; import { PlotLayout } from "../../layout/plot-layout"; -import { renderInPlotFrame } from "../../webgl/plot-frame"; +import { + clearAndSetupFrame, + renderInPlotFrame, + withScissor, +} from "../../webgl/plot-frame"; import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; import { drawBars, BAR_TYPE_BAR_VAL as BAR_TYPE_BAR } from "./glyphs/draw-bars"; import { getHoveredBar } from "./series-interact"; import { computeNiceTicks } from "../../layout/ticks"; -import { type AxisDomain } from "../../axis/numeric-axis"; +import { + renderOuterXAxis, + renderOuterYAxis, + type AxisDomain, +} from "../../axis/numeric-axis"; import { renderBarAxesChrome, renderBarGridlines, @@ -34,8 +42,19 @@ import { import { measureCategoricalAxisHeight, measureCategoricalAxisWidth, + renderCategoricalXTicks, + renderCategoricalYTicks, type CategoricalDomain, } from "../../axis/categorical-axis"; +import { + bottomRowLayouts, + buildFacetGrid, + leftColumnLayouts, + type FacetGrid, +} from "../../layout/facet-grid"; +import { drawFacetTitle } from "../../axis/facet-chrome"; +import { getScaledContext, initCanvas } from "../../axis/canvas"; +import { drawGridlinesX, drawGridlinesY } from "../../axis/axis-primitives"; import { buildBarTooltipLines } from "./series-interact"; /** @@ -77,6 +96,12 @@ function ensureBarInstanceScratch(n: number): BarInstanceScratch { /** * Upload bar instance buffers from the columnar `_bars` storage. + * + * Overlay mode emits instances in `_bars` order. Facet-grid mode + * (`_facetActive`) emits them split-major via a counting sort so each + * facet's instances form a contiguous range — recorded on + * `chart._facetBarRanges` and drawn per facet with an instance-offset + * `drawBars` call. */ export function uploadBarInstances( chart: SeriesChart, @@ -84,8 +109,11 @@ export function uploadBarInstances( ): void { const bars = chart._bars; const total = bars.count; + const P = chart._splitPrefixes.length; + const faceted = chart._facetActive && P > 0; let n = 0; + chart._facetBarRanges = null; if (total > 0) { const scratch = ensureBarInstanceScratch(total); if ( @@ -108,6 +136,34 @@ export function uploadBarInstances( const by0 = bars.y0; const by1 = bars.y1; const ax = bars.axis; + + // Facet mode: counting sort by splitIdx (`seriesId % P`). Pass + // 1 counts eligible instances per split; prefix sums become the + // per-split write cursors AND the published ranges. + let writeAt: ((seriesId: number) => number) | null = null; + if (faceted) { + const counts = new Array(P).fill(0); + for (let i = 0; i < total; i++) { + if (ct[i] !== BAR_TYPE_BAR || hidden.has(sid[i])) { + continue; + } + + counts[sid[i] % P]++; + } + + const ranges: { start: number; count: number }[] = []; + const cursors = new Int32Array(P); + let acc = 0; + for (let p = 0; p < P; p++) { + ranges.push({ start: acc, count: counts[p] }); + cursors[p] = acc; + acc += counts[p]; + } + + chart._facetBarRanges = ranges; + writeAt = (seriesId: number) => cursors[seriesId % P]++; + } + for (let i = 0; i < total; i++) { if (ct[i] !== BAR_TYPE_BAR) { continue; @@ -118,19 +174,25 @@ export function uploadBarInstances( continue; } - scratch.xCenters[n] = xC[i] - xOrigin; - scratch.halfWidths[n] = hw[i]; - scratch.y0s[n] = by0[i]; - scratch.y1s[n] = by1[i]; - scratch.seriesIds[n] = seriesId; - scratch.axes[n] = ax[i]; + const w = writeAt ? writeAt(seriesId) : n; + scratch.xCenters[w] = xC[i] - xOrigin; + scratch.halfWidths[w] = hw[i]; + scratch.y0s[w] = by0[i]; + scratch.y1s[w] = by1[i]; + scratch.seriesIds[w] = seriesId; + scratch.axes[w] = ax[i]; const color = series[seriesId].color; - scratch.colors[n * 3] = color[0]; - scratch.colors[n * 3 + 1] = color[1]; - scratch.colors[n * 3 + 2] = color[2]; - indices[n] = i; + scratch.colors[w * 3] = color[0]; + scratch.colors[w * 3 + 1] = color[1]; + scratch.colors[w * 3 + 2] = color[2]; + indices[w] = i; n++; } + } else if (faceted) { + chart._facetBarRanges = Array.from({ length: P }, () => ({ + start: 0, + count: 0, + })); } chart._uploadedBars = n; @@ -386,6 +448,28 @@ export function renderBarFrame( } } + // Facet-grid branch — one sub-plot per split group. Keys off the + // build-time `_facetActive` stamp (NOT the live `_facetConfig`) so + // this frame's render mode always matches the stack geometry the + // current `_bars` were built with. + if (chart._facetActive && chart._splitPrefixes.length > 1) { + renderFacetedBarFrame(chart, glManager, { + horizontal, + numericCat, + cssWidth, + cssHeight, + visCatMin, + visCatMax, + visValMin, + visValMax, + visRightMin, + visRightMax, + }); + return; + } + + chart._facetGrid = null; + const hasLegend = chart._series.length > 1; const hasCatLabel = chart._groupBy.length > 0; @@ -610,6 +694,309 @@ export function renderBarFrame( chart._defer2D(() => renderBarChromeOverlay(chart)); } +/** + * Domain window computed by `renderBarFrame`'s shared prologue (zoom + * window + auto-fit + `include_zero`), handed to the faceted branch. + */ +interface FacetedFrameCtx { + horizontal: boolean; + numericCat: boolean; + cssWidth: number; + cssHeight: number; + visCatMin: number; + visCatMax: number; + visValMin: number; + visValMax: number; + visRightMin: number; + visRightMax: number; +} + +/** + * Faceted frame — one sub-plot per split group, laid out by + * `buildFacetGrid`. Geometry contract with the build pipeline + * (`facetSplits`): every split's records share the same band-slot + * coordinates and per-split stack baselines, so a facet is exactly + * "the single-plot render restricted to one split" — same domains, + * same projection math, different `PlotLayout` + instance subset. + * + * Axis policy: the value axis is shared (one outer band, one domain + * for every facet); the category axis paints per-cell when + * categorical (the outer painters are numeric-only — same compromise + * as the cartesian faceted path) and shared-outer when numeric. Zoom + * is shared: `syncFacetZoomLayouts` keeps the single controller's + * layout on cell 0, and every facet renders the same visible window. + * + * Known limitation: the grid reserves no outer band for a secondary + * value axis, so dual-axis series project correctly inside each facet + * but the right-axis chrome is suppressed (`_lastAltYDomain = null`). + */ +function renderFacetedBarFrame( + chart: SeriesChart, + glManager: WebGLContextManager, + ctx: FacetedFrameCtx, +): void { + const gl = glManager.gl; + const dpr = glManager.dpr; + const theme = chart._resolveTheme(); + const { + horizontal, + numericCat, + cssWidth, + cssHeight, + visCatMin, + visCatMax, + visValMin, + visValMax, + visRightMin, + visRightMax, + } = ctx; + + const hasCatLabel = chart._groupBy.length > 0; + const valueCatDomain = chart._leftValueCategoryDomain; + const valueCatActive = + chart._leftValueAxisMode === "category" && + valueCatDomain !== null && + valueCatDomain.numRows > 0; + + // Facets absorb the split dimension and color follows the + // aggregate, so only multiple aggregates warrant a legend. + const hasLegend = chart._aggregates.length > 1; + + // `buildFacetGrid` axis modes are named in CANVAS orientation + // (x = bottom band, y = left band); map the logical category / + // value sides onto them per chart orientation. + const catAxisMode = numericCat ? ("outer" as const) : ("cell" as const); + const valAxisMode = valueCatActive ? ("cell" as const) : ("outer" as const); + + const grid: FacetGrid = buildFacetGrid(chart._splitPrefixes, { + cssWidth, + cssHeight, + xAxis: horizontal ? valAxisMode : catAxisMode, + yAxis: horizontal ? catAxisMode : valAxisMode, + hasLegend, + hasXLabel: horizontal ? true : hasCatLabel, + hasYLabel: horizontal ? hasCatLabel : true, + gap: chart._facetConfig.facet_padding, + }); + chart._facetGrid = grid; + chart._lastLayout = grid.cells[0]?.layout ?? null; + if (grid.cells.length === 0 || !chart._lastLayout) { + return; + } + + chart.syncFacetZoomLayouts(grid.cells); + + const leftValueTicks = computeNiceTicks(visValMin, visValMax, 6); + const catTicks = numericCat + ? computeNiceTicks(visCatMin, visCatMax, 6) + : null; + + const sampleLayout = grid.cells[0].layout; + const gridlineCanvas = chart._gridlineCanvas; + if (gridlineCanvas) { + // Deferred FIRST so the destructive resize/clear runs before + // the per-cell gridline closures below (FIFO flush order). + chart._defer2D(() => initCanvas(gridlineCanvas, sampleLayout, dpr)); + } + + const requireZero = chart._pluginConfig.include_zero; + const hovered = chart._series.length > 1 ? getHoveredBar(chart) : null; + const hasRight = + chart._hasRightAxis && chart._rightDomain !== null && !horizontal; + + clearAndSetupFrame(gl); + for (let p = 0; p < grid.cells.length; p++) { + const cell = grid.cells[p]; + const layout = cell.layout; + + // Same projection args as the single-plot path, on the cell's + // layout. Seeds the cell's padded domain, which the deferred + // gridline closure, chrome overlay, and hover hit-test read. + const projLeft = horizontal + ? layout.buildProjectionMatrix( + visValMin, + visValMax, + visCatMax, + visCatMin, + "x", + requireZero, + undefined, + 0, + chart._categoryOrigin, + ) + : layout.buildProjectionMatrix( + visCatMin, + visCatMax, + visValMin, + visValMax, + "y", + requireZero, + undefined, + chart._categoryOrigin, + 0, + ); + + let projRight = projLeft; + if (hasRight) { + const savedPadXMin = layout.paddedXMin; + const savedPadXMax = layout.paddedXMax; + const savedPadYMin = layout.paddedYMin; + const savedPadYMax = layout.paddedYMax; + projRight = layout.buildProjectionMatrix( + visCatMin, + visCatMax, + visRightMin, + visRightMax, + "y", + requireZero, + undefined, + chart._categoryOrigin, + 0, + ); + layout.paddedXMin = savedPadXMin; + layout.paddedXMax = savedPadXMax; + layout.paddedYMin = savedPadYMin; + layout.paddedYMax = savedPadYMax; + } + + if (gridlineCanvas) { + chart._defer2D(() => + renderBarGridlinesCell( + gridlineCanvas, + layout, + leftValueTicks, + theme, + dpr, + horizontal, + ), + ); + } + + withScissor(gl, layout, dpr, () => { + // Same paint order as the single-plot path: areas behind + // bars, lines above, scatter on top. X Bar draws bars only + // (the other glyphs bake vertical geometry). + if (!horizontal) { + chart._glyphs.areas.draw( + chart, + gl, + glManager, + projLeft, + projRight, + theme.areaOpacity, + p, + ); + } + + gl.useProgram(chart._program!); + const loc = chart._locations!; + gl.uniformMatrix4fv(loc.u_proj_left, false, projLeft); + gl.uniformMatrix4fv(loc.u_proj_right, false, projRight); + gl.uniform1f(loc.u_horizontal, horizontal ? 1.0 : 0.0); + gl.uniform1f( + loc.u_hover_series, + hovered && hovered.splitIdx === p ? hovered.seriesId : -1, + ); + drawBars( + chart, + gl, + glManager, + chart._facetBarRanges?.[p] ?? { start: 0, count: 0 }, + ); + + if (!horizontal) { + chart._glyphs.lines.draw( + chart, + gl, + glManager, + projLeft, + projRight, + p, + ); + chart._glyphs.scatter.draw( + chart, + gl, + glManager, + projLeft, + projRight, + p, + ); + } + }); + } + + chart._lastXDomain = { + levels: chart._rowPaths, + numRows: chart._numCategories, + levelLabels: chart._groupBy.slice(), + }; + chart._lastYDomain = { + min: visValMin, + max: visValMax, + label: chart._primaryValueLabel, + }; + chart._lastYTicks = leftValueTicks; + chart._lastAltYDomain = null; + chart._lastAltYTicks = null; + chart._lastCatTicks = catTicks; + chart._defer2D(() => renderBarChromeOverlay(chart)); +} + +/** + * Per-cell value-axis gridlines for the faceted frame. Non-destructive + * counterpart to {@link renderBarGridlines} — the shared gridline + * canvas is `initCanvas`'d once per frame, then each facet paints into + * it via `getScaledContext`. + */ +function renderBarGridlinesCell( + canvas: NonNullable, + layout: PlotLayout, + valueTicks: number[], + theme: ReturnType, + dpr: number, + horizontal: boolean, +): void { + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + return; + } + + ctx.strokeStyle = theme.gridlineColor; + ctx.lineWidth = 1; + if (horizontal) { + drawGridlinesX( + ctx, + layout.plotRect, + valueTicks, + (v) => layout.dataToPixel(v, 0).px, + ); + } else { + drawGridlinesY( + ctx, + layout.plotRect, + valueTicks, + (v) => layout.dataToPixel(0, v).py, + ); + } +} + +/** + * Resolve the `PlotLayout` a record renders in: the record's facet + * cell in faceted frames, the single plot layout otherwise. Tooltip / + * pin positioning must map through this so faceted anchors land in the + * record's own cell. + */ +export function layoutForRecord( + chart: SeriesChart, + b: { splitIdx: number }, +): PlotLayout | null { + if (chart._facetGrid) { + return chart._facetGrid.cells[b.splitIdx]?.layout ?? null; + } + + return chart._lastLayout; +} + /** * Draw axes chrome + legend + tooltip onto the overlay canvas. */ @@ -623,6 +1010,11 @@ export function renderBarChromeOverlay(chart: SeriesChart): void { return; } + if (chart._facetGrid) { + renderFacetedBarChromeOverlay(chart); + return; + } + const theme = chart._resolveTheme(); let catAxis: BarCategoryAxis; if ( @@ -711,6 +1103,248 @@ export function renderBarChromeOverlay(chart: SeriesChart): void { } } +/** + * Chrome overlay for the faceted frame. The canvas is `initCanvas`'d + * once, then every painter goes through `getScaledContext` — per-cell + * axis frames, per-cell categorical ticks, shared outer numeric + * bands, facet titles, the aggregate legend, and the tooltip (mapped + * through the hovered record's own cell). + */ +function renderFacetedBarChromeOverlay(chart: SeriesChart): void { + const grid = chart._facetGrid!; + const canvas = chart._chromeCanvas!; + const theme = chart._resolveTheme(); + const dpr = chart._glManager?.dpr ?? 1; + const horizontal = chart._isHorizontal; + const hasCatLabel = chart._groupBy.length > 0; + const numericCat = + chart._categoryAxisMode === "numeric" && + chart._numericCategoryDomain !== null && + chart._lastCatTicks !== null; + + if (!initCanvas(canvas, chart._lastLayout!, dpr)) { + return; + } + + const catDomain = chart._lastXDomain; + const valueDomain = chart._lastYDomain!; + const valueTicks = chart._lastYTicks!; + const valueCatDomain = chart._leftValueCategoryDomain; + const valueCatActive = + chart._leftValueAxisMode === "category" && + valueCatDomain !== null && + valueCatDomain.numRows > 0; + + const primarySeries = chart._series.find((s) => s.axis === 0); + const valueFmt = chart.getColumnFormatter( + primarySeries?.aggName ?? null, + "tick", + ); + const catFmt = chart.getColumnFormatter(chart._groupBy[0], "tick"); + + for (const cell of grid.cells) { + const layout = cell.layout; + const plot = layout.plotRect; + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + continue; + } + + ctx.strokeStyle = theme.axisLineColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(plot.x, plot.y); + ctx.lineTo(plot.x, plot.y + plot.height); + ctx.lineTo(plot.x + plot.width, plot.y + plot.height); + ctx.stroke(); + + // Categorical sides paint per cell (the outer band painters + // are numeric-only); numeric sides are painted once below + // into the shared outer bands. + if (!horizontal) { + if (!numericCat && catDomain) { + renderCategoricalXTicks(ctx, layout, catDomain, theme); + } + + if (valueCatActive) { + renderCategoricalYTicks(ctx, layout, valueCatDomain!, theme); + } + } else { + if (!numericCat && catDomain) { + renderCategoricalYTicks(ctx, layout, catDomain, theme); + } + + if (valueCatActive) { + renderCategoricalXTicks(ctx, layout, valueCatDomain!, theme); + } + } + + if (cell.titleRect) { + drawFacetTitle(canvas, cell.label, cell.titleRect, theme, dpr); + } + } + + const numericCatAxisDomain: AxisDomain | null = numericCat + ? { + min: chart._numericCategoryDomain!.min, + max: chart._numericCategoryDomain!.max, + isDate: chart._numericCategoryDomain!.isDate, + label: chart._numericCategoryDomain!.label, + } + : null; + + if (!horizontal) { + if (numericCatAxisDomain && grid.outerXAxisRect) { + renderOuterXAxis( + canvas, + grid.outerXAxisRect, + numericCatAxisDomain, + chart._lastCatTicks!, + bottomRowLayouts(grid), + theme, + hasCatLabel, + dpr, + catFmt, + ); + } + + if (!valueCatActive && grid.outerYAxisRect) { + renderOuterYAxis( + canvas, + grid.outerYAxisRect, + valueDomain, + valueTicks, + leftColumnLayouts(grid), + theme, + true, + dpr, + valueFmt, + ); + } + } else { + if (numericCatAxisDomain && grid.outerYAxisRect) { + renderOuterYAxis( + canvas, + grid.outerYAxisRect, + numericCatAxisDomain, + chart._lastCatTicks!, + leftColumnLayouts(grid), + theme, + hasCatLabel, + dpr, + catFmt, + ); + } + + if (!valueCatActive && grid.outerXAxisRect) { + renderOuterXAxis( + canvas, + grid.outerXAxisRect, + valueDomain, + valueTicks, + bottomRowLayouts(grid), + theme, + true, + dpr, + valueFmt, + ); + } + } + + renderFacetedBarLegend(chart, grid); + + if (getHoveredBar(chart)) { + renderBarTooltipCanvas(chart); + } +} + +/** + * Aggregate-level legend for the faceted frame, painted into the + * grid's shared right gutter. One entry per aggregate (facets absorb + * the split dimension; every split of an aggregate shares its color — + * see `ensurePalette`). A legend toggle targets the aggregate's full + * seriesId set, so hiding "Sales" hides it in every facet at once; an + * entry reads as hidden only when ALL of its series are hidden. + */ +function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void { + chart._legendRects = []; + if (!chart._chromeCanvas || !grid.legendRect) { + return; + } + + const M = chart._aggregates.length; + if (M <= 1) { + return; + } + + const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + + ctx.save(); + + const theme = chart._resolveTheme(); + const swatchSize = 10; + const lineHeight = 18; + const x = grid.legendRect.x + 12; + let y = grid.legendRect.y + 10; + + ctx.font = `11px ${theme.fontFamily}`; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + for (let k = 0; k < M; k++) { + const seriesIds: number[] = []; + let color: [number, number, number] = [0.5, 0.5, 0.5]; + for (const s of chart._series) { + if (s.aggIdx === k) { + seriesIds.push(s.seriesId); + color = s.color; + } + } + + const label = chart._aggregates[k]; + const hidden = seriesIds.every((sid) => chart._hiddenSeries.has(sid)); + const r = Math.round(color[0] * 255); + const g = Math.round(color[1] * 255); + const b = Math.round(color[2] * 255); + + ctx.globalAlpha = hidden ? 0.3 : 1.0; + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize); + + ctx.fillStyle = theme.legendText; + ctx.fillText(label, x + swatchSize + 6, y); + + const textW = ctx.measureText(label).width; + if (hidden) { + ctx.strokeStyle = theme.legendText; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x + swatchSize + 6, y); + ctx.lineTo(x + swatchSize + 6 + textW, y); + ctx.stroke(); + } + + ctx.globalAlpha = 1.0; + + chart._legendRects.push({ + seriesIds, + rect: { + x: x - 2, + y: y - lineHeight / 2, + width: swatchSize + 6 + textW + 4, + height: lineHeight, + }, + }); + + y += lineHeight; + } + + ctx.restore(); +} + /** * Cached parallel array of measured legend text widths. The legend * renderer reads from this each frame instead of re-running @@ -810,7 +1444,7 @@ function renderBarLegend(chart: SeriesChart): void { width: swatchSize + 6 + textW + 4, height: lineHeight, }; - chart._legendRects.push({ seriesId: s.seriesId, rect }); + chart._legendRects.push({ seriesIds: [s.seriesId], rect }); y += lineHeight; } @@ -828,7 +1462,12 @@ function renderBarTooltipCanvas(chart: SeriesChart): void { return; } - const layout = chart._lastLayout; + // Faceted frames anchor in the record's own cell; single-plot + // frames fall through to `_lastLayout`. + const layout = layoutForRecord(chart, b); + if (!layout) { + return; + } // Bar glyphs anchor the tooltip at the midpoint of the bar body so // it reads against a solid swatch. Line / scatter / area glyphs @@ -845,7 +1484,7 @@ function renderBarTooltipCanvas(chart: SeriesChart): void { ? chart._isHorizontal ? layout.dataToPixel(anchorV, b.xCenter) : layout.dataToPixel(b.xCenter, anchorV) - : rightAxisDataToPixel(chart, b.xCenter, anchorV); + : rightAxisDataToPixel(chart, b.xCenter, anchorV, layout); const lines = buildBarTooltipLines(chart, b); const theme = chart._resolveTheme(); @@ -863,8 +1502,9 @@ export function rightAxisDataToPixel( chart: SeriesChart, x: number, y: number, + layoutOverride?: PlotLayout, ): { px: number; py: number } { - const layout = chart._lastLayout!; + const layout = layoutOverride ?? chart._lastLayout!; const { x: px, y: py, width, height } = layout.plotRect; const tx = (x - layout.paddedXMin) / (layout.paddedXMax - layout.paddedXMin); diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts index 2597e88f4c..4c497af133 100644 --- a/packages/viewer-charts/src/ts/charts/series/series.ts +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -13,6 +13,7 @@ import type { ColumnDataMap } from "../../data/view-reader"; import type { WebGLContextManager } from "../../webgl/context-manager"; import type { ZoomConfig } from "../../interaction/zoom-controller"; +import type { FacetGrid } from "../../layout/facet-grid"; import { CategoricalYChart } from "../common/categorical-y-chart"; import { type PlotRect } from "../../layout/plot-layout"; import { type AxisDomain } from "../../axis/numeric-axis"; @@ -123,6 +124,35 @@ export class SeriesChart extends CategoricalYChart { _splitPrefixes: string[] = []; _series: SeriesInfo[] = []; + /** + * Whether the CURRENT build's geometry was produced for facet-grid + * mode (`facet_mode: "grid"` with real splits). The render / hover + * paths branch on this — never on the live `_facetConfig` — so a + * `setPluginConfig` that flips `facet_mode` between a build and a + * redraw can't pair grid-mode rendering with overlay-built stack + * geometry (or vice versa). The host always follows a + * `plugin.restore` with an `update` → `loadAndRender`, so the flag + * catches up one build later. + */ + _facetActive = false; + + /** + * Facet grid of the most recent faceted frame — one cell per + * split group. `null` in overlay / no-split frames. Read by the + * chrome overlay, hover hit-test, and tooltip pin to resolve the + * per-facet `PlotLayout`. + */ + _facetGrid: FacetGrid | null = null; + + /** + * Per-split contiguous instance ranges in the uploaded bar + * buffers, indexed by `splitIdx`. Populated by + * `uploadBarInstances` when `_facetActive` (instances are emitted + * split-major); `null` in overlay mode. Each facet's draw binds + * the instance attributes at `start` and draws `count` instances. + */ + _facetBarRanges: { start: number; count: number }[] | null = null; + /** * Columnar bar/area record storage. Indexed by bar slot in * `[0, _bars.count)`. Replaces the legacy `SeriesChartRecord[]` to @@ -205,8 +235,12 @@ export class SeriesChart extends CategoricalYChart { /** * `domain_mode: "expand"` accumulators. Hold the running union of - * every prior build's value-axis (and, in numeric-category mode, - * category-axis) extent for as long as the option is active. + * every prior build's VALUE-axis extents (primary + alt) for as + * long as the option is active. The category axis deliberately has + * NO accumulator: `domain_mode` scopes to the value axis only on + * band charts (Y for the Y-family, X for X Bar) — a numeric or + * datetime `group_by` axis always fits the current data, so a + * streaming chart's time axis releases departed categories. * Cleared in `resetExpandedDomain` — wired from the worker's * `resetAllZooms` and from view-config mutations on the base * class. `null` whenever the option is `"fit"` or the accumulator @@ -214,7 +248,6 @@ export class SeriesChart extends CategoricalYChart { */ _expandedLeftDomain: { min: number; max: number } | null = null; _expandedRightDomain: { min: number; max: number } | null = null; - _expandedCategoryDomain: { min: number; max: number } | null = null; /** * Numeric category-axis state. Populated only when `group_by` has @@ -296,7 +329,13 @@ export class SeriesChart extends CategoricalYChart { */ _visibleBarIndices: Int32Array = new Int32Array(0); - _legendRects: { seriesId: number; rect: PlotRect }[] = []; + /** + * Hit-test rects for legend entries. Overlay mode: one entry per + * series (`seriesIds.length === 1`). Facet-grid mode: one entry + * per AGGREGATE, carrying every split's seriesId — a legend + * toggle hides/shows the aggregate across all facets at once. + */ + _legendRects: { seriesIds: number[]; rect: PlotRect }[] = []; /** * Cached legend layout — recomputed only on series-set / palette / @@ -486,6 +525,9 @@ export class SeriesChart extends CategoricalYChart { this._cornerBuffer = createQuadCornerBuffer(gl); } + const facetSplits = + this._splitBy.length > 0 && this._facetConfig.facet_mode === "grid"; + const result = buildSeriesPipeline({ columns, numRows: endRow, @@ -504,15 +546,21 @@ export class SeriesChart extends CategoricalYChart { bandInnerFrac: this._pluginConfig.band_inner_frac, barInnerPad: this._pluginConfig.bar_inner_pad, includeZero: this._pluginConfig.include_zero, + facetSplits, scratchBars: this._bars, scratchPosStack: this._posStackScratch, scratchNegStack: this._negStackScratch, }); - // `domain_mode: "expand"` post-build union. Each call mutates - // the pipeline result in place so the downstream assignments - // below (`_leftDomain`, `_rightDomain`, `_numericCategoryDomain`, - // `_categoryOrigin`) automatically pick up the grown extent. + // `domain_mode: "expand"` post-build union — VALUE axes only + // (`leftDomain`/`rightDomain` are the logical value domains, + // orientation-agnostic, so this is Y for the Y-family and X for + // X Bar). The category axis is NEVER expanded: it identifies + // the data, and pinning a numeric/datetime `group_by` axis to + // departed categories froze streaming charts on their full + // history. Each call mutates the pipeline result in place so + // the downstream assignments below (`_leftDomain`, + // `_rightDomain`) automatically pick up the grown extent. // `"fit"` (or a fresh reset) leaves the result untouched and // clears the accumulators so the next toggle starts fresh. if (this._pluginConfig.domain_mode === "expand") { @@ -527,22 +575,18 @@ export class SeriesChart extends CategoricalYChart { result.rightDomain, ); } - - // // TODO(texodus): I don't think this is right, its - if (result.numericCategoryDomain) { - this._expandedCategoryDomain = expandDomainInPlace( - this._expandedCategoryDomain, - result.numericCategoryDomain, - ); - } } else { this._expandedLeftDomain = null; this._expandedRightDomain = null; - this._expandedCategoryDomain = null; } this._aggregates = result.aggregates; this._splitPrefixes = result.splitPrefixes; + + // Stamp AFTER the build so render always branches on the mode + // the geometry was actually built for. A single "" prefix means + // no real splits — facet mode degenerates to the single plot. + this._facetActive = facetSplits && result.splitPrefixes.length > 1; this._rowPaths = result.rowPaths; this._numCategories = result.numCategories; this._rowOffset = result.rowOffset; @@ -647,7 +691,6 @@ export class SeriesChart extends CategoricalYChart { override resetExpandedDomain(): void { this._expandedLeftDomain = null; this._expandedRightDomain = null; - this._expandedCategoryDomain = null; } protected destroyInternal(): void { @@ -663,6 +706,9 @@ export class SeriesChart extends CategoricalYChart { this._program = null; this._locations = null; this._cornerBuffer = null; + this._facetActive = false; + this._facetGrid = null; + this._facetBarRanges = null; this._bars = emptyBarColumns(); this._series = []; this._barSeries = []; @@ -727,9 +773,16 @@ function uniqueAggLabels(series: SeriesInfo[], axis: 0 | 1): string { /** * Resolve the per-series palette and stamp it onto `_series[i].color`. * Cached on `_paletteCache` keyed by reference identity of the theme - * inputs + series count — only `restyle()` (which clears `_paletteCache` - * via `invalidateTheme`) or a data load (which clears it explicitly) - * forces re-resolution. + * inputs + resolved color count — only `restyle()` (which clears + * `_paletteCache` via `invalidateTheme`) or a data load (which clears + * it explicitly) forces re-resolution. + * + * Facet-grid mode colors by AGGREGATE, not by (agg × split) series: + * the facet is the axis of splitting, so aggregate `k` renders the + * same color in every facet and the legend lists aggregates once. + * Overlay mode keeps the per-series palette (splits are distinguished + * by color there). `_facetActive` only changes at build time, which + * clears the cache, so the stamp mode can't go stale. * * Returns true when the cache changed (caller invalidates color upload). */ @@ -737,7 +790,9 @@ export function ensurePalette(chart: SeriesChart): boolean { const theme = chart._resolveTheme(); const seriesPalette = theme.seriesPalette; const gradientStops = theme.gradientStops; - const seriesLength = chart._series.length; + const paletteCount = chart._facetActive + ? Math.max(1, chart._aggregates.length) + : chart._series.length; const key = chart._paletteCacheKey; if ( @@ -745,7 +800,7 @@ export function ensurePalette(chart: SeriesChart): boolean { key && key.seriesPalette === seriesPalette && key.gradientStops === gradientStops && - key.seriesLength === seriesLength + key.seriesLength === paletteCount ) { return false; } @@ -753,13 +808,19 @@ export function ensurePalette(chart: SeriesChart): boolean { const palette = resolvePaletteCached( seriesPalette, gradientStops, - seriesLength, + paletteCount, ); chart._paletteCache = palette; - chart._paletteCacheKey = { seriesPalette, gradientStops, seriesLength }; + chart._paletteCacheKey = { + seriesPalette, + gradientStops, + seriesLength: paletteCount, + }; for (let i = 0; i < chart._series.length; i++) { - chart._series[i].color = palette[i]; + chart._series[i].color = chart._facetActive + ? palette[chart._series[i].aggIdx] + : palette[i]; } return true; diff --git a/packages/viewer-charts/src/ts/data/view-reader.ts b/packages/viewer-charts/src/ts/data/view-reader.ts index 4673a2578a..ac417409ea 100644 --- a/packages/viewer-charts/src/ts/data/view-reader.ts +++ b/packages/viewer-charts/src/ts/data/view-reader.ts @@ -50,10 +50,19 @@ export interface TypedArrayWindowOptions { * `Promise`, the underlying `with_typed_arrays` call awaits it before * releasing the backing Arrow buffer. Callers must not retain any * `ColumnData` reference past `render`'s resolution. + * + * `render` also receives the DELIVERED row count — the shortest + * column's length, which may be less than the requested `end_row`: + * the engine clamps the window to the view's size at fetch time, and + * on a fast-updating table with deletions that size can shrink after + * the caller's `num_rows()` round-trip. Build pipelines must iterate + * to THIS count, never the requested one — a typed array indexed past + * its length returns `undefined`, which no amount of validity-bitmap + * checking catches (the bitmap read is out-of-bounds too). */ export async function viewToColumnDataMap( view: View, - render: (data: ColumnDataMap) => void | Promise, + render: (data: ColumnDataMap, numRows: number) => void | Promise, options?: TypedArrayWindowOptions, ): Promise { const result: ColumnDataMap = new Map(); @@ -101,7 +110,18 @@ export async function viewToColumnDataMap( } } - await render(result); + let numRows = 0; + if (result.size > 0) { + numRows = Infinity; + for (const col of result.values()) { + const n = col.indices?.length ?? col.values?.length ?? 0; + if (n < numRows) { + numRows = n; + } + } + } + + await render(result, numRows); }, ); } diff --git a/packages/viewer-charts/src/ts/layout/ticks.ts b/packages/viewer-charts/src/ts/layout/ticks.ts index 4c8ef9ab93..08eed8cfc4 100644 --- a/packages/viewer-charts/src/ts/layout/ticks.ts +++ b/packages/viewer-charts/src/ts/layout/ticks.ts @@ -72,8 +72,17 @@ export function computeNiceTicks( /** * Format a numeric tick value for display. * Uses K/M/B suffixes for large numbers, fixed decimals for small. + * + * Total over any input: label formatters run inside render passes, so a + * non-finite value (or `undefined` smuggled in by an upstream + * out-of-bounds read) must degrade to a placeholder, never throw the + * frame away. */ export function formatTickValue(val: number): string { + if (!Number.isFinite(val)) { + return "-"; + } + const abs = Math.abs(val); if (abs === 0) { return "0"; diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index a98308635d..26efda69e2 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -48,9 +48,12 @@ export interface ChartTypeConfig { * Per-chart-type overrides for `DEFAULT_PLUGIN_CONFIG`. Used when a * field's sensible default differs by chart family — currently * `include_zero` (true for Y Bar / Y Area / X Bar, false for line - * / scatter / cartesian / financial). Applied at schema generation - * and at `restore({})` so the effective default matches the - * surfaced UI default. + * / scatter / cartesian / financial) and `facet_mode` ("overlay" + * for the band-pipeline families — series / financial — whose + * historical split_by rendering is the single stacked/colored + * plot; "grid" elsewhere). Applied at schema generation and at + * `restore({})` so the effective default matches the surfaced UI + * default. */ plugin_field_defaults?: Partial; } @@ -85,10 +88,19 @@ const SERIES_FIELDS: readonly PluginConfigField[] = [ "bar_inner_pad", ]; +// The band pipeline's historical split_by rendering is a single plot +// (splits stacked / colored in place), so the series family defaults +// `facet_mode` to "overlay" — grid faceting is the opt-in. Cartesian +// charts keep the global "grid" default from `DEFAULT_PLUGIN_CONFIG`. +const SERIES_DEFAULTS: Partial = { facet_mode: "overlay" }; + // Bar / area series glyphs grow from the zero baseline, so the value // axis must enclose 0 to render correctly. Line / scatter glyphs have // no such constraint — their default `include_zero` stays `false`. -const ZERO_ANCHORED_DEFAULTS: Partial = { include_zero: true }; +const ZERO_ANCHORED_DEFAULTS: Partial = { + ...SERIES_DEFAULTS, + include_zero: true, +}; // Pure Cartesian (X/Y Scatter, X/Y Line) — no categorical axis, so no // band geometry; gets the facet-routing variant of zoom_mode. @@ -210,9 +222,11 @@ const CHARTS: ChartTypeConfig[] = [ }), make("Y Line", "y-line", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { default_chart_type: "line", + plugin_field_defaults: SERIES_DEFAULTS, }), make("Y Scatter", "y-scatter", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { default_chart_type: "scatter", + plugin_field_defaults: SERIES_DEFAULTS, }), make("Y Area", "y-area", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { default_chart_type: "area", @@ -250,9 +264,11 @@ const CHARTS: ChartTypeConfig[] = [ make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS), make("Candlestick", "candlestick", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { default_chart_type: "candlestick", + plugin_field_defaults: SERIES_DEFAULTS, }), make("OHLC", "ohlc", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { default_chart_type: "ohlc", + plugin_field_defaults: SERIES_DEFAULTS, }), make( "Map Scatter", diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index 38aa0f6c48..a21c3b6e78 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -614,7 +614,45 @@ export class HTMLPerspectiveViewerWebGLPluginElement return; } - this._renderer?.resize(); + // AWAITED to the resized frame's PRESENT (the worker's + // `resizeAck`) — the host's presize protocol style-overrides + // this element to its target box and holds the layout commit on + // this promise, so resolving at message-post would commit the + // settings-pane layout against the old-dimensions bitmap (the + // aspect-ratio warp the datagrid's synchronous resize never + // shows). + await this._renderer?.resize(); + } + + /** + * OPTIONAL host presize protocol: render at the TARGET element box + * `(width, height)` — the box the host's pending layout commit will + * produce — holding the resulting frame offscreen, so nothing on + * screen changes during the round-trip. Resolves, once the resized + * frame is staged, to a present closure; the host calls it in the + * same task as the layout commit, landing geometry and pixels in + * one paint. Plugins without this method get the host's held + * style-override presize path instead. + */ + async presize(width: number, height: number): Promise<(() => void) | void> { + if ( + !this.isConnected || + this.offsetParent == null || + !this._renderer || + !this._glCanvas + ) { + return; + } + + // Target GL-canvas box = its current box shifted by the element's + // box delta — the chrome between the element edge and the canvas + // is constant across a resize. + const hostRect = this.getBoundingClientRect(); + const glRect = this._glCanvas.getBoundingClientRect(); + return await this._renderer.presize( + Math.max(0, glRect.width + (width - hostRect.width)), + Math.max(0, glRect.height + (height - hostRect.height)), + ); } restyle() { diff --git a/packages/viewer-charts/src/ts/transport/protocol.ts b/packages/viewer-charts/src/ts/transport/protocol.ts index a0da3a6afe..21e4662aa1 100644 --- a/packages/viewer-charts/src/ts/transport/protocol.ts +++ b/packages/viewer-charts/src/ts/transport/protocol.ts @@ -54,6 +54,7 @@ export type WorkerMsg = | UserClickMsg | UserSelectMsg | LoadAndRenderAckMsg + | ResizeAckMsg | FrameBitmapMsg | ErrorMsg; @@ -312,13 +313,33 @@ export interface DeselectMsg { kind: "deselect"; } +/** + * Host → worker: resize the chart to a new CSS box. Replied with + * {@link ResizeAckMsg} AFTER the resized frame PRESENTS — the host's + * `plugin.resize()` promise is the viewer's presize contract ("the + * plugin has repainted at the target box when this resolves"), so an + * ack at message-receipt would let the settings-pane layout commit + * against the old-dimensions bitmap (the aspect-ratio warp). + */ export interface ResizeMsg { kind: "resize"; + msgId: number; cssWidth: number; cssHeight: number; dpr: number; } +/** + * Worker → host reply to a `ResizeMsg`, posted after the resized + * frame's present completes. Always sent — present failures and + * torn-down charts included — so the host's awaited promise resolves + * deterministically. + */ +export interface ResizeAckMsg { + kind: "resizeAck"; + msgId: number; +} + export interface ClearMsg { kind: "clear"; } diff --git a/packages/viewer-charts/src/ts/transport/renderer-transport.ts b/packages/viewer-charts/src/ts/transport/renderer-transport.ts index 5fdbe0a550..8552a9440e 100644 --- a/packages/viewer-charts/src/ts/transport/renderer-transport.ts +++ b/packages/viewer-charts/src/ts/transport/renderer-transport.ts @@ -84,7 +84,11 @@ interface RendererHandle { terminate(): void; } -type PendingRenderType = "saveZoom" | "loadAndRender" | "snapshotPng"; +type PendingRenderType = + | "saveZoom" + | "loadAndRender" + | "snapshotPng" + | "resize"; interface PendingRenderRequest { kind: PendingRenderType; resolve: (v: any) => void; @@ -169,6 +173,37 @@ export class RendererTransport { */ private _displayCtx: CanvasRenderingContext2D | null = null; + /** + * Present-hold state for the staged-present presize protocol + * ({@link presize}). While holding, inbound `frameBitmap`s are + * STAGED (latest wins) instead of blitted, so the on-screen frame + * — old dimensions in the old, unchanged box — stays put until the + * host invokes the present closure `presize` resolved to, in the + * same task as its layout commit. Entered only by `presize`; + * exited by the present closure, by `presize`'s rejection path, or + * by `destroy` — every settle path releases the hold or hands the + * caller the release. + */ + /** + * The last `(cssWidth, cssHeight, dpr)` posted to the worker — at init + * or any resize — i.e. the dimensions the worker has already rendered + * (or has an in-flight render for). {@link resize} short-circuits when + * its measurement matches (±0.5px, the presize protocol's own + * threshold): reactive resizes fan in from several host paths per + * layout transition (the post-commit fan-out, activation nudges, the + * fresh-restore tail), and every worker resize is an unconditional + * full re-render. + */ + private _lastPostedSize: { + cssWidth: number; + cssHeight: number; + dpr: number; + } | null = null; + + private _holdPresent = false; + + private _stagedFrame: ImageBitmap | null = null; + /** * Host-side sink for tooltip + cursor side-effects. The chart * inside the renderer calls into a `MessageHostSink` that posts @@ -314,6 +349,11 @@ export class RendererTransport { precompileShaders: this._precompileShaders, }; + this._lastPostedSize = { + cssWidth: rect.width, + cssHeight: rect.height, + dpr, + }; this._handle = await this._createHandle(workerURL, initMsg); this._handle.addMessageListener((msg) => this._handleRendererMsg(msg as WorkerMsg), @@ -487,19 +527,87 @@ export class RendererTransport { this._post({ kind: "deselect" }); } - resize(): void { + /** + * Resize the chart to the GL canvas's CURRENT CSS box and resolve + * once the resized frame has PRESENTED (the worker's `resizeAck`) — + * not at message-post, which would let callers proceed while the + * old-dimensions bitmap is still on screen. Reactive geometry + * changes only; anticipated ones go through {@link presize}. + * + * A measurement matching {@link _lastPostedSize} resolves immediately + * WITHOUT a worker round trip: the worker has already rendered (or is + * presenting) that exact frame, and re-posting would be a full + * re-render of an identical bitmap. + */ + resize(): Promise { if (!this._hostGlCanvas) { - return; + return Promise.resolve(); } const rect = this._hostGlCanvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; + const last = this._lastPostedSize; + if ( + last && + Math.abs(last.cssWidth - rect.width) <= 0.5 && + Math.abs(last.cssHeight - rect.height) <= 0.5 && + last.dpr === dpr + ) { + return Promise.resolve(); + } + + return this._postResize(rect.width, rect.height); + } + + /** + * Staged-present variant of {@link resize}: render at the given + * TARGET canvas CSS box (no measurement — the caller supplies the + * box the pending layout commit will produce), HOLDING the + * resulting frame (and any others that arrive meanwhile) offscreen + * instead of blitting, so nothing on screen changes. Resolves, + * once the resized frame is staged, to a present closure: calling + * it blits the latest staged frame and exits the hold — the host + * runs it in the same task as its layout commit so geometry and + * pixels land in one paint. On rejection (teardown) the hold is + * released here, so every settle path either frees the display or + * hands the caller the release. + */ + presize(cssWidth: number, cssHeight: number): Promise<(() => void) | void> { + if (!this._hostGlCanvas) { + return Promise.resolve(); + } + + this._holdPresent = true; + return this._postResize(cssWidth, cssHeight).then( + () => () => this._presentStaged(), + (err) => { + this._presentStaged(); + throw err; + }, + ); + } + + private _postResize(cssWidth: number, cssHeight: number): Promise { + const dpr = window.devicePixelRatio || 1; + const { id, promise } = this._allocPending("resize"); + this._lastPostedSize = { cssWidth, cssHeight, dpr }; this._post({ kind: "resize", - cssWidth: rect.width, - cssHeight: rect.height, + msgId: id, + cssWidth, + cssHeight, dpr, }); + return promise; + } + + private _presentStaged(): void { + this._holdPresent = false; + const staged = this._stagedFrame; + this._stagedFrame = null; + if (staged) { + this._drawFrameBitmap(staged); + } } clear() { @@ -623,6 +731,9 @@ export class RendererTransport { // them, and so the GPU-backed 2D context can release earlier. this._hostGlCanvas = null; this._displayCtx = null; + this._holdPresent = false; + this._stagedFrame?.close(); + this._stagedFrame = null; // Drain pending request promises with kind-aware semantics: // - `loadAndRender` resolves silently (the host's awaited draw @@ -728,7 +839,13 @@ export class RendererTransport { } case "frameBitmap": - this._drawFrameBitmap(msg.bitmap); + if (this._holdPresent) { + this._stagedFrame?.close(); + this._stagedFrame = msg.bitmap; + } else { + this._drawFrameBitmap(msg.bitmap); + } + break; case "error": this._rejectReady(new Error(msg.message)); @@ -736,6 +853,9 @@ export class RendererTransport { case "loadAndRenderAck": this._resolvePending(msg.msgId, "loadAndRender", undefined); break; + case "resizeAck": + this._resolvePending(msg.msgId, "resize", undefined); + break; case "snapshotPngReply": this._resolvePending(msg.requestId, "snapshotPng", msg.blob); break; diff --git a/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts b/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts index a139119881..c282dbc68d 100644 --- a/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts +++ b/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts @@ -108,6 +108,12 @@ export function createQuadCornerBuffer(gl: GL): WebGLBuffer { * paint zero data). No-op return `true` when `attr` is negative * (optimized-out attribute — drawing the rest is still valid). * + * `firstInstance` byte-offsets the attribute pointer so the draw's + * instance 0 reads element `firstInstance` of the buffer — the WebGL + * substitute for a `baseInstance` draw parameter. Faceted renderers + * use it to draw a contiguous per-facet sub-range of a shared + * instance buffer. + * * Render-path uses `peek`, never `getOrCreate`: the latter recreates * with zero-initialized contents when `_totalCapacity` has grown past * the current buffer, which would wipe the previous draw's vertex @@ -120,6 +126,7 @@ export function bindInstancedFloatAttr( attr: number, name: string, components: number, + firstInstance = 0, ): boolean { if (attr < 0) { return true; @@ -133,7 +140,14 @@ export function bindInstancedFloatAttr( const gl: GL = glManager.gl; gl.bindBuffer(gl.ARRAY_BUFFER, buf.buffer); gl.enableVertexAttribArray(attr); - gl.vertexAttribPointer(attr, components, gl.FLOAT, false, 0, 0); + gl.vertexAttribPointer( + attr, + components, + gl.FLOAT, + false, + 0, + firstInstance * components * Float32Array.BYTES_PER_ELEMENT, + ); instancing.setDivisor(attr, 1); return true; } diff --git a/packages/viewer-charts/src/ts/worker/dispatch.ts b/packages/viewer-charts/src/ts/worker/dispatch.ts index 3f74355d04..cbbfed7a98 100644 --- a/packages/viewer-charts/src/ts/worker/dispatch.ts +++ b/packages/viewer-charts/src/ts/worker/dispatch.ts @@ -45,7 +45,7 @@ export function dispatch(r: WorkerRenderer, msg: ControlMsg): void { break; case "resize": r.resize(msg.cssWidth, msg.cssHeight, msg.dpr); - r.redraw(); + r.redrawAck(msg.msgId); break; case "clear": r.clear(); diff --git a/packages/viewer-charts/src/ts/worker/renderer.worker.ts b/packages/viewer-charts/src/ts/worker/renderer.worker.ts index cea1362fde..a95ca29cd1 100644 --- a/packages/viewer-charts/src/ts/worker/renderer.worker.ts +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -295,16 +295,23 @@ export class WorkerRenderer { try { await viewToColumnDataMap( this.view, - async (cols) => { + async (cols, deliveredRows) => { if (this._renderGen !== myGen) { throw new StaleGenerationError(); } + // `totalRows` came from a SEPARATE `num_rows()` + // round-trip; a fast-updating table with deletions + // can shrink the view before the Arrow fetch, which + // clamps its window silently. Iterating to the stale + // count reads typed arrays out-of-bounds (`undefined` + // values no validity bitmap covers) — clamp to what + // was actually delivered. await this.chartImpl.uploadAndRender( this.glManager, cols, 0, - totalRows, + Math.min(totalRows, deliveredRows), ); }, { end_row: totalRows, float32: msg.options.float32 }, @@ -327,6 +334,24 @@ export class WorkerRenderer { this.chartImpl.requestRender(this.glManager); } + /** + * `redraw`, acked to the host AFTER the frame's present completes — + * the reply backing the viewer's presize contract (`ResizeAckMsg`): + * `plugin.resize()` must not resolve until the resized frame is + * actually on screen. A resize landing mid-present defers its + * canvas mutation (`deferIfDraining`), but the redraw's own frame + * applies pending dimensions in `beginFrame`, so the present this + * awaits IS at the new size. Always acks — a failed present or a + * torn-down chart resolves the host's await rather than stranding + * it (completion, not success, is the contract). + */ + redrawAck(msgId: number): void { + this.chartImpl + .requestRender(this.glManager) + .catch(() => {}) + .finally(() => this.post({ kind: "resizeAck", msgId })); + } + resize(cssWidth: number, cssHeight: number, dpr: number): void { // `glManager.resize` would set `canvas.width = N`, which the // spec mandates clears the drawing buffer immediately. In diff --git a/packages/viewer-charts/test/ts/domain-mode.spec.ts b/packages/viewer-charts/test/ts/domain-mode.spec.ts new file mode 100644 index 0000000000..d066caeb5b --- /dev/null +++ b/packages/viewer-charts/test/ts/domain-mode.spec.ts @@ -0,0 +1,306 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * `plugin_config.domain_mode` axis-scope contract + * (`.plan/DOMAIN_MODE_PLAN.md`): + * + * - Cartesian charts (X/Y Scatter, X/Y Line): `"expand"` applies to + * BOTH axes (+ color/size scales). + * - Band charts (Y Bar / Y Line / Y Scatter / Y Area / X Bar): + * `"expand"` applies to the VALUE axis ONLY — the category axis + * (numeric/datetime `group_by`) always fits the current data, so a + * streaming time axis releases departed categories. + * + * The accumulators only live across DATA updates — any `plugin.draw` + * (view-config change) resets them — so every scenario drives + * `table.remove` on the indexed fixture table via `window.__TEST_WORKER__` + * and waits for the update-redraw (debounced + throttled) by polling + * pixel counts. SwiftShader is deterministic; thresholds are loose only + * to absorb AA. + */ + +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); + + // Full data spans the plot; the right side is populated. + expect( + await pollPixels(page, RIGHT, (n) => n > POPULATED_MIN), + ).toBeGreaterThan(POPULATED_MIN); + + // Keep only the earliest dates. Under the `"expand"` DEFAULT the + // value axis may retain its extent, but the category (X) axis + // must REFIT — the surviving line spreads across the full plot + // width instead of compressing into the historical range's + // leading sliver. + 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, + }) => { + // One row per day (sums = values): a small ±10 wiggle plus a + // SYMMETRIC ±1000 extreme pair MID-SERIES (so the spike's x + // position falls inside the sampled band, not at the plot edge). + // The symmetric domain centers zero MID-plot. + 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); + + // The +1000 spike reaches the top of the plot. + expect( + await pollPixels(page, TOP, (n) => n > EMPTY_MAX), + ).toBeGreaterThan(EMPTY_MAX); + + // Drop the ±1000 extremes: survivors are ±10. Under `"expand"` + // (default) the Y axis RETAINS [-1000, 1000], so the line hugs + // zero MID-plot and the top band empties. + await removeExtremes(page, "Profit", 20); + expect(await pollPixels(page, TOP, (n) => n < EMPTY_MAX)).toBeLessThan( + EMPTY_MAX, + ); + + // `"fit"` recomputes: the surviving ±10 extent stretches to the + // plot and the line reaches the top band again. + 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); + + // Keep only the earliest dates (rendered from the top). The + // category axis is Y here — it must refit so the surviving bars + // spread down the full plot height. + 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, + }) => { + // `Sales` spread uniformly over [0, 975]; `Profit` is 0 for every + // point except two low-`Sales` ±500 anchors that stretch the Y + // domain, parking the point mass MID-plot (inside the band's + // y-range — a value AT the domain extreme renders at the plot + // edge, outside it). Rows with `Sales > 500` are the removable + // extremes; the anchors survive. + 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); + + // Drop the high-Sales rows. Cartesian `"expand"` (default) keeps + // the X extent, so survivors stay clustered at the left and the + // right side EMPTIES — the axis did not refit. + await removeExtremes(page, "Sales", 500); + expect( + await pollPixels(page, RIGHT, (n) => n < EMPTY_MAX), + ).toBeLessThan(EMPTY_MAX); + + // Control: `"fit"` refits X and the survivors spread back into + // the right side. + 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/facet-mode.spec.ts b/packages/viewer-charts/test/ts/facet-mode.spec.ts new file mode 100644 index 0000000000..6eb4ac8ebe --- /dev/null +++ b/packages/viewer-charts/test/ts/facet-mode.spec.ts @@ -0,0 +1,180 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Regression tests for `plugin_config.facet_mode` across chart + * families. + * + * The series family (X Bar / Y Bar / Y Line / Y Scatter / Y Area) + * defaults to `"overlay"` — the band pipeline's historical split_by + * rendering — and implements `"grid"` as opt-in small multiples (one + * facet per split group, per-split stack baselines, shared value + * axis). The cartesian family defaults to `"grid"` with `"overlay"` + * opt-in. Treemap / Sunburst do not advertise the field at all. + * + * Two failure modes are covered: + * 1. Reachability — the host schema-filters `plugin_config` on + * every write path, so a value only round-trips + * `save()`/`restore()` when the chart type advertises the key + * AND the value differs from the schema-declared default. + * 2. Effect — SwiftShader is deterministic, so "config had no + * effect" reproduces as byte-equal frames; a real layout change + * always shifts the non-background pixel count. + */ + +import { expect, test } from "@perspective-dev/test"; +import { + calibratePlotBaseline, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +async function schemaFields( + page: import("@playwright/test").Page, +): Promise<{ key: string; default?: unknown }[]> { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + const plugin = await (viewer as any).getPlugin(); + const schema = plugin.plugin_config_schema?.() ?? { fields: [] }; + return schema.fields.map((f: { key: string; default?: unknown }) => ({ + key: f.key, + default: f.default, + })); + }); +} + +async function savedPluginConfig(page: import("@playwright/test").Page) { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + const config = await (viewer as any).save(); + return config.plugin_config ?? {}; + }); +} + +async function setFacetMode( + page: import("@playwright/test").Page, + facet_mode: string, +) { + await restoreChart(page, { plugin_config: { facet_mode } } as never); + await waitOneFrame(page); + await waitOneFrame(page); +} + +test.describe("facet_mode", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("Y Line defaults to overlay; grid renders small multiples and round-trips", async ({ + page, + }) => { + await restoreChart(page, { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], + split_by: ["Category"], + } as never); + await waitOneFrame(page); + + const facetField = (await schemaFields(page)).find( + (f) => f.key === "facet_mode", + ); + expect(facetField).toBeDefined(); + expect(facetField!.default).toEqual("overlay"); + + const overlayPixels = await calibratePlotBaseline(page); + + await setFacetMode(page, "grid"); + expect(await savedPluginConfig(page)).toEqual({ facet_mode: "grid" }); + const gridPixels = await calibratePlotBaseline(page); + expect(gridPixels).not.toEqual(overlayPixels); + + // Explicit default resets the key (empty ⇒ reads-default) and + // restores the overlay rendering. + await setFacetMode(page, "overlay"); + expect(await savedPluginConfig(page)).toEqual({}); + expect(await calibratePlotBaseline(page)).toEqual(overlayPixels); + }); + + test("Y Bar grid mode unstacks splits into facets", async ({ page }) => { + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category"], + } as never); + await waitOneFrame(page); + + const overlayPixels = await calibratePlotBaseline(page); + + await setFacetMode(page, "grid"); + expect(await savedPluginConfig(page)).toEqual({ facet_mode: "grid" }); + expect(await calibratePlotBaseline(page)).not.toEqual(overlayPixels); + }); + + test("X Bar grid mode facets horizontally", async ({ page }) => { + await restoreChart(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category"], + } as never); + await waitOneFrame(page); + + const overlayPixels = await calibratePlotBaseline(page); + + await setFacetMode(page, "grid"); + expect(await savedPluginConfig(page)).toEqual({ facet_mode: "grid" }); + expect(await calibratePlotBaseline(page)).not.toEqual(overlayPixels); + }); + + test("X/Y Scatter defaults to grid; overlay round-trips and re-renders", async ({ + page, + }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Sales", "Profit"], + split_by: ["Category"], + } as never); + await waitOneFrame(page); + + const facetField = (await schemaFields(page)).find( + (f) => f.key === "facet_mode", + ); + expect(facetField).toBeDefined(); + expect(facetField!.default).toEqual("grid"); + + const gridPixels = await calibratePlotBaseline(page); + + await setFacetMode(page, "overlay"); + expect(await savedPluginConfig(page)).toEqual({ + facet_mode: "overlay", + }); + expect(await calibratePlotBaseline(page)).not.toEqual(gridPixels); + }); + + test("Treemap and Sunburst do not advertise facet_mode", async ({ + page, + }) => { + for (const plugin of ["Treemap", "Sunburst"]) { + await restoreChart(page, { + plugin, + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category"], + } as never); + const keys = (await schemaFields(page)).map((f) => f.key); + expect(keys).not.toContain("facet_mode"); + } + }); +}); diff --git a/packages/viewer-charts/test/ts/helpers.ts b/packages/viewer-charts/test/ts/helpers.ts index e3dab136d9..756c8212c9 100644 --- a/packages/viewer-charts/test/ts/helpers.ts +++ b/packages/viewer-charts/test/ts/helpers.ts @@ -61,15 +61,17 @@ export async function waitOneFrame(page: Page): Promise { } /** - * Take a screenshot of the viewer element (not the whole page) and - * compare to `name`'s baseline. Cropping to the viewer excludes page - * scrollbars / viewport chrome that would add pixel noise. + * Take a screenshot of the CHART (the slotted plugin element) and compare + * to `name`'s baseline. */ export async function expectViewerScreenshot( page: Page, options: { maxDiffPixelRatio?: number } = {}, ): Promise { - const viewer = page.locator("perspective-viewer"); + const chart = page.locator( + 'perspective-viewer > [slot]:not([slot^="tab-"])', + ); + const snapshotName = test .info() @@ -82,7 +84,7 @@ export async function expectViewerScreenshot( ) .join("-") + ".png"; - await expect(viewer).toHaveScreenshot(snapshotName, { + await expect(chart).toHaveScreenshot(snapshotName, { threshold: DEFAULT_THRESHOLD, maxDiffPixelRatio: options.maxDiffPixelRatio ?? DEFAULT_MAX_DIFF_PIXEL_RATIO, diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts index 39c6baa52f..a348c35d01 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -245,6 +245,41 @@ export class HTMLPerspectiveViewerDatagridPluginElement } } + /** + * Host presize protocol: stage a render for the TARGET element box + * `(width, height)` — the box the host's pending layout commit will + * produce — via `regular-table`'s `predraw()`, which runs the data + * fetch and viewport calculation now without touching the visible + * DOM. Resolves to the commit closure; the host invokes it in the + * same task as the layout commit, landing geometry and cells in one + * paint. + * + * The `predraw()` box is derived by delta: `regular-table` fills this + * element with constant chrome, so the element's box delta IS the + * table's. When column widths for the target viewport aren't yet + * measured (first paint, post-`resetAutoSize`), `predraw()` draws + * inline and the closure no-ops — the pre-staging behavior, degraded + * not broken. + */ + async presize(width: number, height: number): Promise<(() => void) | void> { + if ( + !this.isConnected || + this.offsetParent == null || + !this._initialized + ) { + return; + } + + const rect = this.getBoundingClientRect(); + return await this.regular_table.predraw( + Math.max(0, this.regular_table.clientWidth + (width - rect.width)), + Math.max( + 0, + this.regular_table.clientHeight + (height - rect.height), + ), + ); + } + async clear(): Promise { this.regular_table.resetAutoSize(); this.regular_table.clear(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d89e4a99d..bed6c2a8a3 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.5' - version: 0.8.5 + specifier: '=0.8.6' + version: 0.8.6 stoppable: specifier: '=1.1.0' version: 1.1.0 @@ -788,7 +788,7 @@ importers: version: link:../../rust/perspective-viewer regular-table: specifier: 'catalog:' - version: 0.8.5 + version: 0.8.6 devDependencies: '@perspective-dev/esbuild-plugin': specifier: 'workspace:' @@ -2197,9 +2197,6 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - '@types/lodash@4.17.20': - resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} - '@types/lru-cache@5.1.1': resolution: {integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==} @@ -4372,8 +4369,8 @@ packages: regular-layout@0.6.1: resolution: {integrity: sha512-vGDEFACgFbS/d5kLWJ6AMWY/3DYaZoF1P8+y4KIDnPUFqUQgV5bV8avX7836izmexfmz6ik8JZ4V0Xo4FhaZGQ==} - regular-table@0.8.5: - resolution: {integrity: sha512-2ow89unuOZChNDG72ZghZ7NIOIOJKMEUmJp1En+IOyxT4ZoHrFs446WmcyqltI5Gze2yf8lpZyOkg/FQBHvqBw==} + regular-table@0.8.6: + resolution: {integrity: sha512-jzJzu9WLtqwMgbf5ak/VdNm/YLHUDnDSCHD5SOksnHrHLuDN6l5i2stYfe7BjsHbQV1Gx9369Ma40nTBCgOFvA==} engines: {node: '>=16'} relateurl@0.2.7: @@ -5879,23 +5876,23 @@ snapshots: '@lumino/widgets': 2.7.1 ajv: 8.17.1 commander: 9.5.0 - css-loader: 6.11.0(webpack@5.102.1(webpack-cli@5.1.4)) + css-loader: 6.11.0(webpack@5.102.1) duplicate-package-checker-webpack-plugin: 3.0.0 fs-extra: 10.1.0 glob: 7.1.7 - license-webpack-plugin: 2.3.21(webpack@5.102.1(webpack-cli@5.1.4)) - mini-css-extract-plugin: 2.9.4(webpack@5.102.1(webpack-cli@5.1.4)) + license-webpack-plugin: 2.3.21(webpack@5.102.1) + mini-css-extract-plugin: 2.9.4(webpack@5.102.1) mini-svg-data-uri: 1.4.4 path-browserify: 1.0.1 process: 0.11.10 - source-map-loader: 1.0.2(webpack@5.102.1(webpack-cli@5.1.4)) - style-loader: 3.3.4(webpack@5.102.1(webpack-cli@5.1.4)) + source-map-loader: 1.0.2(webpack@5.102.1) + style-loader: 3.3.4(webpack@5.102.1) supports-color: 7.2.0 terser-webpack-plugin: 5.3.14(webpack@5.102.1) webpack: 5.102.1(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack@5.102.1) webpack-merge: 5.10.0 - worker-loader: 3.0.8(webpack@5.102.1(webpack-cli@5.1.4)) + worker-loader: 3.0.8(webpack@5.102.1) transitivePeerDependencies: - '@rspack/core' - '@swc/core' @@ -6731,8 +6728,6 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 24.9.1 - '@types/lodash@4.17.20': {} - '@types/lru-cache@5.1.1': {} '@types/ms@2.1.0': {} @@ -7408,7 +7403,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@6.11.0(webpack@5.102.1(webpack-cli@5.1.4)): + css-loader@6.11.0(webpack@5.102.1): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -8477,7 +8472,7 @@ snapshots: dependencies: isomorphic.js: 0.2.5 - license-webpack-plugin@2.3.21(webpack@5.102.1(webpack-cli@5.1.4)): + license-webpack-plugin@2.3.21(webpack@5.102.1): dependencies: '@types/webpack-sources': 0.1.12 webpack-sources: 1.4.3 @@ -8654,7 +8649,7 @@ snapshots: mime@1.6.0: {} - mini-css-extract-plugin@2.9.4(webpack@5.102.1(webpack-cli@5.1.4)): + mini-css-extract-plugin@2.9.4(webpack@5.102.1): dependencies: schema-utils: 4.3.3 tapable: 2.3.0 @@ -9109,7 +9104,7 @@ snapshots: regular-layout@0.6.1: {} - regular-table@0.8.5: {} + regular-table@0.8.6: {} relateurl@0.2.7: {} @@ -9341,7 +9336,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@1.0.2(webpack@5.102.1(webpack-cli@5.1.4)): + source-map-loader@1.0.2(webpack@5.102.1): dependencies: data-urls: 2.0.0 iconv-lite: 0.6.3 @@ -9434,7 +9429,7 @@ snapshots: strip-json-comments@3.1.1: {} - style-loader@3.3.4(webpack@5.102.1(webpack-cli@5.1.4)): + style-loader@3.3.4(webpack@5.102.1): dependencies: webpack: 5.102.1(webpack-cli@5.1.4) @@ -9880,7 +9875,7 @@ snapshots: wordwrapjs@5.1.1: {} - worker-loader@3.0.8(webpack@5.102.1(webpack-cli@5.1.4)): + worker-loader@3.0.8(webpack@5.102.1): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1c7a3e71fd..5f4b59678e 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.5" + "regular-table": "=0.8.6" "stoppable": "=1.1.0" "ws": "^8.17.0" diff --git a/rust/perspective-js/src/ts/perspective.cdn.ts b/rust/perspective-js/src/ts/perspective.cdn.ts index de3cf5559b..b3a12b53ac 100644 --- a/rust/perspective-js/src/ts/perspective.cdn.ts +++ b/rust/perspective-js/src/ts/perspective.cdn.ts @@ -12,20 +12,17 @@ import perspective from "./perspective.browser.ts"; export * from "./perspective.browser.ts"; - -const url = new URL( - "../../../server/dist/wasm/perspective-server.wasm", - import.meta.url, -); - -const url64 = new URL( - "../../../server/dist/wasm/perspective-server.memory64.wasm", - import.meta.url, -); +import { resolve_server_wasm_url } from "./wasm/cdn.ts"; perspective.init_server({ - wasm32: () => fetch(url), - wasm64: () => fetch(url64), + wasm32: () => fetch(resolve_server_wasm_url(import.meta.url)), + wasm64: () => + fetch( + resolve_server_wasm_url( + import.meta.url, + "perspective-server.memory64.wasm", + ), + ), }); export default perspective; diff --git a/rust/perspective-js/src/ts/wasm/cdn.ts b/rust/perspective-js/src/ts/wasm/cdn.ts new file mode 100644 index 0000000000..ddfda4afa1 --- /dev/null +++ b/rust/perspective-js/src/ts/wasm/cdn.ts @@ -0,0 +1,32 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Resolve the URL of one of `@perspective-dev/server`'s engine WASM binaries + * relative to the URL of the `@perspective-dev/client` CDN bundle which loads + * it. + */ +export function resolve_server_wasm_url( + client_url: string, + filename: string = "perspective-server.wasm", +): string { + const rewritten = client_url.replace( + /\/client(@[^/]+)?\/dist\/cdn\/[^/?#]*([?#].*)?$/, + (_, tag) => `/server${tag ?? ""}/dist/wasm/${filename}`, + ); + + if (rewritten !== client_url) { + return rewritten; + } + + return new URL(`../../../server/dist/wasm/${filename}`, client_url).href; +} diff --git a/rust/perspective-js/test/js/cdn_url.spec.ts b/rust/perspective-js/test/js/cdn_url.spec.ts new file mode 100644 index 0000000000..274d932691 --- /dev/null +++ b/rust/perspective-js/test/js/cdn_url.spec.ts @@ -0,0 +1,104 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "@perspective-dev/test"; +import { resolve_server_wasm_url } from "../../src/ts/wasm/cdn.ts"; + +test.describe("resolve_server_wasm_url", function () { + test("preserves exact version tags on jsdelivr URLs", function () { + expect( + resolve_server_wasm_url( + "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.5.1/dist/cdn/perspective.js", + ), + ).toEqual( + "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.wasm", + ); + }); + + test("preserves range and dist-tag aliases", function () { + expect( + resolve_server_wasm_url( + "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4/dist/cdn/perspective.js", + ), + ).toEqual( + "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4/dist/wasm/perspective-server.wasm", + ); + + expect( + resolve_server_wasm_url( + "https://cdn.jsdelivr.net/npm/@perspective-dev/client@latest/dist/cdn/perspective.js", + ), + ).toEqual( + "https://cdn.jsdelivr.net/npm/@perspective-dev/server@latest/dist/wasm/perspective-server.wasm", + ); + }); + + test("resolves alternate binaries with the version tag preserved", function () { + expect( + resolve_server_wasm_url( + "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.5.1/dist/cdn/perspective.js", + "perspective-server.memory64.wasm", + ), + ).toEqual( + "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.memory64.wasm", + ); + + expect( + resolve_server_wasm_url( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js", + "perspective-server.memory64.wasm", + ), + ).toEqual( + "http://localhost:6598/node_modules/@perspective-dev/server/dist/wasm/perspective-server.memory64.wasm", + ); + }); + + test("preserves version tags on unpkg URLs", function () { + expect( + resolve_server_wasm_url( + "https://unpkg.com/@perspective-dev/client@4.5.1/dist/cdn/perspective.js", + ), + ).toEqual( + "https://unpkg.com/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.wasm", + ); + }); + + test("resolves untagged node_modules layouts to the sibling package", function () { + expect( + resolve_server_wasm_url( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js", + ), + ).toEqual( + "http://localhost:6598/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm", + ); + }); + + test("matches minified and query-string variants", function () { + expect( + resolve_server_wasm_url( + "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.5.1/dist/cdn/perspective.min.js?v=1", + ), + ).toEqual( + "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.wasm", + ); + }); + + test("falls back to relative resolution for unrecognized layouts", function () { + expect( + resolve_server_wasm_url( + "https://example.com/assets/perspective.js", + ), + ).toEqual( + "https://example.com/server/dist/wasm/perspective-server.wasm", + ); + }); +}); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp index f29b44dc5b..7b477f8e9e 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp @@ -2850,6 +2850,21 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { m_resources.mark_table_deleted( req.entity_id(), client_id, req.msg_id() ); + + // notify `on_hosted_tables_update` listeners — a lazily + // marked table leaves the hosted set NOW (`get_table_ids` + // filters it), and a listener releasing its `View`s is + // exactly what completes this delete. + auto subscriptions = + m_resources.get_on_hosted_tables_update_sub(); + for (auto& subscription : subscriptions) { + Response out; + out.set_msg_id(subscription.id); + ProtoServerResp resp2; + resp2.data = std::move(out); + resp2.client_id = subscription.client_id; + proto_resp.emplace_back(std::move(resp2)); + } } break; diff --git a/rust/perspective-viewer/src/css/panel-tab.css b/rust/perspective-viewer/src/css/panel-tab.css index 75ed681aa0..d90d9b5551 100644 --- a/rust/perspective-viewer/src/css/panel-tab.css +++ b/rust/perspective-viewer/src/css/panel-tab.css @@ -17,7 +17,7 @@ box-sizing: border-box; height: 100%; min-width: 0; - padding: 0 7px 0 12px; + padding: 0 7px 0 13px; cursor: pointer; font-size: 11px; white-space: nowrap; @@ -32,11 +32,11 @@ :host([part~="tab"].visible) { z-index: 6; background: var(--psp--background-color, #ffffff) !important; - box-shadow: + /* box-shadow: 2.25px -2.25px 0 2.25px var(--psp--background-color), -2.25px -2.25px 0 2.25px var(--psp--background-color), 2.75px -2.75px 0 2.75px var(--psp-inactive--color), - 0 12.5px 0 -11.5px var(--psp-inactive--border-color); + 0 12.5px 0 -11.5px var(--psp-inactive--border-color); */ } .psp-tab-grip { @@ -59,16 +59,35 @@ .psp-tab-caret { flex: 0 0 auto; display: none; - margin-right: 10px; + margin-right: 12px; pointer-events: none; } -.psp-tab-caret::before { - content: ">"; +.psp-tab-caret { + /* content: " "; */ + display: block; + + width: 12px; + height: 12px; + pointer-events: none; + background-color: var(--psp--color); + /* -webkit-mask-image: var(--psp-icon--active-indicator--mask-image); + mask-image: var(--psp-icon--active-indicator--mask-image); */ + -webkit-mask-image: var(--psp-icon--column-drag-handle--mask-image); + mask-image: var(--psp-icon--column-drag-handle--mask-image); + + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; } :host([part~="tab"].active) .psp-tab-caret { display: block; + -webkit-mask-image: var(--psp-icon--active-indicator--mask-image); + mask-image: var(--psp-icon--active-indicator--mask-image); } /* A lone tab (its viewer has a single plugin child) has no stack to open — @@ -79,6 +98,8 @@ } .psp-tab-title { + height: 11px; + line-height: 12px; flex: 1 1 auto; pointer-events: none; overflow: hidden; @@ -126,7 +147,8 @@ content: var(--psp-broadcast--content, "[BROADCAST]"); } -.psp-tab-close { +.psp-tab-close, +.psp-tab-settings { flex: 0 0 auto; display: flex; align-items: center; @@ -139,19 +161,19 @@ color: inherit; } -.psp-tab-close:hover::before { +.psp-tab-close:hover::before, +.psp-tab-settings:hover::before { background-color: var(--psp--color); } -.psp-tab-close::before { +.psp-tab-close::before, +.psp-tab-settings::before { content: ""; /* TODO don't hard-code dimensions */ width: 20px; height: 20px; background-color: var(--psp-inactive--color); - -webkit-mask-image: var(--psp-icon--close--mask-image); - mask-image: var(--psp-icon--close--mask-image); -webkit-mask-size: contain; mask-size: contain; -webkit-mask-repeat: no-repeat; @@ -159,3 +181,13 @@ -webkit-mask-position: center; mask-position: center; } + +.psp-tab-close::before { + -webkit-mask-image: var(--psp-icon--close--mask-image); + mask-image: var(--psp-icon--close--mask-image); +} + +.psp-tab-settings::before { + -webkit-mask-image: var(--psp-icon--drawer-tab-inverted--mask-image); + mask-image: var(--psp-icon--drawer-tab-inverted--mask-image); +} diff --git a/rust/perspective-viewer/src/css/plugin-selector.css b/rust/perspective-viewer/src/css/plugin-selector.css index e9f4964b90..a6848e0b58 100644 --- a/rust/perspective-viewer/src/css/plugin-selector.css +++ b/rust/perspective-viewer/src/css/plugin-selector.css @@ -63,7 +63,7 @@ .plugin-select-item-name { padding-left: 4px; - font-size: 1.2em; + font-size: 1.1em; } .plugin-select-item-name:before { diff --git a/rust/perspective-viewer/src/css/status-bar.css b/rust/perspective-viewer/src/css/status-bar.css index c8c1faf859..9c5dc3cdbe 100644 --- a/rust/perspective-viewer/src/css/status-bar.css +++ b/rust/perspective-viewer/src/css/status-bar.css @@ -36,7 +36,6 @@ height: auto; min-height: auto; max-height: none; - gap: 6px; top: calc((var(--psp-status-bar--height, 38px) - 20px) / 2); right: calc( min(48px, (var(--psp-status-bar--height, 38px) - 20px) / 2) @@ -47,7 +46,7 @@ box-shadow: 0 1px 0 0px var(--psp-inactive--color); z-index: 6; display: var(--psp-status-bar--display, flex); - gap: 6px; + gap: 4px; align-items: center; background: var(--psp--background-color); padding: var(--psp-status-bar--padding, 0 31px 0 4px); diff --git a/rust/perspective-viewer/src/css/viewer.css b/rust/perspective-viewer/src/css/viewer.css index bb8af40915..f6e35aa297 100644 --- a/rust/perspective-viewer/src/css/viewer.css +++ b/rust/perspective-viewer/src/css/viewer.css @@ -93,10 +93,36 @@ position: absolute; inset: 0; background-color: var(--psp--background-color) !important; + box-shadow: + 2.25px 2.25px 0 2.25px var(--psp--background-color), + /* down-right */ -2.25px 2.25px 0 2.25px var(--psp--background-color), + /* down-left */ 2.75px 2.75px 0 2.75px var(--psp-inactive--color), + -1.75px 2.75px 0 2.75px var(--psp-inactive--color); +} + +.overlay ::slotted(:not([slot^="statusbar-extra"]):not([slot^="tab-"])) { box-shadow: 2.25px 2.25px 0 2.25px var(--psp--background-color), -2.25px 2.25px 0 2.25px var(--psp--background-color), - 2.75px 2.75px 0 2.75px var(--psp-inactive--color); + 2.75px 2.75px 0 2.75px var(--psp-inactive--color), + -2.75px 2.75px 0 2.75px var(--psp-inactive--color); +} + +::slotted([part="tab"].visible) { + box-shadow: + 2.25px -2.25px 0 2.25px var(--psp--background-color), + -2.25px -2.25px 0 2.25px var(--psp--background-color), + 3.5px -2.25px 0 2.25px var(--psp-inactive--color), + 0 12.5px 0 -11.5px var(--psp-inactive--border-color); +} + +.overlay ::slotted([part="tab"].visible) { + box-shadow: + 2.25px -2.25px 0 2.25px var(--psp--background-color), + -2.25px -2.25px 0 2.25px var(--psp--background-color), + 2.75px -2.75px 0 2.75px var(--psp-inactive--color), + -2.75px -2.75px 0 2.75px var(--psp-inactive--color), + 0 12.5px 0 -11.5px var(--psp-inactive--border-color); } :host input[type="number"]::-webkit-inner-spin-button { @@ -218,11 +244,6 @@ #menu-bar { opacity: 1 !important; } - - #component_container #settings_button, - #component_container #settings_button.titled { - opacity: 1; - } } :host { @@ -322,10 +343,32 @@ var(--psp--background-color, none) ); + /* A STAGED panel's hidden staging wrapper (`MainPanel::render` + + `Workspace::stage_panel`): a freshly-created panel's plugin + completes its first draw here, laid out at predicted final size + but unpainted. `visibility` (not `display`) so the plugin keeps a + real box and a non-null `offsetParent` — draw guards, redraw + subscriptions and observers behave exactly as for a visible + panel. */ + & > .psp-staging { + position: absolute; + top: 0; + left: 0; + overflow: hidden; + visibility: hidden; + pointer-events: none; + } + + & > .psp-staging ::slotted(*) { + display: block; + width: 100%; + height: 100%; + } + /* The layout engine hosting this panel's plugin fills the container. */ & regular-layout { position: absolute; - inset: -2px -3px -3px -3px; + inset: -4px -4px -4px -4px; box-sizing: border-box; padding: 0px; background: var(--psp--background-color, transparent); @@ -351,7 +394,7 @@ & > .rl-panel::part(titlebar) { display: flex; align-items: stretch; - height: var(--psp-panel-titlebar--height, 24px); + height: var(--psp-panel-titlebar--height, 26px); font-family: var(--button--font-family, inherit); overflow: visible; } @@ -486,15 +529,6 @@ border-right-width: 0px; } - #component_container > #settings_button { - opacity: 0; - position: absolute; - top: 0; - right: 0; - margin: var(--settings-button--margin, 10px 36px 0 0); - padding: 0; - } - #close_button { background-color: var(--psp--background-color); padding: 0px; @@ -533,49 +567,6 @@ } } - #settings_button { - background-color: var(--psp--background-color); - padding: 0; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - z-index: 3; - border: 1px solid var(--psp-inactive--color); - border-radius: 5px; - font-size: 10px; - font-weight: normal; - - &:hover { - color: var(--psp--background-color, inherit); - background-color: var(--psp--color); - cursor: pointer; - } - - &:hover .icon { - background-color: var(--psp--background-color); - } - - .icon { - display: inline-flex; - mask-size: cover; - -webkit-mask-size: cover; - background-repeat: no-repeat; - background-color: var(--psp--color); - mask-image: var(--psp-icon--drawer-tab-inverted--mask-image); - -webkit-mask-image: var( - --psp-icon--drawer-tab-inverted--mask-image - ); - - &:before { - line-height: 0; - display: inline-flex; - content: var(--psp-icon--drawer-tab-inverted--mask-image); - visibility: hidden; - } - } - } - .settings_tab_bar_scroll_offset { margin-left: -8px; } diff --git a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs index 1f1496c727..a75cef843b 100644 --- a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs +++ b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs @@ -29,8 +29,10 @@ pub struct SplitPanelProps { #[prop_or_default] pub orientation: Orientation, - /// Whether to render `<>` empty templates as empty child panels, or - /// omit them entirely. + /// Whether to OMIT children that render nothing (an empty fragment, or + /// any nesting of empty fragments — see [`is_empty_html`]) instead of + /// rendering them as empty panes. A skipped pane takes its divider with + /// it. #[prop_or_default] pub skip_empty: bool, @@ -72,6 +74,20 @@ pub struct SplitPanelProps { pub size: Option, } +/// `true` when `node` renders NOTHING: an empty fragment, or a fragment of +/// nothing but empty fragments. SEMANTIC, not literal — the `html!` +/// if-syntax (and other wrappers) nests an empty branch inside another +/// `VList` rather than yielding a bare `<>`, which is why a literal +/// `x != html! { <> }` comparison is not a usable emptiness test (see +/// the mechanism unit test). Deliberately conservative: `VText("")` and +/// other node kinds are NOT considered empty. +fn is_empty_html(node: &Html) -> bool { + match node { + Html::VList(list) => list.iter().all(is_empty_html), + _ => false, + } +} + /// The fixed-size pane style for a committed size (the complement pane /// flex-fills). fn size_style(orientation: Orientation, x: i32) -> String { @@ -204,6 +220,20 @@ impl Component for SplitPanel { self.refs.resize_with(new_len, Default::default); self.styles.resize(new_len, Default::default()); + if let Some(state) = self.resize_state.as_ref() { + let skip_empty = ctx.props().skip_empty; + let still_visible = ctx + .props() + .children + .iter() + .enumerate() + .any(|(i, x)| i == state.index && (!skip_empty || !is_empty_html(&x))); + + if !still_visible { + self.resize_state = None; + } + } + // Deferred mode: pane 0's style tracks the controlled `size` prop // (`None` = natural width, e.g. after a divider double-click reset). if ctx.props().deferred { @@ -234,7 +264,7 @@ impl Component for SplitPanel { .children .iter() .enumerate() - .filter(|(_, x)| !skip_empty || x != &html! { <> }) + .filter(|(_, x)| !skip_empty || !is_empty_html(x)) .collect::>(); let last = panes.len().saturating_sub(1); @@ -532,3 +562,44 @@ impl ResizingState { Ok(self.body_style.set_property("cursor", &self.cursor)?) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_if_empty_branch_is_not_literally_empty() { + let via_if: Html = html! { if false {
} else { <> } }; + assert_ne!(via_if, html! { <> }); + } + + #[test] + fn empty_fragment_is_empty() { + assert!(is_empty_html(&html! { <> })); + } + + #[test] + fn html_if_empty_branch_is_semantically_empty() { + assert!(is_empty_html(&html! { if false {
} else { <> } })); + } + + #[test] + fn nested_empty_fragments_are_empty() { + assert!(is_empty_html(&html! { <><><> })); + } + + #[test] + fn tag_is_not_empty() { + assert!(!is_empty_html(&html! {
})); + } + + #[test] + fn fragment_containing_a_tag_is_not_empty() { + assert!(!is_empty_html(&html! { <>
})); + } + + #[test] + fn text_is_not_empty() { + assert!(!is_empty_html(&html! { { "" } })); + } +} diff --git a/rust/perspective-viewer/src/rust/components/main_panel.rs b/rust/perspective-viewer/src/rust/components/main_panel.rs index 7c0e2bc89f..91ec1c3f17 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel.rs @@ -47,6 +47,9 @@ use crate::workspace::{PanelId, Workspace}; #[derive(Clone, Properties)] pub struct MainPanelProps { + /// Toggle the settings sidebar open. Fired by a `PanelTab`'s open-settings + /// button (after selecting + activating that panel) — the tabs are the + /// only open-settings affordance. pub on_settings: Callback<()>, /// Reset callback forwarded from the root component. Fired when the user @@ -134,6 +137,10 @@ impl PartialEq for MainPanelProps { && self.panel_themes == rhs.panel_themes && self.panel_masters == rhs.panel_masters && self.global_filters == rhs.global_filters + && self.workspace == rhs.workspace + && self.session == rhs.session + && self.renderer == rhs.renderer + && self.presentation == rhs.presentation } } @@ -141,6 +148,14 @@ impl MainPanelProps { fn is_title(&self) -> bool { self.session_props.title.is_some() } + + pub(super) fn effective_panel_theme(&self, id: &str) -> Option { + self.panel_themes + .iter() + .find(|(pid, _)| pid == id) + .and_then(|(_, theme)| theme.clone()) + .or_else(|| self.presentation_props.available_themes.first().cloned()) + } } pub struct MainPanel { @@ -166,14 +181,27 @@ pub struct MainPanel { /// and attached alongside the others. _layout_before_resize_listener: Closure, - /// `contextmenu` listener on the layout element (panel context menu). Kept - /// alive here and attached alongside the others. Imperative — not a Yew - /// `oncontextmenu` — because the plugin body is light-DOM attached by the - /// renderer (its DOM parent is the host, not the frame), so Yew's delegated - /// handler never matches a right-click there. A native listener catches it - /// via composed bubbling and resolves the panel from the path. - _layout_contextmenu_listener: Closure, - listener_attached: bool, + /// `contextmenu` listener on the panel *container* — one stable attach + /// point covering the whole stage, independent of the layout reconcile; + /// at zero panels it opens the stage menu. Kept alive here and attached + /// once in `rendered`. Imperative — not a Yew `oncontextmenu` — because + /// the plugin body is light-DOM attached by the renderer (its DOM parent + /// is the host, not the frame), so Yew's delegated handler never matches + /// a right-click there. A native listener catches it via composed + /// bubbling and resolves the panel from the path. + _contextmenu_listener: Closure, + + /// The `` ELEMENT the layout listeners are attached to. + /// Compared by identity in `reconcile` — if the element is ever a + /// different instance (it should never be: the render keys it into a + /// fully-keyed sibling list precisely so Yew reuses it), the listeners + /// are re-attached and `inserted` is reset so the fresh (empty) layout + /// is repopulated instead of silently orphaning every panel. A latched + /// `bool` here once turned an unkeyed-diff element replacement into + /// "Duplicate commits into an EMPTY tree with no `before-resize` + /// listener": the old element left the DOM with the committed tree and + /// every listener, and nothing ever noticed. + listener_target: Option, /// Per-panel `ResizeObserver`s, keyed by panel id, each observing that /// panel's slotted plugin element and resizing only that panel's @@ -189,10 +217,11 @@ pub struct MainPanel { /// update — every panel starts visible. hidden_tabs: HashSet, - /// Open panel context menu as `(client x, client y, panel id)`; `None` - /// when closed. Rendered as a cursor-anchored + /// Open context menu as `(client x, client y, target panel id)`; outer + /// `None` when closed. A `None` *panel id* is the empty-stage menu (zero + /// panels — "New" only). Rendered as a cursor-anchored /// [`PanelMenu`](super::panel_menu::PanelMenu) overlay. - context_menu: Option<(f64, f64, String)>, + context_menu: Option<(f64, f64, Option)>, /// Id of the currently maximized panel (via `regular-layout.maximize`), or /// `None`. Transient (regular-layout doesn't persist it); drives the @@ -209,6 +238,10 @@ pub struct MainPanel { /// `None` until the first *fully-resolved* pass — an unresolved frame /// leaves it unlatched so the mirror retries each render. stamped_frame_themes: Option, + + /// `Workspace::staged_changed` → [`MainPanelMsg::StagedChanged`] on THIS + /// component's scope (see the message doc for why not the root's). + _staged_sub: crate::utils::Subscription, } impl Component for MainPanel { @@ -250,7 +283,10 @@ impl Component for MainPanel { // frame never matches it (Yew walks the vdom, not the composed path). // This native listener resolves the panel from the // `` on the event's composed path, then - // suppresses the browser menu and emits. + // suppresses the browser menu and emits. On the EMPTY stage (zero + // panels — the persistent `` has no frame + // descendants), it opens the stage menu instead, whose "New" items + // create the first panel. let contextmenu_cb = ctx .link() .callback(|(id, x, y)| MainPanelMsg::ContextMenu(id, x, y)); @@ -268,15 +304,40 @@ impl Component for MainPanel { } } - // Only act when the click is inside a panel; outside any frame, let - // the native menu through. + let is_empty_stage = || { + event + .current_target() + .and_then(|t| t.dyn_into::().ok()) + .is_some_and(|el| { + el.query_selector("regular-layout-frame") + .ok() + .flatten() + .is_none() + }) + }; + if let Some(id) = panel_id { event.prevent_default(); let mouse = event.unchecked_ref::(); - contextmenu_cb.emit((id, mouse.client_x() as f64, mouse.client_y() as f64)); + contextmenu_cb.emit((Some(id), mouse.client_x() as f64, mouse.client_y() as f64)); + } else if is_empty_stage() { + event.prevent_default(); + let mouse = event.unchecked_ref::(); + contextmenu_cb.emit((None, mouse.client_x() as f64, mouse.client_y() as f64)); } + // With panels present, a click outside every frame lets the + // native menu through. }) as Box); + let staged_sub = { + use crate::utils::AddListener; + let cb = ctx.link().callback(|_: ()| MainPanelMsg::StagedChanged); + ctx.props() + .workspace + .staged_changed() + .add_listener(move |()| cb.emit(())) + }; + Self { main_panel_ref: NodeRef::default(), layout_ref: NodeRef::default(), @@ -284,20 +345,22 @@ impl Component for MainPanel { _layout_update_listener: listener, _layout_select_listener: select_listener, _layout_before_resize_listener: before_resize_listener, - _layout_contextmenu_listener: contextmenu_listener, - listener_attached: false, + _contextmenu_listener: contextmenu_listener, + listener_target: None, panel_resize_observers: HashMap::new(), hidden_tabs: HashSet::new(), context_menu: None, maximized: None, theme_backgrounds: HashMap::new(), stamped_frame_themes: None, + _staged_sub: staged_sub, } } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { MainPanelMsg::PointerEvent(event) => self.on_pointer_event(ctx, event), + MainPanelMsg::StagedChanged => true, MainPanelMsg::LayoutUpdated => self.on_layout_updated(ctx), MainPanelMsg::TabSelected(name) => self.on_tab_selected(ctx, name), MainPanelMsg::ContextMenu(id, x, y) => self.on_context_menu(ctx, id, x, y), @@ -307,11 +370,24 @@ impl Component for MainPanel { } } - fn changed(&mut self, _ctx: &Context, _old: &Self::Properties) -> bool { - true + fn changed(&mut self, ctx: &Context, old: &Self::Properties) -> bool { + ctx.props() != old } - fn rendered(&mut self, ctx: &Context, _first_render: bool) { + fn rendered(&mut self, ctx: &Context, first_render: bool) { + // The `contextmenu` listener attaches to the panel CONTAINER — one + // stable attach point covering the whole stage, decoupled from the + // layout reconcile. Panel right-clicks bubble to it through the + // layout on the composed path; at zero panels it serves the stage + // menu. + if first_render && let Some(el) = self.main_panel_ref.cast::() { + let _ = el.add_event_listener_with_callback( + "contextmenu", + self._contextmenu_listener.as_ref().unchecked_ref(), + ); + } + + self.size_staging_wrappers(); self.reconcile(ctx); self.stamp_frame_themes(ctx); } diff --git a/rust/perspective-viewer/src/rust/components/main_panel/frame_theme.rs b/rust/perspective-viewer/src/rust/components/main_panel/frame_theme.rs index 96b96556d0..a6af7440c8 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/frame_theme.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/frame_theme.rs @@ -113,7 +113,6 @@ impl MainPanel { } let viewer = props.presentation.viewer_elem().clone(); - let default_theme = props.presentation_props.available_themes.first(); let mut complete = true; let children = layout.children(); for i in 0..children.length() { @@ -132,12 +131,7 @@ impl MainPanel { continue; }; - let theme = props - .panel_themes - .iter() - .find(|(pid, _)| *pid == name) - .and_then(|(_, theme)| theme.clone()) - .or_else(|| default_theme.cloned()); + let theme = props.effective_panel_theme(&name); let background = theme.as_ref().and_then(|theme| { let fresh = read_plugin_background(&viewer, &name, theme); 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 38299e8a6e..a9b2281238 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/msg.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/msg.rs @@ -16,6 +16,15 @@ 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 + /// 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 + /// in their hidden wrappers), while this component's own messages + /// deliver in every observed context. + StagedChanged, + /// The `` tree changed (fired by its /// `regular-layout-update` event). Used to detect panels removed from /// the layout (e.g. a frame's close button) so they can be disposed. @@ -35,13 +44,14 @@ pub enum MainPanelMsg { /// `preventDefault()`-ed it. BeforeResize(web_sys::Event), - /// A right-click landed inside a panel (panel id, client x, y). Fired by - /// the imperative `contextmenu` listener on the layout element — see - /// `_layout_contextmenu_listener` — and by each `PanelTab`. Activates the - /// panel and opens the - /// [`PanelMenu`](crate::components::panel_menu::PanelMenu) - /// at the cursor. - ContextMenu(String, f64, f64), + /// A right-click opened the panel context menu (target panel id, client + /// x, y). Fired by the imperative `contextmenu` listener on the panel + /// container — see `_contextmenu_listener` — and by each `PanelTab`. + /// `Some(id)` activates that panel and opens its + /// [`PanelMenu`](crate::components::panel_menu::PanelMenu) at the + /// cursor; `None` is the EMPTY stage (zero panels) — a target-less menu + /// offering only "New", whose items create the first panel. + ContextMenu(Option, f64, f64), /// Dismiss the panel context menu (the menu session ended). CloseContextMenu, diff --git a/rust/perspective-viewer/src/rust/components/main_panel/presize.rs b/rust/perspective-viewer/src/rust/components/main_panel/presize.rs index c8ed25e4fd..9d1e3f52ca 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/presize.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/presize.rs @@ -14,9 +14,7 @@ //! its *target* cell box before the `` commit, so content is //! ready ahead of the geometry change (no post-commit shear/clip). -use std::cell::Cell; use std::collections::HashMap; -use std::rc::Rc; use futures::future::join_all; use perspective_client::utils::PerspectiveResultExt; @@ -31,13 +29,9 @@ use crate::renderer::Renderer; use crate::tasks::PanelResizeObserverHandle; use crate::workspace::PanelId; -/// Presize the DRAGGED panel from the `before-resize` detail's `overlay` -/// preview box (regular-layout >= 0.6.1) instead of tracking it reactively -/// with a per-drag `ResizeObserver`. -const PRESIZE_DRAG_OVERLAY: bool = false; - fn set_tab_presize(tab: &web_sys::HtmlElement, dx: f64, dy: f64) { - if dx.abs() > 0.5 || dy.abs() > 0.5 { + if dx.abs() > crate::renderer::SUBPIXEL_EPSILON || dy.abs() > crate::renderer::SUBPIXEL_EPSILON + { let _ = tab .style() .set_property("transform", &format!("translate({dx}px, {dy}px)")); @@ -58,10 +52,25 @@ struct PresizePath { view_window: JsValue, } -#[derive(serde::Deserialize)] -struct PresizeOverlay { - slot: String, - path: PresizePath, +/// The current screen origin of a frame's grid TRACK (its margin box — the +/// coordinate space `real_coordinates` reports), measured live from the frame. +/// `target_track − current_track` is the presize translate delta: the plugin's +/// offset *within* its track is constant across a layout transition, so the +/// track delta is exactly the plugin delta (see `Renderer::presize_with_box`). +fn frame_track_origin(frame: &web_sys::Element) -> Option<(f64, f64)> { + let style = web_sys::window()?.get_computed_style(frame).ok()??; + let px = |prop: &str| { + style + .get_property_value(prop) + .ok() + .and_then(|v| v.trim().trim_end_matches("px").parse::().ok()) + .unwrap_or(0.0) + }; + let rect = frame.get_bounding_client_rect(); + Some(( + rect.left() - px("margin-left"), + rect.top() - px("margin-top"), + )) } impl MainPanel { @@ -76,25 +85,21 @@ impl MainPanel { .detail() .unchecked_into::(); - let mut paths: HashMap = detail + let paths: HashMap = detail .calculate_presize_paths() .into_serde_ext() .unwrap_or_default(); - if PRESIZE_DRAG_OVERLAY - && let Ok(Some(overlay)) = detail.overlay().into_serde_ext::>() - { - paths.insert(overlay.slot, overlay.path); - } - // Bind a drag ResizeObserver on the panel being dragged: it's // the one present in the layout but EXCLUDED from the presize - // paths. With [`PRESIZE_DRAG_OVERLAY`] enabled the dragged slot - // IS in `paths`, so this loop no-ops.) + // paths. A STAGED panel is absent from the layout for a different + // reason than the dragged panel — never bind the per-drag observer + // to its staging wrapper. for id in &ctx.props().panel_ids { let name = id.as_str(); if !paths.contains_key(name) && !self.panel_resize_observers.contains_key(name) + && !ctx.props().workspace.is_staged(id) && let Some(panel) = ctx.props().workspace.panel(id) && let Some(plugin) = panel.renderer.active_plugin() { @@ -108,7 +113,9 @@ impl MainPanel { let workspace = ctx.props().workspace.clone(); let viewer = ctx.props().presentation.viewer_elem().clone(); + let effect = workspace.effects().guard(); ApiFuture::spawn(async move { + let _effect = effect; let mut targets: Vec<(Renderer, f64, f64, f64, f64)> = Vec::new(); let mut tab_targets: Vec<(web_sys::HtmlElement, f64, f64)> = Vec::new(); let mut last_chrome: Option<(f64, f64)> = None; @@ -137,12 +144,12 @@ impl MainPanel { .as_ref() .and_then(|frame| crate::tasks::plugin_chrome(frame, plugin_el)); last_chrome = chrome.or(last_chrome); - let (cw, ch) = chrome.or(last_chrome).unwrap_or((8.0, 33.0)); + let (cw, ch) = last_chrome.unwrap_or(crate::tasks::CHROME_FALLBACK); let width = (cell.width() - cw).max(0.0); let height = (cell.height() - ch).max(0.0); let (dx, dy) = frame .as_ref() - .and_then(crate::tasks::frame_track_origin) + .and_then(frame_track_origin) .map(|(x, y)| (cell.left() - x, cell.top() - y)) .unwrap_or((0.0, 0.0)); targets.push((panel.renderer, dx, dy, width, height)); @@ -162,15 +169,27 @@ impl MainPanel { .iter() .map(|(t, ..)| t.clone()) .collect::>(); - let resumed = Rc::new(Cell::new(false)); - { - let resumed = resumed.clone(); - let renderers = renderers.clone(); - let tabs = tabs.clone(); - let layout = layout.clone().unchecked_into::(); - ApiFuture::spawn(async move { - crate::utils::set_timeout(500).await?; - if !resumed.get() { + for (tab, dx, dy) in &tab_targets { + set_tab_presize(tab, *dx, *dy); + } + + let presents = crate::tasks::staged_presents_with_deadline( + workspace.effects().guard(), + async move { + join_all( + targets + .iter() + .map(|(r, dx, dy, w, h)| r.presize_with_box(*dx, *dy, *w, *h)), + ) + .await + .into_iter() + .filter_map(|r| r.ok().flatten()) + .collect() + }, + { + let renderers = renderers.clone(); + let tabs = tabs.clone(); + move || { for renderer in &renderers { let _ = renderer.clear_presize(); } @@ -178,23 +197,8 @@ impl MainPanel { for tab in &tabs { clear_tab_presize(tab); } - - resumed.set(true); - layout.resume_resize(); } - - Ok(()) - }); - } - - for (tab, dx, dy) in &tab_targets { - set_tab_presize(tab, *dx, *dy); - } - - join_all( - targets - .iter() - .map(|(r, dx, dy, w, h)| r.presize_with_box(*dx, *dy, *w, *h)), + }, ) .await; @@ -206,11 +210,8 @@ impl MainPanel { clear_tab_presize(tab); } - if !resumed.get() { - resumed.set(true); - layout.resume_resize(); - } - + presents.reveal(); + layout.resume_resize(); Ok(()) }); 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 4b377a78af..d33448b727 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs @@ -22,7 +22,6 @@ use yew::prelude::*; use super::MainPanel; use crate::js::RegularLayout; -use crate::tasks::resize_callback; /// Layout interaction tuning constants, applied via /// [`RegularLayout::restore_physics`] when the layout element mounts. @@ -41,21 +40,79 @@ 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 + /// 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 + /// uses. Prediction only — the staged first draw renders at these + /// dimensions, and any delta from the real committed cell is + /// reconciled by the post-commit reactive resize (free when exact, + /// via the charts transport's dims-unchanged guard). Imperative + /// because it MEASURES the committed layout box, which a `view()` + /// style prop cannot. + pub(super) fn size_staging_wrappers(&self) { + let Some(root) = self.main_panel_ref.cast::() else { + return; + }; + + let Ok(wrappers) = root.query_selector_all(".psp-staging") else { + return; + }; + + if wrappers.length() == 0 { + return; + } + + let Some(layout) = self.layout_ref.cast::() else { + return; + }; + + let stage = layout.get_bounding_client_rect(); + let shares = (self.inserted.len() + wrappers.length() as usize).max(1) as f64; + let (chrome_w, chrome_h) = crate::tasks::CHROME_FALLBACK; + let width = (stage.width() / shares - chrome_w).max(0.0); + let height = (stage.height() - chrome_h).max(0.0); + for i in 0..wrappers.length() { + if let Some(node) = wrappers.get(i) + && let Some(el) = node.dyn_ref::() + { + let _ = el.style().set_property("width", &format!("{width}px")); + let _ = el.style().set_property("height", &format!("{height}px")); + } + } + } + /// Reconcile the `` grid against `panel_ids`: `insertPanel` /// newly-rendered cells (flipping each from `display:none` to a visible /// grid cell) and `removePanel` cells whose panels are gone. pub(super) fn reconcile(&mut self, ctx: &Context) { + // The `` element is rendered unconditionally and so is + // stable for this component's lifetime — zero panels is just another + // count (empty insert loop; the retain below removes the last cell, + // collapsing the layout tree to a childless split). Do NOT reset + // `listener_target`/`inserted` on empty: the listeners stay attached + // to the persistent element. let Some(el) = self.layout_ref.cast::() else { return; }; - // Attach the close-detection + active-panel-sync listeners once (the - // `` element is stable for this MainPanel's lifetime), - // and configure resize physics — `GRID_DIVIDER_SIZE` is the divider hit - // tolerance (0 by default → not resizable). The cell-edge band it grabs - // must be left uncovered: each `.rl-panel` carries a `margin` (viewer.css) - // so pointerdowns there reach `regular-layout` instead of the frame. - if !self.listener_attached { + // Attach the close-detection + active-panel-sync listeners once per + // ELEMENT (the `` element is keyed into a fully-keyed + // sibling list, so Yew reuses one instance for this MainPanel's + // lifetime), and configure resize physics — `GRID_DIVIDER_SIZE` is the + // divider hit tolerance (0 by default → not resizable). The cell-edge + // band it grabs must be left uncovered: each `.rl-panel` carries a + // `margin` (viewer.css) so pointerdowns there reach `regular-layout` + // instead of the frame. Comparing the ELEMENT (not a latched bool) + // self-heals an instance swap: a fresh element boots an EMPTY layout + // tree with no listeners, so everything must be re-bound and every + // placed panel re-inserted (see `listener_target`). + if self.listener_target.as_ref() != Some(&el) { + if self.listener_target.is_some() { + self.inserted.clear(); + } + let _ = el.add_event_listener_with_callback( RegularLayout::UPDATE_EVENT, self._layout_update_listener.as_ref().unchecked_ref(), @@ -71,17 +128,12 @@ impl MainPanel { self._layout_before_resize_listener.as_ref().unchecked_ref(), ); - let _ = el.add_event_listener_with_callback( - "contextmenu", - self._layout_contextmenu_listener.as_ref().unchecked_ref(), - ); - if let Ok(physics) = JsValue::from_serde_ext(&LAYOUT_PHYSICS) { el.unchecked_ref::() .restore_physics(&physics); } - self.listener_attached = true; + self.listener_target = Some(el.clone()); } let layout: RegularLayout = el.unchecked_into(); @@ -111,11 +163,18 @@ impl MainPanel { continue; } + // 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. + if ctx.props().workspace.is_staged(id) { + continue; + } + // Already placed in the layout tree (the layout is the placement // source of truth — e.g. a restored tree naming a panel this // component hasn't tracked yet): record it, never re-split it. - let path = layout.calculate_path(name); - if !path.is_null() && !path.is_undefined() { + if layout.contains_panel(name) { self.inserted.push(name.to_owned()); continue; } @@ -129,17 +188,6 @@ impl MainPanel { let path = JsValue::from(js_sys::Array::of1(&JsValue::from_f64(index as f64))); let _ = layout.insert_panel(name, path, JsValue::from_bool(true)); self.inserted.push(name.to_owned()); - - // If this panel's plugin is already drawn (e.g. its cell was - // re-created while the plugin persisted in the light DOM), redraw it - // now its cell is visible. A not-yet-loaded panel is skipped — the - // normal draw path renders into the now-visible cell, and forcing a - // render before a `Table` is loaded throws "No `Table` attached". - if let Some(panel) = ctx.props().workspace.panel(id) - && panel.renderer.is_plugin_activated().unwrap_or(false) - { - resize_callback(&panel.session, &panel.renderer).emit(()); - } } // Remove cells whose panels are gone. diff --git a/rust/perspective-viewer/src/rust/components/main_panel/render.rs b/rust/perspective-viewer/src/rust/components/main_panel/render.rs index 88acf83dad..c88995c897 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/render.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/render.rs @@ -44,8 +44,6 @@ impl MainPanel { } = ctx.props(); let is_settings_open = ctx.props().is_settings_open; - let on_settings = (!is_settings_open).then(|| ctx.props().on_settings.clone()); - let mut class = classes!(); if !is_settings_open { class.push("settings-closed"); @@ -64,73 +62,82 @@ impl MainPanel { // `regular-layout` grid-places via its `::slotted([name])` rule — that // only matches *direct*, non-`` children, hence the wrapper) whose // inner forwarding `` projects the light-DOM plugin into the placed - // cell. Falls back to a bare default slot when there are no panels. - let plugin_area = if ctx.props().panel_ids.is_empty() { - html! { } - } else { + // cell. The `` element is ALWAYS rendered — at zero + // panels it is merely empty (its tree collapses to a childless split, + // and it paints nothing of its own), which keeps the stage CSS + // (viewer.css keys the stage background + inset bleed on the element) + // and the attached listeners alive across empty↔populated transitions. + let plugin_area = { // The active panel (whose engines the status bar/settings target) is // the one the active renderer is bound to. let active_slot = renderer.slot_name(); - let cells = ctx.props().panel_ids.iter().map(|id| { - let name = id.as_str().to_owned(); - let mut class = classes!("rl-panel"); - if ctx.props().panel_ids.len() > 1 { - if active_slot.as_deref() == Some(name.as_str()) { - class.push("active"); + // Panel-count chrome (`single`/`multi`, closable, draggable) + let placed_count = ctx.props().workspace.placed_count(); + let cells = ctx + .props() + .panel_ids + .iter() + .filter(|id| !ctx.props().workspace.is_staged(id)) + .map(|id| { + let name = id.as_str().to_owned(); + let mut class = classes!("rl-panel"); + if placed_count > 1 { + if active_slot.as_deref() == Some(name.as_str()) { + class.push("active"); + } + } else { + // Lone panel: can't be closed to zero — hide the close button + // (see viewer.css) and skip the active outline. + class.push("single"); } - } else { - // Lone panel: can't be closed to zero — hide the close button - // (see viewer.css) and skip the active outline. - class.push("single"); - } - // Activate this panel on pointerdown anywhere in it (body or - // titlebar), so *working* in a panel — not only clicking its tab - // — targets it with the shared settings/toolbar. regular-layout - // emits `regular-layout-select` on tab clicks only, which left - // body interaction unable to change the active panel. Tab clicks - // still also fire `-select`, but that dispatch is async (after - // `restore`) so it lands after this synchronous handler — within a - // stack, switching tabs still resolves to the clicked panel. - // Re-activating the already-active panel is a no-op upstream. - let on_activate = ctx.props().on_activate_panel.clone(); - let activate_name = name.clone(); - let onpointerdown = Callback::from(move |_: web_sys::PointerEvent| { - on_activate.emit(activate_name.clone()); - }); + // Activate this panel on pointerdown anywhere in it (body or + // titlebar), so *working* in a panel — not only clicking its tab + // — targets it with the shared settings/toolbar. regular-layout + // emits `regular-layout-select` on tab clicks only, which left + // body interaction unable to change the active panel. Tab clicks + // still also fire `-select`, but that dispatch is async (after + // `restore`) so it lands after this synchronous handler — within a + // stack, switching tabs still resolves to the clicked panel. + // Re-activating the already-active panel is a no-op upstream. + let on_activate = ctx.props().on_activate_panel.clone(); + let activate_name = name.clone(); + let onpointerdown = Callback::from(move |_: web_sys::PointerEvent| { + on_activate.emit(activate_name.clone()); + }); - // Right-click anywhere in the panel opens the context menu. This - // is handled by the imperative `contextmenu` listener on the - // layout element (see `_layout_contextmenu_listener`), NOT a Yew - // `oncontextmenu` here: the plugin body is light-DOM attached by - // the renderer, so Yew's delegated handler — which walks the vdom - // — never matches a right-click on it, leaving the native menu. + // Right-click anywhere in the panel opens the context menu. This + // is handled by the imperative `contextmenu` listener on the + // panel container (see `_contextmenu_listener`), NOT a Yew + // `oncontextmenu` here: the plugin body is light-DOM attached by + // the renderer, so Yew's delegated handler — which walks the vdom + // — never matches a right-click on it, leaving the native menu. - // `` gives each panel a titlebar (its - // `slot="tab"`) and a `::part(container)`. Two forwarding ``s - // project our light-DOM nodes in: `name` → the plugin (into the - // container), and `tab-` (with `slot="tab"`) → this panel's - // `` (into the titlebar). Both targets live in the - // viewer's light DOM, so the slots — being in the viewer's shadow — - // collect them and forward them one level deeper into the frame. - // - // The frame no longer carries the theme: the per-panel theme is - // applied directly to the light-DOM plugin + tab (each stamped with - // a `theme` attribute) via the `perspective-viewer [theme="X"]` - // document rules, so it themes through the native cascade instead - // of a scraped/inlined `--psp-*` block. - html! { - - - - - } - }); + // `` gives each panel a titlebar (its + // `slot="tab"`) and a `::part(container)`. Two forwarding ``s + // project our light-DOM nodes in: `name` → the plugin (into the + // container), and `tab-` (with `slot="tab"`) → this panel's + // `` (into the titlebar). Both targets live in the + // viewer's light DOM, so the slots — being in the viewer's shadow — + // collect them and forward them one level deeper into the frame. + // + // The frame no longer carries the theme: the per-panel theme is + // applied directly to the light-DOM plugin + tab (each stamped with + // a `theme` attribute) via the `perspective-viewer [theme="X"]` + // document rules, so it themes through the native cascade instead + // of a scraped/inlined `--psp-*` block. + html! { + + + + + } + }); // Per-panel tab titles: regular-layout's tabs render their label from // a `--regular-layout---title` custom property (a CSS string). We @@ -158,13 +165,9 @@ impl MainPanel { // dragged — so it's neither closable nor draggable; its tab also // carries the `single` panel-count class (which e.g. hides the // caret). - let closable = ctx.props().panel_ids.len() > 1; + let closable = placed_count > 1; let draggable = closable; let single = !closable; - // Each panel's effective theme: its own (`panel_themes` snapshot) or - // the registry default (first registered theme). Stamped on the tab so - // the `[theme]` document rules theme it per-panel. - let default_theme = ctx.props().presentation_props.available_themes.first(); let on_select = { let layout_ref = self.layout_ref.clone(); Callback::from(move |id: String| { @@ -195,10 +198,27 @@ impl MainPanel { } }) }; + // The tab's open-settings button (the ONLY open-settings + // affordance — the status bar's button is gone): select the panel + // in the layout, activate it, then toggle the sidebar open. All + // three are synchronous root-message sends, so activation lands + // before the toggle and the sidebar binds the clicked panel's + // engines. + let on_open_settings = { + let on_select = on_select.clone(); + let on_activate = ctx.props().on_activate_panel.clone(); + let on_settings = ctx.props().on_settings.clone(); + Callback::from(move |id: String| { + on_select.emit(id.clone()); + on_activate.emit(id); + on_settings.emit(()); + }) + }; let tabs = ctx .props() .panel_ids .iter() + .filter(|id| !ctx.props().workspace.is_staged(id)) .map(|id| { let name = id.as_str().to_owned(); let title = ctx @@ -210,13 +230,7 @@ impl MainPanel { let active = active_slot.as_deref() == Some(name.as_str()); let visible = !self.hidden_tabs.contains(name.as_str()); let is_master = ctx.props().panel_masters.contains(id); - let theme = ctx - .props() - .panel_themes - .iter() - .find(|(pid, _)| pid == &name) - .and_then(|(_, t)| t.clone()) - .or_else(|| default_theme.cloned()); + let theme = ctx.props().effective_panel_theme(&name); html! { } }) - .collect::(); + .collect::>(); - html! { - <> - - { for cells } - - { tabs } - - } + // STAGED panels: the plugin `` is delivered OUTSIDE the + // layout into a hidden wrapper, sized to the predicted + // post-insert cell by `size_staging_wrappers`. `visibility: + // hidden` (not `display: none`) so the plugin keeps a real box + // and a non-null `offsetParent` — first draws, redraw + // subscriptions and every observer (the host-level + // `AutoPauseHandle`/`ResizeObserverHandle` gates, the drag + // `ResizeObserver`s) see it exactly as they would a visible + // panel. No tab is rendered while staged. + let staging = ctx + .props() + .panel_ids + .iter() + .filter(|id| ctx.props().workspace.is_staged(id)) + .map(|id| { + let name = id.as_str().to_owned(); + html! { +
+ +
+ } + }); + + // ONE flat FULLY-KEYED list: `` + tabs + staging + // wrappers. Keying every sibling (the layout element included) + // routes Yew's list diff through `apply_keyed`, which matches by + // key — the layout element is REUSED across renders regardless of + // how the tab/staging population shifts around it. The prior + // shape (`` + nested `{tabs}`/`{staging}` lists, + // layout unkeyed) took `apply_unkeyed`, whose back-to-front + // positional pairing REPLACED the layout element whenever the + // trailing lists changed length — orphaning its committed tree + // and attached listeners, and silently re-booting the layout + // from `EMPTY_PANEL` (see `listener_target`). + std::iter::once(html! { + + { for cells } + + }) + .chain(tabs) + .chain(staging) + .collect::() }; // The cursor-anchored panel command menu — a body-mounted // `PortalModal` (like the Export/Copy pickers it spawns), so its // placement in this subtree is irrelevant. Themed by the TARGET // panel's effective theme — its own, else the registry default — - // not the active/host theme. Maximize/Restore are handled locally; + // not the active/host theme (the target-less stage menu also takes + // the registry default). Maximize/Restore are handled locally; // other commands forward to the root. let panel_menu = self .context_menu .as_ref() .map(|(x, y, id)| { - let default_theme = ctx.props().presentation_props.available_themes.first(); - let theme = ctx - .props() - .panel_themes - .iter() - .find(|(pid, _)| pid == id) - .and_then(|(_, theme)| theme.clone()) - .or_else(|| default_theme.cloned()); + let theme = match id { + Some(id) => ctx.props().effective_panel_theme(id), + None => ctx + .props() + .presentation_props + .available_themes + .first() + .cloned(), + }; + + let maximized = id + .as_deref() + .is_some_and(|id| self.maximized.as_deref() == Some(id)); html! { @@ -289,7 +353,6 @@ impl MainPanel {
, - id: String, + id: Option, x: f64, y: f64, ) -> bool { // Make the right-clicked panel active so active-targeting // commands (e.g. Reset) act on it; then show the menu. - ctx.props().on_activate_panel.emit(id.clone()); + if let Some(id) = &id { + ctx.props().on_activate_panel.emit(id.clone()); + } + self.context_menu = Some((x, y, id)); true } @@ -142,7 +143,7 @@ impl MainPanel { } pub(super) fn on_command(&mut self, ctx: &Context, cmd: PanelCommand) -> bool { - let Some((_, _, id)) = self.context_menu.clone() else { + let Some((_, _, target)) = self.context_menu.clone() else { return false; }; @@ -154,7 +155,9 @@ impl MainPanel { // `BeforeResize` gate presizes the now-visible plugin(s) // — no reactive post-commit resize sweep needed. PanelCommand::Maximize => { - if let Some(el) = self.layout_ref.cast::() { + if let Some(id) = target + && let Some(el) = self.layout_ref.cast::() + { el.unchecked_ref::().maximize(&id); self.maximized = Some(id); } @@ -166,7 +169,14 @@ impl MainPanel { self.maximized = None; }, - cmd => ctx.props().on_panel_command.emit((id, cmd)), + // The stage menu (no target panel) offers ONLY `NewFrom`, whose + // handler resolves the client/table purely from the loaded-clients + // registry — the panel id is unused there, so the empty id never + // reaches a panel lookup. + cmd => ctx + .props() + .on_panel_command + .emit((target.unwrap_or_default(), cmd)), } false diff --git a/rust/perspective-viewer/src/rust/components/panel_menu.rs b/rust/perspective-viewer/src/rust/components/panel_menu.rs index 0e3d4c2acf..a0058687b3 100644 --- a/rust/perspective-viewer/src/rust/components/panel_menu.rs +++ b/rust/perspective-viewer/src/rust/components/panel_menu.rs @@ -71,8 +71,11 @@ pub struct PanelMenuProps { pub x: f64, pub y: f64, - /// The right-clicked panel this menu targets. - pub panel_id: String, + /// The right-clicked panel this menu targets — or `None` for the + /// EMPTY-stage menu (zero panels), which offers only the "New" sub-menu + /// (its `NewFrom` items resolve from the loaded-clients registry, no + /// panel required). + pub panel_id: Option, /// For per-panel command context (`is_master`, pivot state, panel count) /// and resolving the target panel's engines for Export/Copy. @@ -233,9 +236,6 @@ impl Component for PanelMenu { impl PanelMenu { fn menu_html(&self, ctx: &Context) -> Html { let on_close = ctx.link().callback(|_| PanelMenuMsg::MenuClosed); - let can_close = ctx.props().workspace.len() > 1; - let panel_id = PanelId::from(ctx.props().panel_id.as_str()); - let is_master = ctx.props().workspace.is_master(&panel_id); let item = |label: &str, on_select: Callback<()>, disabled: bool| { ContextMenuEntry::Item(ContextMenuItem { label: label.to_owned(), @@ -247,41 +247,55 @@ impl PanelMenu { ctx.link() .callback(move |_| PanelMenuMsg::Command(cmd.clone())) }; - let entries = vec![ - ContextMenuEntry::Submenu { + let entries = match ctx.props().panel_id.as_deref() { + // The empty-stage menu: no target panel, so only the "New" + // sub-menu. Hover-only (`on_select: None`) — plain "New" copies + // its source panel's table binding, which doesn't exist here. + None => vec![ContextMenuEntry::Submenu { label: "New".to_owned(), - on_select: Some(cmd(PanelCommand::New)), + on_select: None, entries: self.new_submenu_entries(ctx), + }], + Some(panel_id) => { + let can_close = ctx.props().workspace.len() > 1; + let is_master = ctx.props().workspace.is_master(&PanelId::from(panel_id)); + vec![ + ContextMenuEntry::Submenu { + label: "New".to_owned(), + on_select: Some(cmd(PanelCommand::New)), + entries: self.new_submenu_entries(ctx), + }, + item("Duplicate", cmd(PanelCommand::Duplicate), false), + item("Reset", cmd(PanelCommand::Reset), false), + item( + "Export", + ctx.link() + .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Export)), + false, + ), + item( + "Copy", + ctx.link() + .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Copy)), + false, + ), + if ctx.props().maximized { + item("Restore", cmd(PanelCommand::Restore), false) + } else { + item("Maximize", cmd(PanelCommand::Maximize), false) + }, + // Never gated: masters broadcast from ANY select/click event + // (flat grids fall back to the clicked cell's `==` clause), not + // just a grouped row tree. + item( + if is_master { "Detail" } else { "Master" }, + cmd(PanelCommand::ToggleMaster), + false, + ), + item("Close", cmd(PanelCommand::Close), !can_close), + ] }, - item("Duplicate", cmd(PanelCommand::Duplicate), false), - item("Reset", cmd(PanelCommand::Reset), false), - item( - "Export", - ctx.link() - .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Export)), - false, - ), - item( - "Copy", - ctx.link() - .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Copy)), - false, - ), - if ctx.props().maximized { - item("Restore", cmd(PanelCommand::Restore), false) - } else { - item("Maximize", cmd(PanelCommand::Maximize), false) - }, - // Never gated: masters broadcast from ANY select/click event - // (flat grids fall back to the clicked cell's `==` clause), not - // just a grouped row tree. - item( - if is_master { "Detail" } else { "Master" }, - cmd(PanelCommand::ToggleMaster), - false, - ), - item("Close", cmd(PanelCommand::Close), !can_close), - ]; + }; html! { , kind: PickerKind) -> Html { + // Export/Copy are absent from the target-less stage menu, so + // `panel_id` is always `Some` here in practice. let Some(panel) = ctx .props() - .workspace - .panel(&PanelId::from(ctx.props().panel_id.as_str())) + .panel_id + .as_deref() + .and_then(|id| ctx.props().workspace.panel(&PanelId::from(id))) else { return Html::default(); }; diff --git a/rust/perspective-viewer/src/rust/components/panel_tab.rs b/rust/perspective-viewer/src/rust/components/panel_tab.rs index 6ba0d055bb..16cd50c895 100644 --- a/rust/perspective-viewer/src/rust/components/panel_tab.rs +++ b/rust/perspective-viewer/src/rust/components/panel_tab.rs @@ -141,6 +141,12 @@ pub struct PanelTabProps { /// close button. pub closable: bool, + /// `true` when the settings sidebar is open. When closed, the tab renders + /// an open-settings button *in place of* the close button — the only + /// affordance for opening the settings panel (there is deliberately none + /// at zero panels). + pub is_settings_open: bool, + /// `false` for a lone panel — suppresses the tab rearrange-drag. /// `` arms a drag from any `part="tab"` pointerdown, /// but a lone panel has nowhere to drop, so the host `pointerdown` handler @@ -157,6 +163,11 @@ pub struct PanelTabProps { /// directly; see the app-initiated-layout-change invariant). pub on_close: Callback, + /// Open the settings sidebar targeting this panel. Wired by `MainPanel` to + /// select this panel in the layout, activate it (so the sidebar binds its + /// engines), then toggle the sidebar open. + pub on_open_settings: Callback, + /// Open the panel context menu at `(client_x, client_y)`. Wired here on the /// tab host because the tab's content is a `create_portal` subtree, so its /// events don't reach the frame's main-tree `oncontextmenu` (unlike the @@ -186,6 +197,9 @@ pub enum PanelTabMsg { /// `dblclick` listener never fires on the tab. PointerDown(f64), Close, + /// The tab's open-settings button (shown while the settings sidebar is + /// closed, in the close button's place). + OpenSettings, ContextMenu(f64, f64), /// Track the live `` value while editing (drives the auto-sizer). EditInput(String), @@ -274,12 +288,10 @@ impl Component for PanelTab { .unwrap() .unchecked_into(); - let _ = host.set_attribute("part", "tab"); - let _ = host.set_attribute("slot", &Self::slot_name(&ctx.props().panel_id)); + host.set_attribute("part", "tab").unwrap(); + host.set_attribute("slot", &Self::slot_name(&ctx.props().panel_id)) + .unwrap(); - // Encapsulate the tab's contents in an open ShadowRoot, and adopt the - // shared structure stylesheet into it. The host itself stays in the - // light DOM (see `ensure_custom_element`). let init = ShadowRootInit::new(ShadowRootMode::Open); let shadow_root = host .shadow_root() @@ -287,26 +299,10 @@ impl Component for PanelTab { .unchecked_into::(); adopt_sheet(&shadow_root); - - // A pointerdown anywhere on the tab selects the panel (the title is - // `pointer-events:none`, so its clicks fall through to this host), and a - // second within the double-click window enters title-edit mode (see - // `PanelTabMsg::PointerDown`). The close button stops propagation so it - // doesn't also select; the frame's own handler reads `part="tab"` off - // this host to start a drag. let link = ctx.link().clone(); let draggable = Rc::new(Cell::new(ctx.props().draggable)); let drag_flag = draggable.clone(); let pointerdown = Closure::wrap(Box::new(move |event: PointerEvent| { - // A lone panel can't be rearranged — stop the pointerdown before it - // reaches ``'s titlebar handler so it never - // arms a drag (our own select / double-click logic still runs). - // ALSO cancel the compat mouse events (the frame's drag-arm - // `preventDefault` normally does this): the synthesized - // double-click renders + focuses the title editor `` - // between the second `pointerdown` and its compat `mousedown`, - // whose default focus action would land on the non-focusable host - // and instantly blur (→ commit away) the editor. if !drag_flag.get() { event.stop_propagation(); event.prevent_default(); @@ -318,8 +314,6 @@ impl Component for PanelTab { let _ = host .add_event_listener_with_callback("pointerdown", pointerdown.as_ref().unchecked_ref()); - // Right-click → panel context menu (suppressing the native menu). Stops - // propagation so it doesn't also bubble to the frame. let link = ctx.link().clone(); let contextmenu = Closure::wrap(Box::new(move |event: MouseEvent| { event.prevent_default(); @@ -353,9 +347,7 @@ impl Component for PanelTab { .set_attribute("slot", &Self::slot_name(&ctx.props().panel_id)); } - // Keep the pointerdown closure's drag gate in sync with the prop. self.draggable.set(ctx.props().draggable); - true } @@ -363,11 +355,7 @@ impl Component for PanelTab { let id = ctx.props().panel_id.clone(); match msg { PanelTabMsg::PointerDown(ts) => { - // Every pointerdown selects the panel (single-click behavior). ctx.props().on_select.emit(id); - // A second pointerdown within the double-click window enters - // title-edit mode. `begin_edit` only runs (and forces a render) - // on an actual double-click that isn't already editing. let is_double = ts - self.last_pointerdown <= DBLCLICK_MS; self.last_pointerdown = ts; is_double && self.begin_edit(ctx) @@ -376,6 +364,10 @@ impl Component for PanelTab { ctx.props().on_close.emit(id); false }, + PanelTabMsg::OpenSettings => { + ctx.props().on_open_settings.emit(id); + false + }, PanelTabMsg::ContextMenu(x, y) => { ctx.props().on_context_menu.emit((id, x, y)); false @@ -412,6 +404,11 @@ impl Component for PanelTab { PanelTabMsg::Close }); + let on_open_settings = ctx.link().callback(|e: PointerEvent| { + e.stop_propagation(); + PanelTabMsg::OpenSettings + }); + let title_html = if self.editing { let oninput = ctx.link().callback(|e: InputEvent| { let value = e @@ -428,14 +425,12 @@ impl Component for PanelTab { "Escape" => vec![PanelTabMsg::CancelEdit], _ => vec![], }); - // Cursor-positioning clicks in the input must not fall through to the - // host's select / the frame's drag while editing. + let onpointerdown = ctx.link().batch_callback(|e: PointerEvent| { e.stop_propagation(); Vec::::new() }); - // `input-sizer` (ported from the status bar) auto-grows to the value. html! {
} - if let Some(x) = ctx.props().on_settings.as_ref() { -
- -
+ if !is_settings_open {
@@ -387,18 +375,16 @@ impl Component for StatusBar { } - } else if let Some(x) = ctx.props().on_settings.as_ref() { + } else { + // Settings closed + loaded: no docked status bar. The open-settings + // affordance lives on the `PanelTab`s; only the (default-hidden) + // eject button floats here. let class = classes!(is_updating_class_name, "floating"); html! {
-
- -
} - } else { - html! {} } } } diff --git a/rust/perspective-viewer/src/rust/components/viewer.rs b/rust/perspective-viewer/src/rust/components/viewer.rs index 1691ccba89..e35e229b7b 100644 --- a/rust/perspective-viewer/src/rust/components/viewer.rs +++ b/rust/perspective-viewer/src/rust/components/viewer.rs @@ -80,9 +80,17 @@ pub struct PerspectiveViewer { _title_subscriptions: Vec, /// The active panel's engine handles — what the settings panel + status bar - /// bind to. Re-targeted on `SetActivePanel`. + /// bind to. Re-targeted on `SetActivePanel`. Fall back to + /// `empty_session`/`empty_renderer` when the element has zero panels. active_session: Session, active_renderer: Renderer, + + /// Detached "empty" engine handles (never inserted into the `Workspace`, + /// never mounted). Used as the `active_*` fallback when there are zero + /// panels, so the settings/status chrome always has a `Session`+`Renderer` + /// to bind to — and, being tableless, renders an inert empty state. + empty_session: Session, + empty_renderer: Renderer, debug_open: bool, fonts: FontLoaderProps, on_close_column_settings: Callback<()>, @@ -137,8 +145,18 @@ impl Component for PerspectiveViewer { fn create(ctx: &Context) -> Self { let elem = ctx.props().elem.clone(); let fonts = FontLoaderProps::new(&elem, ctx.link().callback(|()| PreloadFontsUpdate)); - let active_session = ctx.props().workspace.active_session(); - let active_renderer = ctx.props().workspace.active_renderer(); + let empty_session = Session::new(); + let empty_renderer = Renderer::new(&elem); + let active_session = ctx + .props() + .workspace + .active_session() + .unwrap_or_else(|| empty_session.clone()); + let active_renderer = ctx + .props() + .workspace + .active_renderer() + .unwrap_or_else(|| empty_renderer.clone()); inject_shared_callbacks(ctx); inject_active_callbacks(ctx, &active_session, &active_renderer); let subscriptions = create_shared_subscriptions(ctx); @@ -183,6 +201,8 @@ impl Component for PerspectiveViewer { _title_subscriptions: subscribe_panel_titles(ctx), active_session, active_renderer, + empty_session, + empty_renderer, debug_open: false, fonts, on_close_column_settings, diff --git a/rust/perspective-viewer/src/rust/components/viewer/panels.rs b/rust/perspective-viewer/src/rust/components/viewer/panels.rs index 50723bd637..b64965e99d 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/panels.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/panels.rs @@ -37,11 +37,35 @@ use crate::workspace::PanelId; impl PerspectiveViewer { pub(super) fn on_layout_changed(&mut self, ctx: &Context) -> bool { - // The panel set may have changed (add); re-subscribe titles. self._title_subscriptions = subscribe_panel_titles(ctx); + self.resync_active(ctx); true } + /// Level-triggered binding sync: re-point the root's engine bindings at + /// the model's *current* active panel (or the detached empty engines at + /// zero panels) when they differ, by `Rc` identity. Catches model-side + /// activations that never dispatch `SetActivePanel`. + fn resync_active(&mut self, ctx: &Context) -> bool { + let session = ctx + .props() + .workspace + .active_session() + .unwrap_or_else(|| self.empty_session.clone()); + let renderer = ctx + .props() + .workspace + .active_renderer() + .unwrap_or_else(|| self.empty_renderer.clone()); + + if session == self.active_session && renderer == self.active_renderer { + false + } else { + self.retarget_active(ctx, session, renderer); + true + } + } + pub(super) fn on_set_active_panel( &mut self, ctx: &Context, @@ -50,31 +74,29 @@ impl PerspectiveViewer { ) -> bool { let id = PanelId::from(id); let prev = ctx.props().workspace.active_id(); - if prev == id || !ctx.props().workspace.set_active(id.clone()) { - // A no-op activation resolves immediately (nothing to render). + if prev.as_ref() == Some(&id) || !ctx.props().workspace.set_active(id.clone()) { + let resynced = self.resync_active(ctx); if let Some(completion) = completion { completion.resolve_after(async { Ok(()) }); } - false + resynced } else { - let new_session = ctx.props().workspace.active_session(); - let new_renderer = ctx.props().workspace.active_renderer(); - self.retarget_active(ctx, new_session, new_renderer); + let new_session = ctx + .props() + .workspace + .active_session() + .unwrap_or_else(|| self.empty_session.clone()); - // Plugins render activation-dependent chrome (e.g. the - // datagrid's extra "edit" column-header row while the - // settings sidebar is open), but nothing else redraws a - // panel on activation alone — so nudge BOTH sides of the - // switch, each as ONE transactional, unthrottled draw that - // stamps the `active` class atomically with the plugin DOM - // it styles (`activation_render` — see the I5 audit gap in - // SESSION_CONFIG_COHERENCE_PLAN.md §4). Hidden/undrawn - // panels are skipped by the activation guard and the - // plugin's own connected check. The runs are JOINED into - // the caller's completion (invariant I6). + let new_renderer = ctx + .props() + .workspace + .active_renderer() + .unwrap_or_else(|| self.empty_renderer.clone()); + + self.retarget_active(ctx, new_session, new_renderer); let mut nudges = Vec::new(); - for pid in [&prev, &id] { + for pid in prev.iter().chain(std::iter::once(&id)) { if let Some(panel) = ctx.props().workspace.panel(pid) && panel.renderer.is_plugin_activated().unwrap_or(false) { @@ -122,38 +144,26 @@ impl PerspectiveViewer { ) -> bool { let id = PanelId::from(id); - // Never close the last panel — it would leave an empty workspace - // with an invalid active id. (The frame's close button is hidden - // for a lone panel; this guards the context-menu path too.) The - // public API documents this as a no-op, so it RESOLVES rather than - // cancels. - if ctx.props().workspace.len() <= 1 { - if let Some(completion) = completion { - completion.resolve_after(async { Ok(()) }); - } - - return false; - } - - let was_active = ctx.props().workspace.active_id() == id; + let was_active = ctx.props().workspace.active_id().as_ref() == Some(&id); let removed = ctx.props().workspace.remove_panel(&id); + if was_active { + if let Some(next) = ctx.props().workspace.panel_ids().first().cloned() { + ctx.props().workspace.set_active(next); + } - // If the active panel was closed, re-point active to a surviving - // panel BEFORE anything reads `active_*` (the workspace's `active` - // still names the just-removed id). The lone panel can't be - // closed (its close button is hidden), so a survivor exists. - if was_active && let Some(next) = ctx.props().workspace.panel_ids().first().cloned() { - ctx.props().workspace.set_active(next); - let new_session = ctx.props().workspace.active_session(); - let new_renderer = ctx.props().workspace.active_renderer(); + let new_session = ctx + .props() + .workspace + .active_session() + .unwrap_or_else(|| self.empty_session.clone()); + let new_renderer = ctx + .props() + .workspace + .active_renderer() + .unwrap_or_else(|| self.empty_renderer.clone()); self.retarget_active(ctx, new_session, new_renderer); } - // Synchronous eject (see `eject_panel` for why its sync halves must - // not be deferred); the DEFERRED teardown future resolves the - // caller's completion, carrying any teardown error (invariant I6 — - // previously fire-and-forget). Surviving panels' resizes are driven - // by the subsequent layout-update event and are attributed to it. let eject = removed.map(eject_panel); match (eject, completion) { (Some(eject), Some(completion)) => completion.resolve_after(eject), @@ -162,14 +172,7 @@ impl PerspectiveViewer { (None, None) => {}, } - // Closing a MASTER retracts its contribution (`remove_panel` already - // cleaned the model); re-stamp the survivors so details drop its - // clauses (diff-aware — a detail close re-renders nothing). apply_global_filters(&ctx.props().workspace); - - // The panel set shrank; drop the closed panel's title subscription. - // (`MainPanel` clears its own `maximized` state when the panel's cell - // leaves the layout.) self._title_subscriptions = subscribe_panel_titles(ctx); true } @@ -182,8 +185,6 @@ impl PerspectiveViewer { let notify = ctx.link().callback(|_: ()| LayoutChanged); let activate = ctx.link().callback(|id| SetActivePanel(id, None)); ApiFuture::spawn(async move { - // Snapshot the source panel's config, then build a new - // independent panel from it. let config = panel .renderer .clone() @@ -193,8 +194,6 @@ impl PerspectiveViewer { .await?; let update = ViewerConfigUpdate::decode(&config.encode()?)?; - // The SOURCE panel's client — its table name is only - // meaningful there (it may not be the default client). let client = panel.session.get_client(); let new_id = create_panel( &elem, @@ -206,8 +205,7 @@ impl PerspectiveViewer { client, ) .await?; - // Make the duplicate active so the shared settings/toolbar - // immediately target it. + activate.emit(new_id.to_string()); Ok(()) }); @@ -225,15 +223,11 @@ impl PerspectiveViewer { let notify = ctx.link().callback(|_: ()| LayoutChanged); let activate = ctx.link().callback(|id| SetActivePanel(id, None)); ApiFuture::spawn(async move { - // A minimal config: the source's table (if any), default - // everything else. let update = ViewerConfigUpdate { table: table_name.map(TableUpdate::Update).unwrap_or_default(), ..Default::default() }; - // The SOURCE panel's client — its table name is only - // meaningful there (it may not be the default client). let client = panel.session.get_client(); let new_id = create_panel( &elem, @@ -245,8 +239,7 @@ impl PerspectiveViewer { client, ) .await?; - // Make the new panel active so the shared settings/toolbar - // immediately target it. + activate.emit(new_id.to_string()); Ok(()) }); @@ -295,8 +288,7 @@ impl PerspectiveViewer { Some(client), ) .await?; - // Make the new panel active so the shared settings/toolbar - // immediately target it. + activate.emit(new_id.to_string()); Ok(()) }); @@ -315,28 +307,20 @@ impl PerspectiveViewer { self._active_subscriptions = create_active_subscriptions(ctx, &session, &renderer); self.session_props = session.to_props(); self.renderer_props = renderer.to_props(None); - // Level-triggered: read the new panel's TRUE in-flight count (its - // `run_state_changed` subscription tracks it from here) — switching - // to a busy panel spins truthfully, instead of the old reset-to-0. self.update_count = session.in_flight_config_runs(); self.active_session = session; self.active_renderer = renderer; - - // Chrome (status bar / settings) follows the active panel: mirror its - // EFFECTIVE theme — its own per-panel theme, else the registry default - // (first registered theme) — onto the host `theme` attribute, so the - // shadow's CSS custom properties (which the chrome inherits) resolve to - // it. The normal theme pathway (`set_theme_name`) also refreshes the - // picker's `selected_theme` (via `theme_config_updated`) and any external - // theme listeners; its no-op guard avoids a redundant emit when the host - // already matches. - let default_theme = self.presentation_props.available_themes.first().cloned(); - if let Some(theme) = self.active_renderer.theme().or(default_theme) { - let presentation = ctx.props().presentation.clone(); - ApiFuture::spawn(async move { + 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) + { presentation.set_theme_name(Some(&theme)).await?; - Ok(()) - }); - } + } + + Ok(()) + }); } } diff --git a/rust/perspective-viewer/src/rust/components/viewer/render.rs b/rust/perspective-viewer/src/rust/components/viewer/render.rs index a66c48d698..7a69012e35 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/render.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/render.rs @@ -82,8 +82,8 @@ impl PerspectiveViewer { let metadata = self.session_props.metadata.clone(); let on_select_tab = ctx.link().callback(SettingsPanelTabChanged); let on_auto_width = ctx.link().callback(SettingsPanelAutoWidth); - let settings_panel = html! { - if is_settings_open { + let settings_panel = if is_settings_open { + html! { - } else { - // Explicit empty so the always-rendered `app_panel` SplitPanel's - // `skip_empty` drops this pane when settings is closed. - <> } + } else { + html! { <> } }; let on_settings = ctx.link().callback(|()| ToggleSettingsInit(None, None)); @@ -147,21 +145,13 @@ impl PerspectiveViewer { } }; - // The toolbar Reset button targets the ACTIVE panel only; the - // whole-element reset is the public `reset()` API. let on_reset = ctx.link().callback(|all| ResetPanel(None, all, None)); - let is_settings_open = self.settings_open - && matches!(self.session_props.has_table, Some(TableLoadState::Loaded)); - let main_panel = html! { vec![NewPanel(id)], @@ -186,9 +176,6 @@ impl PerspectiveViewer { .panel_ids() .iter() .map(|id| { - // Tab label: the panel's *explicit* title only. When absent - // the tab shows a muted placeholder (see `PanelTab`), NOT the - // table / plugin name. let title = ctx .props() .workspace @@ -220,7 +207,7 @@ impl PerspectiveViewer { /> }; - let is_single_panel = if ctx.props().workspace.panel_ids().len() == 1 { + let is_single_panel = if ctx.props().workspace.placed_count() == 1 { "only-child" } else { "" @@ -230,13 +217,6 @@ impl PerspectiveViewer {
- // Always render the `SplitPanel` with `main_panel` as its - // last (flex-fill) pane. Toggling the settings sidebar then - // only adds/removes the leading settings pane — it never - // reparents `main_panel`, so `MainPanel` (and the embedded - // `` + the ``s projecting the plugins) - // is reconciled in place instead of remounted. When closed, - // `settings_panel` is `<>`, which `skip_empty` drops. diff --git a/rust/perspective-viewer/src/rust/components/viewer/settings.rs b/rust/perspective-viewer/src/rust/components/viewer/settings.rs index e636e522af..879e40fba2 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/settings.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/settings.rs @@ -180,11 +180,17 @@ impl PerspectiveViewer { // finalizer. let result: ApiResult = async { if is_open { - presize_visible_panels_grown(&workspace, &elem).await; + let presents = presize_visible_panels_grown(&workspace, &elem).await; let (notify, rendered) = channel::<()>(); callback.emit(notify); presentation.set_settings_open(false); rendered.await?; + // `notify` fires in `rendered()` (same task as the + // Yew DOM patch) and this future resumes as one of + // its microtasks — so the staged reveal below and + // the pane's geometry change reach the screen in a + // single paint. + presents.reveal(); // I6: the exactness-finalizer resize is part of // what this toggle caused — await it here rather // than leaving it to the ResizeObserver (whose @@ -192,15 +198,18 @@ impl PerspectiveViewer { // backstop). resize_visible_panels(&workspace).await; } else { - if let Some((delta_w, delta_h)) = open_deltas { + let presents = if let Some((delta_w, delta_h)) = open_deltas { presize_visible_panels_open(&workspace, &elem, delta_w, delta_h) - .await; - } + .await + } else { + StagedPresents::default() + }; let (notify, rendered) = channel::<()>(); callback.emit(notify); presentation.set_settings_open(true); rendered.await?; + presents.reveal(); resize_visible_panels(&workspace).await; } Ok(JsValue::UNDEFINED) @@ -259,8 +268,14 @@ impl PerspectiveViewer { let elem = ctx.props().elem.clone(); let link = ctx.link().clone(); ApiFuture::spawn(async move { - presize_visible_panels_pane_width(&workspace, &elem, pane_width as f64).await; + let presents = + presize_visible_panels_pane_width(&workspace, &elem, pane_width as f64).await; link.send_message(SettingsDividerCommit(pane_width)); + // Same task as the commit's re-render (whether Yew drained it + // synchronously inside `send_message` or deferred it to a + // microtask): the staged reveal and the pane-width geometry + // land in one paint. + presents.reveal(); Ok(()) }); } else { diff --git a/rust/perspective-viewer/src/rust/components/viewer/wiring.rs b/rust/perspective-viewer/src/rust/components/viewer/wiring.rs index 75630edb43..3a02f11680 100644 --- a/rust/perspective-viewer/src/rust/components/viewer/wiring.rs +++ b/rust/perspective-viewer/src/rust/components/viewer/wiring.rs @@ -388,10 +388,24 @@ 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`. + let staged_sub = { + let cb = ctx.link().callback(|_: ()| LayoutChanged); + ctx.props() + .workspace + .staged_changed() + .add_notify_listener(&cb) + }; + let mut subscriptions = Vec::new(); subscriptions.extend(presentation_props_sub); subscriptions.extend(dragdrop_props_sub); subscriptions.push(filters_sub); + subscriptions.push(staged_sub); subscriptions } diff --git a/rust/perspective-viewer/src/rust/custom_elements/copy_dropdown.rs b/rust/perspective-viewer/src/rust/custom_elements/copy_dropdown.rs index 7423f51b00..e8757d2241 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/copy_dropdown.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/copy_dropdown.rs @@ -137,8 +137,9 @@ impl CopyDropDownMenuElement { } pub fn __set_model(&self, parent: &PerspectiveViewerElement) { - let panel = parent.workspace.active_panel(); - self.set_config_model(&panel.session, &panel.renderer, &parent.presentation) + if let Some(panel) = parent.workspace.active_panel() { + self.set_config_model(&panel.session, &panel.renderer, &parent.presentation); + } } pub fn connected_callback(&self) {} diff --git a/rust/perspective-viewer/src/rust/custom_elements/export_dropdown.rs b/rust/perspective-viewer/src/rust/custom_elements/export_dropdown.rs index a03b02d7a1..83d58f042f 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/export_dropdown.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/export_dropdown.rs @@ -138,8 +138,9 @@ impl ExportDropDownMenuElement { } pub fn __set_model(&self, parent: &PerspectiveViewerElement) { - let panel = parent.workspace.active_panel(); - self.set_config_model(&panel.session, &panel.renderer, &parent.presentation) + if let Some(panel) = parent.workspace.active_panel() { + self.set_config_model(&panel.session, &panel.renderer, &parent.presentation); + } } pub fn connected_callback(&self) {} diff --git a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs index 69872f1374..ed02747b92 100644 --- a/rust/perspective-viewer/src/rust/custom_elements/viewer.rs +++ b/rust/perspective-viewer/src/rust/custom_elements/viewer.rs @@ -20,6 +20,7 @@ use futures::future::join_all; use js_sys::{Array, JsString}; use perspective_client::config::ViewConfigUpdate; use perspective_client::utils::PerspectiveResultExt; +use perspective_js::utils::global; use perspective_js::{JsViewConfig, JsViewWindow, Table, View, apierror}; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -34,9 +35,8 @@ use crate::custom_events::*; use crate::js::*; use crate::presentation::*; use crate::queries::*; -use crate::renderer::*; use crate::root::Root; -use crate::session::{ResetOptions, Session}; +use crate::session::{ResetOptions, TableLoadState}; use crate::tasks::*; use crate::utils::*; use crate::workspace::{Panel, PanelId, Workspace}; @@ -84,6 +84,10 @@ extern "C" { /// `ApiFuture<()>` would otherwise erase to `Promise`). #[wasm_bindgen(typescript_type = "Promise")] pub type JsVoidPromise; + + /// `save()` return: a single-panel config. + #[wasm_bindgen(typescript_type = "Promise")] + pub type JsViewerConfigPromise; } #[derive(serde::Deserialize, Default)] @@ -143,8 +147,9 @@ pub struct PerspectiveViewerElement { pub(crate) elem: HtmlElement, pub(crate) root: Root, resize_handle: Rc>>, - intersection_handle: Rc>>, - _subscriptions: Rc<[Subscription; 1]>, + intersection_handle: Rc>>, + hosted_table_subs: HostedTableSubs, + _subscriptions: Rc<[Subscription; 2]>, _custom_event_subs: Rc>, } @@ -202,7 +207,9 @@ impl PerspectiveViewerElement { let active = this .presentation .is_settings_open() - .then(|| this.workspace.active_id().as_str().to_owned()); + .then(|| this.workspace.active_id()) + .flatten() + .map(|id| id.as_str().to_owned()); let layout = this .layout_element() @@ -232,7 +239,9 @@ fn eject_client_panels( ids: Vec, ) -> ApiFuture<()> { clone!(workspace, root); + let effect = workspace.effects().guard(); ApiFuture::new_throttled(async move { + let _effect = effect; for id in ids { let (completion, receiver) = Completion::new(); root.borrow() @@ -272,24 +281,12 @@ impl PerspectiveViewerElement { fn new_from_shadow(elem: web_sys::HtmlElement, shadow_root: web_sys::Element) -> Self { // Application State. let presentation = Presentation::new(&elem); - let session = Session::new(); - let renderer = Renderer::new(&elem); - if let Some(theme) = elem.get_attribute("theme") { - renderer.set_theme(Some(theme.clone())); - clone!(presentation, renderer); - ApiFuture::spawn(async move { - let themes = presentation.get_available_themes().await?; - if !themes.contains(&theme) && renderer.theme().as_deref() == Some(theme.as_str()) { - renderer.set_theme(None); - } - - Ok(()) - }); - } - // The active panel's subscriptions (redraw + custom-event fanout) - let seed_subs = wire_panel_subs(&elem, &presentation, &session, &renderer); - let workspace = Workspace::new(session, renderer, seed_subs); + // Boot with ZERO panels — an unconfigured element is a blank stage. The + // first `load`/`restore`/`addPanel` creates the first panel, which + // adopts the element's `theme` attribute when set (see + // `create_panel_model`'s authored-theme boot). + let workspace = Workspace::new(); let custom_event_subs = wire_element_events(&elem, &presentation, &workspace); // Create Yew App @@ -322,7 +319,8 @@ impl PerspectiveViewerElement { }); let resize_handle = ResizeObserverHandle::new(&elem, &workspace, &presentation, &root); - let intersect_handle = IntersectionObserverHandle::new(&elem, &presentation, &workspace); + let intersect_handle = AutoPauseHandle::new(&elem, &presentation, &workspace); + let (lifecycle_sub, hosted_table_subs) = wire_table_lifecycle(&workspace, &presentation); Self { elem, @@ -331,7 +329,8 @@ impl PerspectiveViewerElement { workspace, resize_handle: Rc::new(RefCell::new(Some(resize_handle))), intersection_handle: Rc::new(RefCell::new(Some(intersect_handle))), - _subscriptions: Rc::new([eject_sub]), + hosted_table_subs, + _subscriptions: Rc::new([eject_sub, lifecycle_sub]), _custom_event_subs: Rc::new(custom_event_subs), } } @@ -418,16 +417,73 @@ impl PerspectiveViewerElement { /// await viewer.load(table); /// ``` pub fn load(&self, client: JsClientLoad) -> ApiResult> { + let effect = self.workspace.effects().guard(); let table: JsValue = client.into(); let promise = table .clone() .dyn_into::() .unwrap_or_else(|_| js_sys::Promise::resolve(&table)); - // Element-level `load` targets the active panel's engines. Selection - // here (rather than at element construction) is what keeps the - // registry race safe — by `load()` time real plugins have registered. - let panel = self.workspace.active_panel(); + // Resolve the target panel. On an EMPTY element (zero panels): + // - a synchronously-detectable `Client` registers inertly with NO panel (the + // common `load(client)` — no phantom panel is left behind); + // - otherwise (a resolved `Table`, or a `Promise` whose type isn't yet known) + // the first panel is RESERVED synchronously here — a full panel model held + // in the workspace's reservation slot, NOT placed — so its ordering position + // is fixed at the call site: a `restore()` fired right after an unawaited + // `load()` CLAIMS the reservation (placing it) and targets THIS panel, not a + // second one. The reservation is likewise placed when the payload proves to + // be a `Table` (or the load fails, surfacing its error), and discarded — + // only while still unclaimed — for an inert `Client` payload. Placement and + // discard are both atomic slot transfers (`Workspace::claim_reserved` / + // `Workspace::take_reserved`), so an inert payload disposing a panel a + // racing `restore` claimed is unrepresentable. + // A pre-existing active panel is used as-is (a `Client` registers + // inertly against it, never clearing its table). + let (panel, notify) = match self.workspace.active_panel() { + Some(panel) => (panel, None), + None => { + // Empty element — classify the payload synchronously where possible. + if let Ok(Some(client)) = + try_from_js_option::(table.clone()) + { + // A resolved `Client` registers SYNCHRONOUSLY (so an unawaited + // `load(client)` is visible to a `restore()` fired right after, + // which creates the first panel and federates against loaded + // clients) and creates NO panel — inert. + self.workspace + .set_default_client(client.get_client().clone()); + return Ok(ApiFuture::new(async { Ok(()) })); + } + + // A resolved `Table` (or a `Promise` whose type isn't yet known) + // adopts the pending reservation (a second `load()` on a + // still-empty element), else reserves a fresh panel model. + let panel = self.workspace.reserved_panel().unwrap_or_else(|| { + create_panel_model( + &self.elem, + &self.presentation, + &self.workspace, + None, + ViewerConfigUpdate::default(), + None, + Placement::Reserved, + ); + self.workspace + .reserved_panel() + .expect("just-reserved panel is present") + }); + + // Carrying `Some(notify)` marks this load as the reservation's + // owner — the only call that may place or discard it below. + (panel, Some(self.layout_changed_notify())) + }, + }; + + // A `Table` payload targets this panel's engines; a `Client` registers + // inertly against it. Selecting the panel here (not at construction) + // keeps the registry race safe — by `load()` time real plugins have + // registered. let session = panel.session; let renderer = panel.renderer; @@ -445,10 +501,11 @@ impl PerspectiveViewerElement { clone!(self.workspace, self.presentation); Ok(ApiFuture::new_throttled(async move { + let _effect = effect; renderer.set_throttle(None); let _run_token = session.begin_config_run(); let result = { - clone!(session, renderer); + clone!(session, renderer, workspace, notify); renderer .clone() .render_task(|guard| async move { @@ -461,11 +518,15 @@ impl PerspectiveViewerElement { if let Ok(Some(table)) = try_from_js_option::(jstable.clone()) { - tracing::warn!(DEPRECATED_TABLE_MESSAGE); + tracing::warn!("{}", DEPRECATED_TABLE_MESSAGE); let Some(journal) = session.take_pending_load(generation) else { - return Ok(()); + return Ok(None); }; + if let Some(notify) = ¬ify { + place_reserved(&workspace, notify); + } + let _plugin = renderer.ensure_plugin_selected()?; let _ = renderer.mount_active_plugin(); session @@ -507,19 +568,27 @@ impl PerspectiveViewerElement { ) .await?; - Ok(()) + Ok(None) } else if let Ok(Some(client)) = wasm_bindgen_derive::try_from_js_option::< perspective_js::Client, >(jstable) { - if session.take_pending_load(generation).is_none() { - return Ok(()); - } + // INERT: register the client only — never rebind or + // reset the active panel (its table is preserved). + // Panels bind their client lazily at table-resolution + // time (`Workspace::resolve_client_for_table`). The + // window is discarded (not replayed): a `Client` + // performs no reset, and any racing `restore`'s + // 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() + } else { + None + }; - let inner_client = client.get_client().clone(); - session.set_client(inner_client.clone()); - workspace.set_default_client(inner_client); - Ok(()) + workspace.set_default_client(client.get_client().clone()); + Ok(discard) } else { session.take_pending_load(generation); Err(ApiError::new("Invalid argument")) @@ -528,12 +597,19 @@ impl PerspectiveViewerElement { .await }; - if let Err(e) = &result { - session.take_pending_load(generation); - session.set_error(false, e.clone()).await?; - } + match result { + Err(e) => { + session.take_pending_load(generation); + if let Some(notify) = ¬ify { + place_reserved(&workspace, notify); + } - result + session.set_error(false, e.clone()).await?; + Err(e) + }, + Ok(Some(panel)) => eject_panel(panel).await, + Ok(None) => Ok(()), + } })) } @@ -559,7 +635,15 @@ impl PerspectiveViewerElement { /// await viewer.delete(); /// ``` pub fn delete(self) -> ApiFuture<()> { - delete_all(&self.workspace, &self.root) + let subs = std::mem::take(&mut *self.hosted_table_subs.borrow_mut()); + let teardown = delete_all(&self.workspace, &self.root); + ApiFuture::new(async move { + for (client, id) in subs { + let _ = client.remove_hosted_tables_update(id).await; + } + + teardown.await + }) } /// Remove a [`Client`] from this `` and dispose every @@ -579,11 +663,20 @@ impl PerspectiveViewerElement { /// ``` pub fn eject(&mut self, options: Option) -> ApiFuture<()> { let ClientOptions { client } = parse_options(options); - let Some(target) = client.or_else(|| { - self.workspace - .active_client() - .map(|c| c.get_name().to_owned()) - }) else { + // Default target: the active panel's client, or — when the active panel + // is unbound (`load(Client)` is now inert) — the default client. + let Some(target) = client + .or_else(|| { + self.workspace + .active_client() + .map(|c| c.get_name().to_owned()) + }) + .or_else(|| { + self.workspace + .default_client() + .map(|c| c.get_name().to_owned()) + }) + else { return ApiFuture::new_throttled(async move { Ok(()) }); }; @@ -782,27 +875,41 @@ impl PerspectiveViewerElement { /// ``` pub fn flush(&self) -> ApiFuture<()> { let workspace = self.workspace.clone(); + let presentation = self.presentation.clone(); ApiFuture::new_throttled(async move { - // We must let two AFs pass to guarantee listeners to the DOM state - // have themselves triggered, or else `request_animation_frame` - // may finish before a `ResizeObserver` triggered before is - // notifiedd. - // - // https://github.com/w3c/csswg-drafts/issues/9560 - // https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering - request_animation_frame().await; - request_animation_frame().await; - for panel in workspace - .panel_ids() - .into_iter() - .filter_map(|id| workspace.panel(&id)) - { - panel.renderer.clone().with_lock(async { Ok(()) }).await?; - panel.renderer.with_lock(async { Ok(()) }).await?; - panel.session.settle_dispatches().await?; - } + loop { + workspace.effects().settle().await; + let panels = workspace + .reserved_panel() + .into_iter() + .chain(workspace.panels()) + .collect::>(); + + let mut fulfilled = false; + for panel in &panels { + panel.renderer.clone().with_lock(async { Ok(()) }).await?; + panel.renderer.clone().with_lock(async { Ok(()) }).await?; + panel.session.settle_dispatches().await?; + if !global::document().hidden() + && presentation.is_visible() + && !panel.renderer.is_plugin_activated()? + && panel.session.get_error().is_none() + && matches!(panel.session.has_table(), Some(TableLoadState::Loaded)) + { + set_panel_paused(&panel.session, &panel.renderer, &presentation, true) + .await?; + if !panel.renderer.is_plugin_activated()? { + just_render(&panel.session, &panel.renderer)?.await?; + } - Ok(()) + fulfilled = true; + } + } + + if !fulfilled && workspace.effects().is_empty() { + return Ok(()); + } + } }) } @@ -816,6 +923,10 @@ impl PerspectiveViewerElement { /// [`Self::addPanel`] but with a caller-chosen id. As with a created panel, /// the element-level `settings`/`theme` fields are ignored in that case. /// + /// 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` /// map) must be applied via [`Self::restoreWorkspace`] — its `panels` / /// `layout` keys are ignored here. @@ -852,40 +963,70 @@ impl PerspectiveViewerElement { options: Option, ) -> JsVoidPromise { let PanelOptions { panel: name } = parse_options(options); + let effect = self.workspace.effects().guard(); let this = self.clone(); let fut = ApiFuture::new_throttled(async move { + let _effect = effect; let id = name.map(PanelId::from); let update = ViewerConfigUpdate::decode(&update)?; match this.workspace.panel_or_active(id.as_ref()) { // An existing (or the active) panel — update it in place. Some(panel) => { - let active = panel.id == this.workspace.active_id(); + let active = this.workspace.active_id().as_ref() == Some(&panel.id); restore_panel( &panel.session, &panel.renderer, &this.presentation, + &this.workspace, Some(&this.root), RestoreMode::Existing { active }, update, ) .await }, - // A `panel` that matches no panel — create it (upsert), routing - // through the shared `create_panel` (`RestoreMode::Fresh`) - // pipeline so the new panel's id is the requested `panel`. + // 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(); - create_panel( - &this.elem, - &this.presentation, - &this.workspace, - ¬ify, - id, - update, - None, - ) - .await?; - Ok(()) + 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, + Some(&this.root), + RestoreMode::Existing { active: true }, + update, + ) + .await + }, + None => { + create_panel( + &this.elem, + &this.presentation, + &this.workspace, + ¬ify, + id, + update, + None, + ) + .await?; + Ok(()) + }, + } }, } }); @@ -907,8 +1048,10 @@ impl PerspectiveViewerElement { /// ``` pub fn restoreWorkspace(&self, update: JsWorkspaceConfigUpdate) -> JsVoidPromise { let update: JsViewerConfigUpdate = update.unchecked_into(); + let effect = self.workspace.effects().guard(); let this = self.clone(); let fut = ApiFuture::new(async move { + let _effect = effect; let (contents, eject_tasks) = sync_update_panels(&this, update)?; let results = join_all(contents.into_iter().map(|(id, session, renderer, config)| { let presentation = this.presentation.clone(); @@ -919,6 +1062,7 @@ impl PerspectiveViewerElement { &session, &renderer, &presentation, + &workspace, None, RestoreMode::Fresh, config, @@ -949,9 +1093,20 @@ impl PerspectiveViewerElement { /// re-render. Calling this method is equivalent to clicking the error reset /// button in the UI. pub fn resetError(&self) -> ApiFuture<()> { - let panel = self.workspace.active_panel(); - ApiFuture::spawn(panel.session.reset(ResetOptions::default())); + let Some(panel) = self.workspace.active_panel() else { + return ApiFuture::new_throttled(async move { Ok(()) }); + }; + + let reset_effect = self.workspace.effects().guard(); + let reset_task = panel.session.reset(ResetOptions::default()); + ApiFuture::spawn(async move { + let _effect = reset_effect; + reset_task.await + }); + + let effect = self.workspace.effects().guard(); ApiFuture::new_throttled(async move { + let _effect = effect; apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?.await?; Ok(()) }) @@ -1167,9 +1322,11 @@ impl PerspectiveViewerElement { /// ``` pub fn reset(&self, reset_all: Option, options: Option) -> ApiFuture<()> { let PanelOptions { panel: name } = parse_options(options); + let effect = self.workspace.effects().guard(); let this = self.clone(); let all = reset_all.unwrap_or_default(); ApiFuture::new_throttled(async move { + let _effect = effect; let (completion, receiver) = Completion::new(); { let root = this.root.borrow(); @@ -1226,9 +1383,17 @@ impl PerspectiveViewerElement { .unwrap_or_default() .unwrap_or_default(); + let effect = self.workspace.effects().guard(); let workspace = self.workspace.clone(); ApiFuture::new_throttled(async move { - let panel = workspace.active_panel(); + let _effect = effect; + // With zero panels there is nothing to resize; fan out to whatever + // panels exist otherwise. + let Some(panel) = workspace.active_panel() else { + resize_visible_panels(&workspace).await; + return Ok(()); + }; + if !panel.renderer.is_plugin_activated()? { apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())? .await?; @@ -1282,10 +1447,11 @@ impl PerspectiveViewerElement { /// Sets the auto-pause behavior of this component. /// - /// When `true`, this `` will register an - /// `IntersectionObserver` on itself and subsequently skip rendering - /// whenever its viewport visibility changes. Auto-pause is enabled by - /// default. + /// When `true`, this `` will skip rendering + /// whenever it cannot be seen — tracked via an `IntersectionObserver` + /// on itself (scrolled out of the viewport, `display: none`) combined + /// with the document's page visibility (backgrounded browser tab, + /// minimized window). Auto-pause is enabled by default. /// /// # Arguments /// @@ -1301,7 +1467,7 @@ impl PerspectiveViewerElement { #[wasm_bindgen] pub fn setAutoPause(&self, autopause: bool) -> ApiFuture<()> { if autopause { - let handle = Some(IntersectionObserverHandle::new( + let handle = Some(AutoPauseHandle::new( &self.elem, &self.presentation, &self.workspace, @@ -1310,26 +1476,19 @@ impl PerspectiveViewerElement { *self.intersection_handle.borrow_mut() = handle; } else { *self.intersection_handle.borrow_mut() = None; + let effect = self.workspace.effects().guard(); let workspace = self.workspace.clone(); let presentation = self.presentation.clone(); return ApiFuture::new(async move { + let _effect = effect; for id in workspace.panel_ids() { - if let Some(panel) = workspace.panel(&id) - && panel.session.set_pause(false) - { - let result = restore_and_render( - &panel.session, - &panel.renderer, - &presentation, - RunOrigin::Internal, - ViewerConfigUpdate::default(), - async { Ok(()) }, - ) - .await; - - if let Err(e) = result { - panel.session.set_error(false, e).await?; - } + if let Some(panel) = workspace.panel(&id) { + // A failed resume is already surfaced as that + // panel's error state — don't let it abort the + // remaining panels' resumes. + let _ = + set_panel_paused(&panel.session, &panel.renderer, &presentation, true) + .await; } } @@ -1398,7 +1557,9 @@ impl PerspectiveViewerElement { #[wasm_bindgen] pub fn restyleElement(&self) -> ApiFuture { clone!(self.workspace, self.presentation); + 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() @@ -1430,7 +1591,9 @@ impl PerspectiveViewerElement { #[wasm_bindgen] pub fn resetThemes(&self, themes: Option>) -> ApiFuture { clone!(self.workspace, self.presentation); + let effect = workspace.effects().guard(); ApiFuture::new(async move { + let _effect = effect; let themes: Option> = themes .unwrap_or_default() .iter() @@ -1439,9 +1602,8 @@ impl PerspectiveViewerElement { let theme_name = presentation.get_selected_theme_name().await; presentation.reset_available_themes(themes).await; - let reset_theme = presentation - .get_available_themes() - .await? + let available = presentation.get_available_themes().await?; + let reset_theme = available .iter() .find(|y| theme_name.as_ref() == Some(y)) .cloned(); @@ -1453,6 +1615,19 @@ impl PerspectiveViewerElement { .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?; @@ -1506,8 +1681,10 @@ impl PerspectiveViewerElement { /// ``` #[wasm_bindgen] pub fn toggleConfig(&self, force: Option) -> ApiFuture { + let effect = self.workspace.effects().guard(); let root = self.root.clone(); ApiFuture::new(async move { + let _effect = effect; let force = force.map(SettingsUpdate::Update); let (sender, receiver) = channel::>(); root.borrow().as_ref().into_apierror()?.send_message( @@ -1525,9 +1702,8 @@ impl PerspectiveViewerElement { pub fn getAllPlugins(&self) -> Array { self.workspace .active_renderer() - .get_all_plugins() - .iter() - .collect::() + .map(|r| r.get_all_plugins().iter().collect::()) + .unwrap_or_default() } /// Gets a plugin Custom Element with the `name` field, or get the active @@ -1539,9 +1715,13 @@ impl PerspectiveViewerElement { /// or `None` for the active plugin's Custom Element. #[wasm_bindgen] pub fn getPlugin(&self, name: Option) -> ApiResult { + let renderer = self + .workspace + .active_renderer() + .ok_or_else(|| ApiError::new("No active panel"))?; match name { - None => self.workspace.active_renderer().ensure_plugin_selected(), - Some(name) => self.workspace.active_renderer().get_plugin(&name), + None => renderer.ensure_plugin_selected(), + Some(name) => renderer.get_plugin(&name), } } @@ -1555,8 +1735,10 @@ impl PerspectiveViewerElement { #[wasm_bindgen] pub fn addPanel(&self, update: JsViewerConfigUpdate) -> ApiFuture { clone!(self.elem, self.presentation, self.workspace); + let effect = workspace.effects().guard(); let notify = self.layout_changed_notify(); ApiFuture::new(async move { + let _effect = effect; let update = ViewerConfigUpdate::decode(&update)?; let id = create_panel( &elem, @@ -1583,10 +1765,13 @@ impl PerspectiveViewerElement { } /// The id of the active panel — the one the settings panel and status-bar - /// toolbar target. + /// toolbar target — or `null` when the element has zero panels. #[wasm_bindgen] pub fn getActivePanel(&self) -> JsValue { - JsValue::from_str(self.workspace.active_id().as_str()) + self.workspace + .active_id() + .map(|id| JsValue::from_str(id.as_str())) + .unwrap_or(JsValue::NULL) } /// Make the panel with id `name` the active panel, re-targeting the @@ -1596,8 +1781,10 @@ impl PerspectiveViewerElement { /// (invariant I6). #[wasm_bindgen] pub fn setActivePanel(&self, name: String) -> ApiFuture<()> { + let effect = self.workspace.effects().guard(); let root = self.root.clone(); ApiFuture::new(async move { + let _effect = effect; let (completion, receiver) = Completion::new(); root.borrow() .as_ref() @@ -1616,8 +1803,10 @@ impl PerspectiveViewerElement { /// I6). See also [`Self::addPanel`]. #[wasm_bindgen] pub fn removePanel(&self, name: String) -> ApiFuture<()> { + let effect = self.workspace.effects().guard(); let root = self.root.clone(); ApiFuture::new(async move { + let _effect = effect; let (completion, receiver) = Completion::new(); root.borrow() .as_ref() @@ -1649,10 +1838,12 @@ impl PerspectiveViewerElement { options: Option, ) -> ApiFuture<()> { let PanelOptions { panel: name } = parse_options(options); + let effect = self.workspace.effects().guard(); let this = self.clone(); ApiFuture::new_throttled(async move { + let _effect = effect; let panel = this.resolve_panel(name)?; - let was_active = panel.id == this.workspace.active_id(); + let was_active = this.workspace.active_id().as_ref() == Some(&panel.id); let locator = get_column_locator(&panel.session.metadata(), Some(column_name)); if !was_active { this.root.borrow().as_ref().into_apierror()?.send_message( diff --git a/rust/perspective-viewer/src/rust/custom_events.rs b/rust/perspective-viewer/src/rust/custom_events.rs index 6b4ab0519f..ef4aa68698 100644 --- a/rust/perspective-viewer/src/rust/custom_events.rs +++ b/rust/perspective-viewer/src/rust/custom_events.rs @@ -119,7 +119,8 @@ fn dispatch_column_settings_open_changed( // apply to the active panel, so scope the highlight to its plugin; fall back // to the host if none is drawn. let target: web_sys::EventTarget = workspace - .panel(&workspace.active_id()) + .active_id() + .and_then(|id| workspace.panel(&id)) .and_then(|panel| panel.renderer.active_plugin()) .map(|plugin| plugin.unchecked_into()) .unwrap_or_else(|| elem.clone().unchecked_into()); @@ -209,8 +210,9 @@ pub fn wire_element_events( let theme_sub = presentation.theme_config_updated.add_listener({ clone!(elem, presentation, workspace); move |_| { - let panel = workspace.active_panel(); - dispatch_config_update(&elem, &panel.session, &panel.renderer, &presentation); + if let Some(panel) = workspace.active_panel() { + dispatch_config_update(&elem, &panel.session, &panel.renderer, &presentation); + } } }); @@ -218,8 +220,9 @@ pub fn wire_element_events( clone!(elem, presentation, workspace); move |open: bool| { dispatch_event(&elem, "toggle-settings", open).unwrap(); - let panel = workspace.active_panel(); - dispatch_config_update(&elem, &panel.session, &panel.renderer, &presentation); + if let Some(panel) = workspace.active_panel() { + dispatch_config_update(&elem, &panel.session, &panel.renderer, &presentation); + } } }); diff --git a/rust/perspective-viewer/src/rust/js/plugin.rs b/rust/perspective-viewer/src/rust/js/plugin.rs index 548cc5f2e8..d3272f1d54 100644 --- a/rust/perspective-viewer/src/rust/js/plugin.rs +++ b/rust/perspective-viewer/src/rust/js/plugin.rs @@ -10,8 +10,12 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +use std::cell::RefCell; +use std::collections::HashMap; + use perspective_js::JsViewWindow; use perspective_js::utils::*; +use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use crate::config::PluginStaticConfig; @@ -27,6 +31,25 @@ extern "C" { /// A `` plugin custom element. /// + /// # Capability tiers + /// + /// Only `get_static_config`, `draw`, `save`/`restore` and `delete` are + /// REQUIRED. Every other method is optional — detected once per element + /// tag ([`PluginCapabilities`], methods live on the class prototype) — + /// with a defined host fallback, so a minimal plugin renders correctly + /// and each added method buys performance or polish, never correctness: + /// + /// | method | host fallback when absent | + /// |------------|--------------------------------------------------------| + /// | `update` | `draw` — every repaint is a full render (such plugins | + /// | | must NOT treat `draw` as a state-reset license) | + /// | `resize` | no-op — CSS reflows the box; next dispatch repaints | + /// | `restyle` | no-op — the plugin re-reads CSS at its next render | + /// | `clear` | no-op | + /// | `deselect` | no-op | + /// | `presize` | held style-override presize (`Renderer` resize paths) | + /// | `render` | image export reports unsupported | + /// /// # Call discipline /// /// Plugin implementations assume the host NEVER overlaps calls on the @@ -139,43 +162,21 @@ extern "C" { #[wasm_bindgen(method)] pub fn delete(this: &JsPerspectiveViewerPlugin); - /// Re-read the `--psp-*` CSS custom properties (sync). Dispatched ONLY - /// when the effective theme genuinely changed, state-keyed by - /// [`crate::renderer::Renderer::needs_restyle`] (the effective theme - /// vs. the one recorded at this plugin's last capture — first paint or - /// last restyle), from two sites: fused immediately BEFORE a - /// `draw`/`update` inside the same locked dispatch - /// (`Renderer::draw_view`, "restyle then draw" — one render pass in - /// the new theme), or as `Renderer::restyle_all`'s locked - /// restyle-then-`update` pair when no other dispatch is coming - /// (theme picker, `resetThemes`, default-theme discovery, restore - /// tails). Exception: the public `restyleElement()` API restyles - /// unconditionally — it is the "my external CSS changed" affordance, - /// outside what captured-theme state can know. (Plus one - /// `mount_plugin` restyle on first light-DOM mount.) - #[wasm_bindgen(method)] - pub fn restyle( + #[wasm_bindgen(method, js_name = restyle)] + fn _restyle( this: &JsPerspectiveViewerPlugin, ); - /// Offscreen export render (`copy`/`export`) — returns an image - /// `Blob`; not a dispatch verb (does not touch the plugin's mounted - /// DOM state), but still serialized under the draw lock. - #[wasm_bindgen(method, catch)] - pub async fn render( + #[wasm_bindgen(method, catch, js_name = render)] + async fn _render( this: &JsPerspectiveViewerPlugin, view: perspective_js::View, viewport: Option, ) -> ApiResult; /// Full render of a `View` that is NEW to this plugin — dispatched iff - /// `bind_view` REBUILT the engine `View`, or this freshly-selected - /// plugin owes its first paint of the bound one (see "Dispatch - /// semantics" on [`JsPerspectiveViewerPlugin`]). Witness-gated: call - /// through [`crate::renderer::Renderer::draw_fresh`], which requires - /// the [`crate::session::FreshView`] token — a bare `draw` dispatch - /// does not exist in the host. Plugins may treat this as "new data - /// shape" and reset zoom/scroll/selection-domain state. + /// `bind_view` REBUILT the engine `View`. The only REQUIRED rendering + /// method. #[wasm_bindgen(method, catch)] pub async fn draw( this: &JsPerspectiveViewerPlugin, @@ -185,16 +186,8 @@ extern "C" { force: bool ) -> ApiResult<()>; - /// Repaint of the SAME `View` — dispatched iff one of the six - /// plugin-visible sources changed (see "Dispatch semantics" on - /// [`JsPerspectiveViewerPlugin`]); never defensively. Reaches the - /// plugin via [`crate::renderer::Renderer::update_bound`] (pipeline - /// runs — `Adopted` deltas, changed-config delivery, public no-op - /// refresh) or [`crate::renderer::Renderer::update_lazy`] - /// (`table_updated` data refreshes, the config-apply tasks, warning - /// dismiss), both locked. - #[wasm_bindgen(method, catch)] - pub async fn update( + #[wasm_bindgen(method, catch, js_name = update)] + async fn _update( this: &JsPerspectiveViewerPlugin, view: perspective_js::View, column_limit: Option, @@ -202,29 +195,17 @@ extern "C" { force: bool ) -> ApiResult<()>; - #[wasm_bindgen(method, catch)] - pub async fn clear(this: &JsPerspectiveViewerPlugin) -> ApiResult; + #[wasm_bindgen(method, catch, js_name = clear)] + async fn _clear(this: &JsPerspectiveViewerPlugin) -> ApiResult; - /// Repaint from retained state — dispatched iff geometry or visibility - /// changed: box resizes (resize observers, presize sweeps, settings - /// toggles) and the panel-ACTIVATION chrome nudge - /// ([`crate::renderer::Renderer::activation_repaint`], stamped inside - /// one locked dispatch). Same `View`, same data, no CSS re-read — - /// implementations should no-op while hidden (`offsetParent == null`). - #[wasm_bindgen(method, catch)] - pub async fn resize(this: &JsPerspectiveViewerPlugin) -> ApiResult; + #[wasm_bindgen(method, catch, js_name = resize)] + async fn _resize(this: &JsPerspectiveViewerPlugin) -> ApiResult; - /// OPTIONAL — clear any visible selection state (highlighted rows, - /// pinned tooltips) WITHOUT emitting selection events. Invoked by the - /// host when an element-level global filter contributed by this panel's - /// selection is removed (`GlobalFilterBar` chip × / "Clear"), so the - /// selection visual can't outlive the filter it produced. Call through - /// [`JsPerspectiveViewerPlugin::deselect`], which no-ops for plugins - /// that don't implement it. Implementations may redraw, so callers must - /// hold the plugin's per-`Renderer` draw lock (see "Call discipline"). #[wasm_bindgen(method, catch, js_name = deselect)] async fn _deselect(this: &JsPerspectiveViewerPlugin) -> ApiResult<()>; + #[wasm_bindgen(method, catch, js_name = presize)] + async fn _presize(this: &JsPerspectiveViewerPlugin, width: f64, height: f64) -> ApiResult; } impl From for PluginStaticConfig { @@ -233,6 +214,49 @@ impl From for PluginStaticConfig { } } +/// Which OPTIONAL plugin methods a plugin implements (see "Capability +/// tiers" on [`JsPerspectiveViewerPlugin`]). Detected once per +/// custom-element tag — methods live on the class prototype, so every +/// instance of a tag answers identically — and memoized; call sites read +/// cached flags instead of running per-call `Reflect` probes. +#[derive(Clone, Copy, Debug, Default)] +pub struct PluginCapabilities { + pub draw: bool, + pub update: bool, + pub resize: bool, + pub restyle: bool, + pub clear: bool, + pub deselect: bool, + pub presize: bool, + pub render: bool, +} + +thread_local! { + static PLUGIN_CAPABILITIES: RefCell> = + RefCell::new(HashMap::new()); +} + +impl PluginCapabilities { + fn detect(plugin: &JsPerspectiveViewerPlugin) -> Self { + let is_fn = |name: &str| { + js_sys::Reflect::get(plugin, &JsValue::from_str(name)) + .map(|x| x.is_function()) + .unwrap_or_default() + }; + + Self { + draw: is_fn("draw"), + update: is_fn("update"), + resize: is_fn("resize"), + restyle: is_fn("restyle"), + clear: is_fn("clear"), + deselect: is_fn("deselect"), + presize: is_fn("presize"), + render: is_fn("render"), + } + } +} + impl JsPerspectiveViewerPlugin { /// Read and deserialize the plugin's static config. Should only /// be called once per plugin (at registration time); cache the @@ -251,18 +275,134 @@ impl JsPerspectiveViewerPlugin { self._restore(token, &columns_config) } - /// Invoke the plugin's OPTIONAL `deselect()` (see `_deselect`) — a no-op - /// for plugins that don't implement it (e.g. the built-in `Debug` - /// plugin), rather than the `TypeError` a bare FFI call would raise. - pub async fn deselect(&self) -> ApiResult<()> { - let has_deselect = js_sys::Reflect::get(self, &JsValue::from_str("deselect")) - .map(|x| x.is_function()) - .unwrap_or_default(); + /// This plugin's memoized [`PluginCapabilities`] — the first call for a + /// given element tag runs the `Reflect` probes; all later calls (any + /// instance of the tag) read the cache. + pub fn capabilities(&self) -> PluginCapabilities { + let tag = self.unchecked_ref::().tag_name(); + PLUGIN_CAPABILITIES.with(|cache| { + *cache + .borrow_mut() + .entry(tag) + .or_insert_with(|| PluginCapabilities::detect(self)) + }) + } + + /// Repaint of the SAME `View` — dispatched iff one of the six + /// plugin-visible sources changed (see "Dispatch semantics" on + /// [`JsPerspectiveViewerPlugin`]); never defensively. Reaches the + /// plugin via [`crate::renderer::Renderer::update_bound`] (pipeline + /// runs — `Adopted` deltas, changed-config delivery, public no-op + /// refresh) or [`crate::renderer::Renderer::update_lazy`] + /// (`table_updated` data refreshes, the config-apply tasks, warning + /// dismiss), both locked. Tier fallback: a plugin without `update` + /// receives `draw` — it declared no incremental path, so every repaint + /// is a full render. + pub async fn update( + &self, + view: perspective_js::View, + column_limit: Option, + row_limit: Option, + force: bool, + ) -> ApiResult<()> { + if self.capabilities().update { + self._update(view, column_limit, row_limit, force).await + } else { + self.draw(view, column_limit, row_limit, force).await + } + } + + /// Repaint from retained state — dispatched iff geometry or visibility + /// changed: box resizes (resize observers, presize sweeps, settings + /// toggles) and the panel-ACTIVATION chrome nudge + /// ([`crate::renderer::Renderer::activation_repaint`], stamped inside + /// one locked dispatch). Same `View`, same data, no CSS re-read — + /// implementations should no-op while hidden (`offsetParent == null`). + /// Tier fallback: no-op — CSS reflows the box and the next dispatch + /// repaints the content. + pub async fn resize(&self) -> ApiResult { + if self.capabilities().resize { + self._resize().await + } else { + Ok(JsValue::UNDEFINED) + } + } + + /// Re-read the `--psp-*` CSS custom properties (sync). Dispatched ONLY + /// when the effective theme genuinely changed, state-keyed by + /// [`crate::renderer::Renderer::needs_restyle`] (the effective theme + /// vs. the one recorded at this plugin's last capture — first paint or + /// last restyle), from two sites: fused immediately BEFORE a + /// `draw`/`update` inside the same locked dispatch + /// (`Renderer::draw_view`, "restyle then draw" — one render pass in + /// the new theme), or as `Renderer::restyle_all`'s locked + /// restyle-then-`update` pair when no other dispatch is coming + /// (theme picker, `resetThemes`, default-theme discovery, restore + /// tails). Exception: the public `restyleElement()` API restyles + /// unconditionally — it is the "my external CSS changed" affordance, + /// outside what captured-theme state can know. (Plus one + /// `mount_plugin` restyle on first light-DOM mount.) Tier fallback: + /// no-op — the plugin re-reads CSS at its next render. + pub fn restyle(&self) { + if self.capabilities().restyle { + self._restyle() + } + } + + /// Blank the plugin (view deleted, table removed). Tier fallback: + /// no-op. + pub async fn clear(&self) -> ApiResult { + if self.capabilities().clear { + self._clear().await + } else { + Ok(JsValue::UNDEFINED) + } + } - if has_deselect { + /// Offscreen export render (`copy`/`export`) — returns an image + /// `Blob`; not a dispatch verb (does not touch the plugin's mounted + /// DOM state), but still serialized under the draw lock. Tier + /// fallback: errors — image export is unsupported without it. + pub async fn render( + &self, + view: perspective_js::View, + viewport: Option, + ) -> ApiResult { + if self.capabilities().render { + self._render(view, viewport).await + } else { + Err(ApiError::from("Plugin does not support image export")) + } + } + + /// Clear any visible selection state (highlighted rows, pinned + /// tooltips) WITHOUT emitting selection events. Invoked by the host + /// when an element-level global filter contributed by this panel's + /// selection is removed (`GlobalFilterBar` chip × / "Clear"), so the + /// selection visual can't outlive the filter it produced. + /// Implementations may redraw, so callers must hold the plugin's + /// per-`Renderer` draw lock (see "Call discipline"). Tier fallback: + /// no-op. + pub async fn deselect(&self) -> ApiResult<()> { + if self.capabilities().deselect { self._deselect().await } else { Ok(()) } } + + /// Staged presize: render at the TARGET element box `(width, height)` + /// offscreen — nothing on screen changes — resolving, once the frame + /// is staged, to a present closure that reveals it synchronously. + /// Callers run the closure in the same task as the layout commit the + /// presize anticipated (see `Renderer::presize_with_dimensions`), so + /// geometry and pixels land in one paint; the closure is lock-free (it + /// blits retained pixels, no plugin render entry). Only call when + /// [`Self::capabilities`] reports `presize` — plugins without it take + /// the held style-override path. Resolves `None` when the plugin + /// skipped (hidden), presenting nothing. + pub async fn presize(&self, width: f64, height: f64) -> ApiResult> { + let present = self._presize(width, height).await?; + Ok(present.dyn_into::().ok()) + } } diff --git a/rust/perspective-viewer/src/rust/js/regular_layout.rs b/rust/perspective-viewer/src/rust/js/regular_layout.rs index 823f58a70f..999f6bc350 100644 --- a/rust/perspective-viewer/src/rust/js/regular_layout.rs +++ b/rust/perspective-viewer/src/rust/js/regular_layout.rs @@ -153,6 +153,13 @@ impl RegularLayout { pub const TAG_NAME: &'static str = "regular-layout"; /// Emitted after the layout tree changes (`CustomEvent`). pub const UPDATE_EVENT: &'static str = "regular-layout-update"; + + /// Whether `name` is currently placed in this layout's tree (a non-null + /// [`Self::calculate_path`]). + pub fn contains_panel(&self, name: &str) -> bool { + let path = self.calculate_path(name); + !path.is_null() && !path.is_undefined() + } } /// The orientation of a [`SplitLayout`]. 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 2049d64e41..a22b6e6a84 100644 --- a/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs +++ b/rust/perspective-viewer/src/rust/queries/get_viewer_config.rs @@ -51,7 +51,10 @@ pub async fn get_viewer_config( None => presentation.get_selected_theme_name().await, }; let title = session.get_title(); - let table = session.get_table().map(|x| x.get_name().to_owned()); + let table = session + .get_table() + .map(|x| x.get_name().to_owned()) + .or_else(|| session.pending_table()); let columns_config = renderer.all_columns_configs(); Ok(ViewerConfig { settings, diff --git a/rust/perspective-viewer/src/rust/renderer.rs b/rust/perspective-viewer/src/rust/renderer.rs index 5aeebeab91..104040bba6 100644 --- a/rust/perspective-viewer/src/rust/renderer.rs +++ b/rust/perspective-viewer/src/rust/renderer.rs @@ -18,7 +18,9 @@ //! references throughout the application. mod activate; +mod dispatch; pub mod limits; +mod plugin_config; mod plugin_store; mod props; mod registry; @@ -26,15 +28,11 @@ mod render_timer; use std::cell::{Cell, RefCell}; use std::collections::HashMap; -use std::future::Future; use std::ops::Deref; use std::rc::Rc; -use futures::future::join_all; -use perspective_client::config::ViewConfig; -use perspective_client::{View, ViewWindow}; -use perspective_js::utils::{ApiFuture, ApiResult, JsValueSerdeExt, ResultTApiErrorExt}; -use serde_json::Value; +use perspective_client::ViewWindow; +use perspective_js::utils::{ApiFuture, ApiResult, JsValueSerdeExt}; use wasm_bindgen::prelude::*; use web_sys::*; use yew::html::ImplicitClone; @@ -42,32 +40,18 @@ use yew::prelude::*; use self::activate::*; pub use self::limits::RenderLimits; -use self::limits::*; +pub use self::plugin_config::{ColumnConfigMap, PluginScopedConfig}; use self::plugin_store::*; pub use self::props::RendererProps; pub use self::registry::*; use self::render_timer::*; use crate::config::*; use crate::js::plugin::*; -use crate::queries::resolve_abs_max; -use crate::session::{FreshView, Session}; use crate::utils::*; -/// A per-column config map. Each inner [`serde_json::Map`] is a flat collection -/// of plugin-defined JSON keys whose shape is dictated by the active plugin's -/// [`crate::config::ColumnConfigSchema`]. -pub type ColumnConfigMap = HashMap>; - -/// Per-plugin config bucket. Holds the per-column style map and the -/// plugin-level config map for one plugin. Buckets are keyed by plugin -/// name in [`RendererMutData::plugin_states`], so foreign keys from a -/// different plugin physically cannot appear in the active plugin's -/// bucket. -#[derive(Clone, Debug, Default)] -pub struct PluginScopedConfig { - pub columns: ColumnConfigMap, - pub plugin: serde_json::Map, -} +/// Minimum geometry delta (px) considered a real size/position change by the +/// presize paths. +pub(crate) const SUBPIXEL_EPSILON: f64 = 0.5; /// Everything a plugin may read back during its render, all belonging to /// one snapshot (invariant I5). Built once per bound `View` by the render @@ -184,6 +168,16 @@ pub struct RendererData { /// applied ONLY inside locked plugin dispatches /// ([`Renderer::stamp_active`]), for the same one-paint-commit reason. is_solo_panel: Cell, + + /// A `table_updated` redraw was DROPPED because this panel's plugin was + /// hidden (an unslotted tab-stack panel — see + /// `tasks::create_panel::wire_panel_render_sub`), so the plugin's + /// retained frame no longer reflects the bound `View`'s data. The + /// activation nudge consumes it to dispatch a full `plugin.update` + /// instead of the usual chrome-only `resize` repaint. Cleared at every + /// `draw_view` dispatch — set BEFORE the plugin's data fetch, so an + /// update landing mid-draw re-marks it and can never be lost. + data_stale: Cell, } /// Mutable state @@ -256,6 +250,7 @@ impl Renderer { active_context: Default::default(), is_active_panel: Cell::new(false), is_solo_panel: Cell::new(true), + data_stale: Cell::new(false), })) } @@ -281,6 +276,19 @@ impl Renderer { 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. + 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) { @@ -398,487 +406,10 @@ impl Renderer { self.metadata().can_render_column_styles } - /// Name of the currently-active plugin (used as the key into - /// `plugin_states`). Returns `None` when no plugin has been - /// activated yet. - fn active_plugin_name(&self) -> Option { - Some(self.borrow().metadata.name.clone()).filter(|n| !n.is_empty()) - } - // ─── Per-column config (active plugin's bucket) ─────────────────── - /// Snapshot of the active plugin's per-column config map. - pub fn all_columns_configs(&self) -> ColumnConfigMap { - self.active_plugin_name() - .and_then(|n| { - self.borrow() - .plugin_states - .get(&n) - .map(|b| b.columns.clone()) - }) - .unwrap_or_default() - } - - /// Restore-prep snapshot: like [`Self::all_columns_configs`], but - /// for each column also materializes any `ControlSpec::Number` - /// fields the schema declares with `include: true` that aren't - /// already in the bucket entry. The materialized value is the - /// schema's `default`, which the schema computes from cached - /// column stats (via [`Self::query_column_config_schema`]). - /// - /// The bucket itself stays minimal (user edits + `include: true` - /// values the user *explicitly set*); this helper produces the - /// fully-realized payload the plugin's `restore` is expected to - /// receive. Every restore-prep site should call this rather than - /// `all_columns_configs` directly — otherwise widgets that gate - /// `include` fields off other fields (e.g. Datagrid's - /// `fg_gradient` revealed when `number_fg_mode = "bar"`) will - /// reach the plugin without their default value populated. - /// - /// `async` because per-column stats (e.g. `abs_max` for Datagrid's - /// `fg_gradient`) may need to be fetched before the schema's - /// `default` is meaningful. Pass 1 sync-scans the schema for any - /// column whose `include: true` Number key is missing from its - /// entry AND has no cached stats — `view_config_changed` clears the - /// stats cache, so "missing in cache" subsumes "stale". Pass 2 - /// blocks on a parallel `resolve_abs_max` for that set, then runs - /// the materialize loop with the now-warm cache. Columns never - /// touched in a stats-dependent mode never trigger a fetch. - pub async fn all_columns_configs_materialized( - &self, - view_config: &ViewConfig, - session: &Session, - ) -> ColumnConfigMap { - let mut configs = self.all_columns_configs(); - let mut to_warm: Vec = vec![]; - for (col, entry) in &configs { - if session - .get_column_stats(col) - .and_then(|s| s.abs_max) - .is_some() - { - continue; - } - let Ok(schema) = - self.query_column_config_schema(view_config, session, col, Some(entry)) - else { - continue; - }; - let needs_warm = schema.fields.iter().any(|f| { - matches!( - f, - ControlSpec::Number { - key, - include: Some(true), - .. - } if !entry.contains_key(key) - ) - }); - if needs_warm { - to_warm.push(col.clone()); - } - } - - if !to_warm.is_empty() { - let metadata = session.metadata().clone(); - let view = session.get_view(); - let futs = to_warm - .iter() - .map(|c| resolve_abs_max(session, &metadata, view.as_ref(), c.as_str())); - join_all(futs).await; - } - - for (col, entry) in &mut configs { - let Ok(schema) = - self.query_column_config_schema(view_config, session, col, Some(entry)) - else { - continue; - }; - - for field in &schema.fields { - let ControlSpec::Number { - key, - default, - include: Some(true), - .. - } = field - else { - continue; - }; - - if entry.contains_key(key) { - continue; - } - - let Some(num) = serde_json::Number::from_f64(*default) else { - continue; - }; - - entry.insert(key.clone(), serde_json::Value::Number(num)); - } - } - - configs - } - - /// Clear the active plugin's per-column config map. - pub fn reset_columns_configs(&self) { - if let Some(n) = self.active_plugin_name() { - self.borrow_mut() - .plugin_states - .entry(n) - .or_default() - .columns - .clear(); - } - } - - /// Clone of the active plugin's per-column entry for `column_name`, - /// or `None` if no value is stored. - pub fn get_columns_config( - &self, - column_name: &str, - ) -> Option> { - let n = self.active_plugin_name()?; - self.borrow() - .plugin_states - .get(&n)? - .columns - .get(column_name) - .cloned() - } - - /// Wholesale update the active plugin's per-column config map - /// (e.g. from a `restore()` call). Each incoming column entry is - /// schema-stripped before insertion — values matching the - /// schema-declared default are dropped so the bucket converges to - /// the "empty ⇒ reads-default" invariant, mirroring - /// [`Self::update_plugin_config`]. `ControlSpec::Number` fields - /// declared with `include: true` survive the strip (their default - /// is data-dependent, so a literal default value is preserved as - /// the user's explicit choice). Column entries that become empty - /// after stripping are removed from the bucket entirely. - pub fn update_columns_configs( - &self, - view_config: &ViewConfig, - session: &Session, - update: ColumnConfigUpdate, - ) -> bool { - let Some(n) = self.active_plugin_name() else { - return false; - }; - - match update { - OptionalUpdate::SetDefault => { - let mut st = self.borrow_mut(); - let bucket = st.plugin_states.entry(n).or_default(); - let was_nonempty = !bucket.columns.is_empty(); - bucket.columns.clear(); - was_nonempty - }, - OptionalUpdate::Missing => false, - OptionalUpdate::Update(map) => { - // Strip per-column before borrowing the bucket mutably: - // the schema query takes an immutable borrow via - // `self.metadata()`, which would alias-conflict with - // `borrow_mut` below. - let stripped: Vec<(String, serde_json::Map)> = map - .into_iter() - .map(|(col, mut cfg)| { - if let Ok(schema) = - self.query_column_config_schema(view_config, session, &col, Some(&cfg)) - { - let active = schema.active_keys(); - cfg.retain(|k, _| active.contains(k)); - strip_default_values(&schema, &mut cfg); - } - - (col, cfg) - }) - .collect(); - - let mut st = self.borrow_mut(); - let bucket = st.plugin_states.entry(n).or_default(); - let mut changed = false; - for (col, cfg) in stripped { - if cfg.is_empty() { - if bucket.columns.remove(&col).is_some() { - changed = true; - } - } else { - match bucket.columns.insert(col, cfg.clone()) { - None => changed = true, - Some(old) if old != cfg => changed = true, - _ => {}, - } - } - } - - changed - }, - } - } - - /// Apply a single schema-field update from the column-style UI to - /// the active plugin's bucket. Clears the keys the field owns, - /// then splices in the partial new sub-state. Drops empty - /// entries. - /// - /// The schema-strip is defense-in-depth: widget callbacks (e.g. - /// `NumberFieldPrimitive`) already pre-strip default values, so - /// for live edits this strip pass is a no-op. It closes the hole - /// for programmatic callers that construct a - /// `ColumnConfigFieldUpdate` directly without going through the - /// widget (where `include: true` would otherwise be ignored). - pub fn update_columns_config_field( - &self, - view_config: &ViewConfig, - session: &Session, - column_name: String, - mut update: ColumnConfigFieldUpdate, - ) { - let Some(n) = self.active_plugin_name() else { - return; - }; - - // Take the schema query before the mutable borrow — same - // RefCell aliasing reason as in `update_columns_configs`. - let current_value = self.get_columns_config(&column_name); - if let Ok(schema) = self.query_column_config_schema( - view_config, - session, - &column_name, - current_value.as_ref(), - ) { - strip_default_values(&schema, &mut update.value); - } - - let mut st = self.borrow_mut(); - let bucket = st.plugin_states.entry(n).or_default(); - let entry = bucket.columns.entry(column_name.clone()).or_default(); - for k in &update.keys { - entry.remove(k); - } - for (k, v) in update.value { - if update.keys.contains(&k) { - entry.insert(k, v); - } - } - if entry.is_empty() { - bucket.columns.remove(&column_name); - } - } - // ─── Plugin-level config (active plugin's bucket) ───────────────── - /// Snapshot of the active plugin's plugin-level config map. - pub fn get_plugin_config(&self) -> serde_json::Map { - self.active_plugin_name() - .and_then(|n| { - self.borrow() - .plugin_states - .get(&n) - .map(|b| b.plugin.clone()) - }) - .unwrap_or_default() - } - - /// Clear the active plugin's plugin-level config map. - pub fn reset_plugin_config(&self) { - if let Some(n) = self.active_plugin_name() { - self.borrow_mut() - .plugin_states - .entry(n) - .or_default() - .plugin - .clear(); - } - } - - /// Synchronously query the active plugin's - /// [`ColumnConfigSchema`] used to gate plugin-config strip logic. - /// Inlined here (rather than calling `queries::get_plugin_config_schema`) - /// to keep `renderer` from back-importing the `queries` module. - fn query_plugin_config_schema( - &self, - view_config: &ViewConfig, - ) -> ApiResult { - let plugin = self.ensure_plugin_selected()?; - let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL); - let raw = plugin._plugin_config_schema(&view_config_js)?; - serde_wasm_bindgen::from_value(raw).map_err(|e| e.into()) - } - - /// Per-column counterpart of [`query_plugin_config_schema`]. Used by - /// the columns-config write paths (strip-on-write) and the - /// restore-prep snapshot (materialize-on-read). - /// - /// Reads the cached `ColumnStats` (cleared on `view_config_changed`) - /// so plugins emit gradient defaults against the column's current - /// `abs_max` instead of falling back to `0`. - /// [`Self::all_columns_configs_materialized`] warms the cache on - /// demand before materializing `include: true` Number fields, so - /// the restore path always observes a real default; sync callers - /// (column-config strip-on-write) may still see a missing stats - /// pass-through and the plugin's `?? 0` fallback, but those writes - /// re-strip on the next render cycle. - fn query_column_config_schema( - &self, - view_config: &ViewConfig, - session: &Session, - column_name: &str, - current_value: Option<&serde_json::Map>, - ) -> ApiResult { - let plugin = self.ensure_plugin_selected()?; - let plugin_config = self.metadata(); - let names = &plugin_config.config_column_names; - let group = view_config - .columns - .iter() - .position(|maybe_s| maybe_s.as_deref() == Some(column_name)) - .and_then(|idx| names.get(idx)) - .map(|s| s.as_str()); - - // If the view schema itself hasn't been built yet (e.g. restore - // landed before `create_view` populated `view_schema`), refuse - // to answer rather than returning an empty schema. The strip - // pass in `update_columns_configs` treats `Err` as "leave the - // incoming cfg untouched"; an empty schema, by contrast, would - // cause `cfg.retain(|k, _| active.contains(k))` to drop every - // key and zero out user-supplied column config. - if !session.metadata().has_view_schema() { - return Err(JsValue::from("view_schema not initialized").into()); - } - let Some(view_type) = session.metadata().get_column_view_type(column_name) else { - return Ok(ColumnConfigSchema { fields: vec![] }); - }; - - let current_js = JsValue::from_serde_ext(¤t_value).unwrap_or(JsValue::NULL); - let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL); - - // Pull the column's cached stats from the session. The StyleTab - // pre-warms this via `fetch_column_abs_max` whenever the user - // opens column settings; the cache is invalidated on every - // `view_config_changed`, so freshness is bounded. - let stats = session.get_column_stats(column_name).unwrap_or_default(); - let stats_json = serde_json::json!({ - "abs_max": stats.abs_max, - }); - let stats_js = JsValue::from_serde_ext(&stats_json).unwrap_or(JsValue::NULL); - - let raw = plugin._column_config_schema( - &view_type.to_string(), - group, - column_name, - ¤t_js, - &view_config_js, - &stats_js, - )?; - - serde_wasm_bindgen::from_value(raw).map_err(|e| e.into()) - } - - /// Wholesale update the active plugin's plugin-level config map. - /// Entries whose value equals the schema-declared default are - /// treated as "reset this key" — the corresponding bucket entry - /// is cleared rather than the default being stored literally. - /// Keys absent from the incoming map are left alone (merge - /// semantics for the non-default subset). - pub fn update_plugin_config( - &self, - view_config: &ViewConfig, - update: PluginConfigUpdate, - ) -> bool { - let Some(n) = self.active_plugin_name() else { - return false; - }; - - let schema = self.query_plugin_config_schema(view_config).ok(); - let mut st = self.borrow_mut(); - let bucket = st.plugin_states.entry(n).or_default(); - match update { - OptionalUpdate::SetDefault => { - let changed = !bucket.plugin.is_empty(); - bucket.plugin.clear(); - changed - }, - OptionalUpdate::Missing => false, - OptionalUpdate::Update(mut map) => { - let mut changed = false; - if let Some(s) = &schema { - let active = s.active_keys(); - map.retain(|k, _| active.contains(k)); - // Default-valued entries in a restore payload - // semantically reset the key — strip from the - // map AND clear any existing override in the - // bucket so the wholesale-restore path matches - // the live-edit path (where the widget emits an - // empty value to clear). - map.retain(|key, value| { - let is_default = s - .fields - .iter() - .any(|spec| matches_declared_default(spec, key, value)); - if is_default { - if bucket.plugin.remove(key).is_some() { - changed = true; - } - false - } else { - true - } - }); - } - - for (k, v) in map { - let prev = bucket.plugin.insert(k, v.clone()); - if prev.as_ref() != Some(&v) { - changed = true; - } - } - - changed - }, - } - } - - /// Apply a single schema-field update from the plugin-settings UI - /// to the active plugin's bucket. Clear-then-insert semantics - /// mirror [`Self::update_columns_config_field`]. Entries in - /// `update.value` whose value equals the schema default are - /// stripped before applying so default picks reset the key - /// rather than store the default literally. - pub fn update_plugin_config_field( - &self, - view_config: &ViewConfig, - mut update: ColumnConfigFieldUpdate, - ) -> bool { - let Some(n) = self.active_plugin_name() else { - return false; - }; - - if let Ok(schema) = self.query_plugin_config_schema(view_config) { - strip_default_values(&schema, &mut update.value); - } - - let mut st = self.borrow_mut(); - let bucket = st.plugin_states.entry(n).or_default(); - let mut changed = false; - - for k in &update.keys { - if let Some(v) = update.value.get(k) { - let prev = bucket.plugin.insert(k.to_string(), v.clone()); - if prev.as_ref() != Some(v) { - changed = true; - } - } else if bucket.plugin.remove(k).is_some() { - changed = true; - } - } - - changed - } - /// Whether the active plugin's render warning is currently armed /// (i.e. an oversized view will be capped). Becomes `false` once /// the user clicks "Render all points"; resets to `true` on the @@ -981,115 +512,6 @@ impl Renderer { self.0.is_solo_panel.set(is_solo); } - /// Toggle the `active` and panel-count (`single`/`multi`) classes on - /// `plugin` from the recorded flags — called ONLY from locked - /// plugin-dispatch sites, immediately before the dispatch. The classes - /// are pure CSS hooks (`:host(.active)` chrome, e.g. the datagrid's edit - /// column-header labels); plugins read activation state through - /// `getActivePanel()`, never these classes. Stamping at - /// dispatch bounds any class/DOM disagreement to the one locked draw - /// that reconciles them — never applied from an async render pass - /// (that left the split unbounded — the activation "wrong-row EDIT" - /// artifact). - fn stamp_active(&self, plugin: &JsPerspectiveViewerPlugin) { - let el = plugin.unchecked_ref::(); - let _ = el - .class_list() - .toggle_with_force("active", self.0.is_active_panel.get()); - - // `single`/`multi` reflect whether the host viewer holds one plugin - // child or more than one — the same pure-CSS-hook contract as - // `active`. - let is_solo = self.0.is_solo_panel.get(); - let _ = el.class_list().toggle_with_force("single", is_solo); - let _ = el.class_list().toggle_with_force("multi", !is_solo); - } - - /// Stamp this panel's effective `theme` attribute — its own - /// ([`Self::theme`]), else the cached registry default - /// ([`Self::default_theme`]) — onto the active plugin element. The - /// plugin reads its `--psp-*` CSS at - /// `restyle()`/first-`draw()` time, driven by this attribute (the - /// `perspective-viewer [theme="X"]` document rules), so it must be - /// stamped BEFORE any plugin style read — the "stamp before restyle" - /// invariant. A pure query — no-op when no plugin has been selected yet. - pub fn stamp_theme(&self, plugin: Option<&JsPerspectiveViewerPlugin>) { - 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() { - // Same-value writes are skipped: this now runs on EVERY - // locked dispatch (including streaming `update`s), and an - // unconditional `setAttribute` would churn attribute-selector - // invalidation per frame. - Some(theme) - if theme_elem.get_attribute("theme").as_deref() != Some(theme.as_str()) => - { - let _ = theme_elem.set_attribute("theme", &theme); - }, - Some(_) => {}, - None => { - let _ = theme_elem.remove_attribute("theme"); - }, - } - } - } - - /// Restyle the active plugin and re-draw it from the currently-bound - /// `View`, resolved INSIDE the locked section from the cached - /// [`RenderContext`] — NEVER from a handle captured at call time, which - /// would race a concurrent rebuild (e.g. a plugin switch's column-default - /// commit) and restyle a stale, already-deleted `View` (the same rule as - /// [`Self::update_lazy`]; the cache is only ever replaced under this same - /// lock). A no-op when nothing is bound (never drawn, or disposed). - /// - /// The whole sequence (theme stamp, `restyle()`, `draw()`) runs under - /// this renderer's draw lock so it can never interleave with an in-flight - /// `draw`/`update`/`render` on the same plugin element (see - /// [`crate::js::plugin`] for the serialized-call contract). Full `lock` - /// (not `debounce`) semantics on purpose: a superseding data draw does - /// not re-read CSS, so a skipped restyle would strand stale style state. - /// No caller may already hold the lock (today: `restyleElement`, - /// `resetThemes`, the theme-picker task, `restorePanel`'s own-theme - /// tail, and the root's default-theme fan-out). - pub async fn restyle_all(&self) -> ApiResult { - self.render_task(|guard| async move { - let Some(ctx) = self.cached_context() else { - return Ok(JsValue::UNDEFINED); - }; - - // Pin (I5): plugin read-backs during this restyle's draw answer - // from the bound view's own state bundle. - let _pin = self.pin_context(&guard, ctx.clone()); - let plugin = self.ensure_plugin_selected()?; - let meta = self.metadata(); - let stamped_theme = self.effective_theme(); - self.stamp_theme(Some(&plugin)); - self.stamp_active(&plugin); - plugin.restyle(); - // The CSS re-read just happened (sync) — record the capture - // even if the repaint below fails. - *self.0.captured_theme.borrow_mut() = Some(stamped_theme); - let mut limits = - get_row_and_col_limits(&ctx.view, &meta, self.is_render_warning_enabled()).await?; - limits.is_update = true; - // `update`, NOT `draw`: the `View` is unchanged — only the CSS - // its render reads did (`plugin.draw` ⇔ new `View`, see - // `PLUGIN_DRAW_INVARIANT_PLAN.md`). - plugin - .update( - ctx.view.clone().into(), - limits.max_cols, - limits.max_rows, - false, - ) - .await?; - - Ok(JsValue::UNDEFINED) - }) - .await - } - pub fn set_throttle(&self, val: Option) { self.0.borrow_mut().timer.set_throttle(val); } @@ -1236,426 +658,6 @@ impl Renderer { Ok(()) } - pub async fn with_lock(self, task: impl Future>) -> ApiResult { - let draw_mutex = self.draw_lock(); - draw_mutex.lock(task).await - } - - /// The [`RenderContext`] pinned by the run currently holding this - /// renderer's draw lock, if any. The per-panel element getters answer - /// from this when present (invariant I5). - pub fn render_context(&self) -> Option> { - self.0.active_context.borrow().clone() - } - - /// The cached [`RenderContext`] of the currently-bound `View` (set by - /// the pipeline at bind time). - pub fn cached_context(&self) -> Option> { - self.0.cached_context.borrow().clone() - } - - /// Cache `ctx` as the bound view's context (pipeline, at bind time). - pub fn set_cached_context(&self, ctx: Rc) { - *self.0.cached_context.borrow_mut() = Some(ctx); - } - - /// Pin `ctx` as the active [`RenderContext`] for the duration of the - /// returned RAII guard. Requires the lock witness — a context can only - /// be pinned by a locked run. - pub fn pin_context(&self, _guard: &RenderGuard, ctx: Rc) -> ContextPin { - *self.0.active_context.borrow_mut() = Some(ctx); - ContextPin(self.clone()) - } - - /// `true` while a run is executing on this renderer's draw lock. Used - /// by lock-acquiring public API methods for a debug-build warning (a - /// plugin calling one from its render deadlocks — see the - /// render-callable contract on [`crate::js::plugin`]). - pub fn is_locked(&self) -> bool { - self.draw_lock.is_held() - } - - pub async fn resize(&self) -> ApiResult<()> { - let draw_mutex = self.draw_lock(); - let timer = self.render_timer(); - draw_mutex - .debounce_with(|_guard| async move { - set_timeout(timer.get_throttle()).await?; - // Pure query: nothing drawn yet ⇒ nothing to resize (don't force - // selection just to service a resize). - let Some(jsplugin) = self.active_plugin() else { - return Ok(()); - }; - self.stamp_active(&jsplugin); - jsplugin.resize().await?; - Ok(()) - }) - .await - } - - pub async fn resize_with_dimensions(&self, width: f64, height: f64) -> ApiResult<()> { - // Signal in-flight presize for the whole call (including time spent - // waiting on the draw lock) so `table_updated` update-redraws yield to - // it (see `presize_pending`). Callers must not drop this future - // mid-await (all await it to completion) or the counter would stick. - self.0.presize_pending.set(self.0.presize_pending.get() + 1); - let result = self.resize_with_dimensions_inner(width, height).await; - self.0.presize_pending.set(self.0.presize_pending.get() - 1); - result - } - - async fn resize_with_dimensions_inner(&self, width: f64, height: f64) -> ApiResult<()> { - let draw_mutex = self.draw_lock(); - draw_mutex - .debounce_with(|_guard| async move { - let Some(plugin) = self.active_plugin() else { - return Ok(()); - }; - - self.stamp_active(&plugin); - let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref(); - let rect = main_panel.get_bounding_client_rect(); - let changed = - (height - rect.height()).abs() > 0.5 || (width - rect.width()).abs() > 0.5; - if changed { - let new_width = format!("{}px", width); - let new_height = format!("{}px", height); - main_panel.style().set_property("width", &new_width)?; - main_panel.style().set_property("height", &new_height)?; - let result = plugin.resize().await; - main_panel.style().set_property("width", "")?; - main_panel.style().set_property("height", "")?; - result?; - } - - Ok(()) - }) - .await - } - - /// Pre-size AND pre-position the plugin to its target layout box, so it - /// paints at the exact screen rect it will occupy after the pending layout - /// commit. `(dx, dy)` is the target grid-track origin minus the current - /// one, applied as a `transform: translate` (the plugin's offset within - /// its track is constant across a layout transition, so the track delta IS - /// the plugin delta). - pub async fn presize_with_box( - &self, - dx: f64, - dy: f64, - width: f64, - height: f64, - ) -> ApiResult<()> { - self.0.presize_pending.set(self.0.presize_pending.get() + 1); - let result = self.presize_with_box_inner(dx, dy, width, height).await; - self.0.presize_pending.set(self.0.presize_pending.get() - 1); - result - } - - async fn presize_with_box_inner( - &self, - dx: f64, - dy: f64, - width: f64, - height: f64, - ) -> ApiResult<()> { - let draw_mutex = self.draw_lock(); - draw_mutex - .debounce_with(|_guard| async move { - let Some(plugin) = self.active_plugin() else { - return Ok(()); - }; - - self.stamp_active(&plugin); - self.clear_presize()?; - let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref(); - let rect = main_panel.get_bounding_client_rect(); - let size_changed = - (height - rect.height()).abs() > 0.5 || (width - rect.width()).abs() > 0.5; - - let moved = dx.abs() > 0.5 || dy.abs() > 0.5; - if size_changed { - main_panel - .style() - .set_property("width", &format!("{width}px"))?; - main_panel - .style() - .set_property("height", &format!("{height}px"))?; - } - - if moved { - main_panel - .style() - .set_property("transform", &format!("translate({dx}px, {dy}px)"))?; - } - - if size_changed { - plugin.resize().await?; - } - - Ok(()) - }) - .await - } - - /// Remove the inline presize styles applied by [`Self::presize_with_box`]. - /// Callers run this synchronously in the same task as the layout release - /// (`resumeResize` — whose held commit callback runs as a microtask of the - /// same task), so no frame can paint between the clear and the new grid - /// landing. - pub fn clear_presize(&self) -> ApiResult<()> { - if let Some(plugin) = self.active_plugin() { - let el = plugin.unchecked_ref::(); - el.style().set_property("width", "")?; - el.style().set_property("height", "")?; - el.style().set_property("transform", "")?; - } - - Ok(()) - } - - /// The SINGLE pipeline render entry (invariants I2/I3): run `f` under - /// this renderer's draw lock with the [`RenderGuard`] witness. `f` - /// composes the run — snapshot, validate, bind, pin context, dispatch - /// via [`Self::draw_fresh`]/[`Self::update_bound`] — and everything it - /// touches is witnessed. - pub async fn render_task(&self, f: F) -> ApiResult - where - F: FnOnce(RenderGuard) -> Fut, - Fut: Future>, - { - self.draw_lock().lock_with(f).await - } - - /// FULL-draw a NEW `View` on the active plugin (`plugin.draw`) — the - /// ONLY `plugin.draw` dispatch in the crate, witnessed by both the - /// caller's held lock AND the [`FreshView`] token, which only - /// `bind_view`'s REBUILD arm and [`Self::promote_first_paint`] mint. - /// "Full draw without a new `View`" therefore does not compile (see - /// `PLUGIN_DRAW_INVARIANT_PLAN.md`); every other repaint is - /// [`Self::update_bound`] (same `View`, re-render) or `resize` - /// (geometry/chrome). - pub async fn draw_fresh(&self, guard: &RenderGuard, view: FreshView) -> ApiResult<()> { - let timer = self.render_timer(); - timer - .capture_time(self.draw_view(guard, view.view(), false)) - .await - } - - /// Repaint the ALREADY-BOUND `view` on the active plugin - /// (`plugin.update`): the dispatch for runs that reconciled without - /// constructing a `View` (`BindDisposition::Adopted`/`Unchanged`) — - /// adopted placeholder configs, no-op-commit repaint idioms (status - /// indicator click, toggle-debug, render-warning dismiss). A no-op when - /// no plugin has been selected yet (nothing has painted, so nothing - /// needs repainting — first paints go through the - /// [`Self::promote_first_paint`] → [`Self::draw_fresh`] path). - pub async fn update_bound(&self, guard: &RenderGuard, view: &View) -> ApiResult<()> { - let timer = self.render_timer(); - timer.capture_time(self.draw_view(guard, view, true)).await - } - - /// Mint the [`FreshView`] full-draw witness for a plugin that has never - /// painted the bound `View` — a freshly-selected/swapped plugin element - /// (`commit_plugin_idx` clears `has_drawn`) or a first paint deferred by - /// visibility gating. `plugin.draw`'s "new `View`" contract is from THIS - /// plugin's perspective, so its first paint qualifies even when - /// `bind_view` reconciled without a rebuild. `None` when the plugin has - /// already drawn it — callers fall back to [`Self::update_bound`]. - pub fn promote_first_paint(&self, view: &View) -> Option { - if !self.0.has_drawn.get() { - Some(FreshView::assert_fresh(view.clone())) - } else { - None - } - } - - /// The activation nudge's repaint: stamp the activation class + theme - /// and `plugin.resize()` — same `View`, same data, CHROME only (e.g. - /// the datagrid's edit column-header row) — in ONE locked dispatch, so - /// the class and the DOM it styles land in a single paint commit (the - /// two-frame-artifact fix's atomicity requirement). Deliberately NOT - /// `plugin.draw`/`update`: activation creates no new `View` and changes - /// no data, and for charts a full dispatch is a fetch + multi-blit - /// repaint (the stacked-tab regression). Each plugin's `resize()` is - /// its cheap repaint-from-retained-state path, and both built-ins skip - /// it while hidden — so the OUTGOING (unslotted) panel's nudge is free. - pub async fn activation_repaint(&self, _guard: &RenderGuard) -> ApiResult<()> { - if let Some(plugin) = self.active_plugin() { - self.stamp_active(&plugin); - self.stamp_theme(Some(&plugin)); - let _ = plugin.resize().await?; - } - - Ok(()) - } - - /// Redraw an already-bound view, debounced. The `View` future is - /// resolved *lazily*, inside the debounce/draw lock at actual draw time - /// rather than captured at call time. Used by the per-panel data-refresh - /// subscription so a redraw always renders the `View` currently bound on - /// the `Session`, even if a concurrent config rebuild replaced (and - /// deleted) the previous `View` after this redraw was scheduled. - /// Capturing the `View` eagerly at schedule time instead races that - /// rebuild and draws a stale/deleted `View`. - pub async fn update_lazy( - &self, - view: impl Future>>, - ) -> ApiResult<()> { - let timer = self.render_timer(); - self.draw_lock() - .debounce_with(|guard| async move { - set_timeout(timer.get_throttle()).await?; - if self.0.presize_pending.get() > 0 { - tracing::debug!("Update skipped, presize pending"); - return Ok(()); - } - - // Mount the plugin element eagerly — BEFORE awaiting the - // (possibly slow) view query — so the panel's frame is never - // empty while the query runs. Pure query + idempotent. - self.mount_active_plugin()?; - if let Some(view) = view.await? { - // Update runs pin the bound view's cached context so - // plugin read-backs stay snapshot-consistent (I5). - let _pin = self - .cached_context() - .map(|ctx| self.pin_context(&guard, ctx)); - - let timer = self.render_timer(); - timer - .capture_time(self.draw_view(&guard, &view, true)) - .await - } else { - tracing::debug!("Render skipped, no `View` attached"); - Ok(()) - } - }) - .await - } - - /// This will update an already existing view. - pub async fn update(&self, session: Option) -> ApiResult<()> { - self.update_lazy(async { Ok(session) }).await - } - - async fn draw_view( - &self, - _guard: &RenderGuard, - view: &perspective_client::View, - is_update: bool, - ) -> ApiResult<()> { - debug_assert!( - !is_update || self.cached_context().is_none() || self.render_context().is_some(), - "I5: RenderContext not pinned at plugin dispatch" - ); - - let plugin = if is_update { - match self.active_plugin() { - Some(plugin) => plugin, - None => return Ok(()), - } - } else { - self.ensure_plugin_selected()? - }; - - let meta = self.metadata(); - let mut limits = - get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?; - - limits.is_update = is_update; - if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() { - cb.emit(limits); - } - - let viewer_elem = self.0.borrow().viewer_elem.clone(); - let slot = self.slot_name(); - // "Stamp before draw": activation class + effective theme attribute - // atomic with this dispatch, so a plugin style read (its first - // `draw()` captures the `--psp-*` vars) can never precede them — - // including a plugin switch's first draw, whose freshly-created - // element no async pass has seen yet. - let first_paint = !self.0.has_drawn.get(); - let stamped_theme = self.effective_theme(); - self.stamp_active(&plugin); - self.stamp_theme(Some(&plugin)); - - // Fused stale-CSS restyle ("restyle then draw", captured-theme - // revision): when the plugin's captured `--psp-*` vars predate the - // theme just stamped, re-read them NOW — sync, inside this locked - // dispatch, between the stamp and the render — so the dispatch - // below paints new data in the NEW theme in one pass. Without - // this, a rebuild bundled with a theme change drew in the OLD - // colors (plugins re-read CSS only at `restyle()`/first paint) and - // the mutation-site restyle tail then re-rendered everything. - // State-keyed, so streaming updates with a fresh capture pay - // nothing; recorded immediately (the re-read has happened even if - // the render below fails), which also no-ops the tail - // (`needs_restyle` → false). A first paint never enters (no - // capture exists) — it captures fresh by construction. - if self.needs_restyle() { - plugin.restyle(); - *self.0.captured_theme.borrow_mut() = Some(stamped_theme.clone()); - } - - let result = if is_update { - let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false); - activate_plugin(_guard, &viewer_elem, &plugin, slot.as_deref(), task).await - } else { - let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false); - activate_plugin(_guard, &viewer_elem, &plugin, slot.as_deref(), task).await - }; - - // Record a genuinely-completed draw (a view-delete cancellation does - // NOT count — the plugin may hold no content, and the deferred-render - // resume paths key off this flag to know a redraw is still owed). - if result.is_ok() { - self.0.has_drawn.set(true); - // A FIRST paint is a CSS capture — record the theme it was - // stamped with (`needs_restyle`'s baseline). The value stamped, - // not `effective_theme()` at completion: a theme committed - // mid-draw must read as STALE against this capture. - // Subsequent draws are NOT captures (charts cache theme vars - // until `restyle()`), so only the restyle path may overwrite - // this record afterwards. - if first_paint { - *self.0.captured_theme.borrow_mut() = Some(stamped_theme); - } - } - - let cleanup = remove_inactive_plugin( - &viewer_elem, - &plugin, - slot.as_deref(), - self.plugin_data.borrow_mut().plugin_store.plugins(), - ); - - match result.ignore_view_delete() { - // A FIRST draw failed — this plugin has never successfully - // painted, so the warn-and-continue below would leave the panel - // permanently blank with a console warning as its only signal. - // Propagate instead: the enclosing restore task surfaces it as a - // panel error (`session.set_error` → error overlay), presenting - // like a table error. Subsequent-draw failures keep - // warn-and-continue — the panel still shows its last good - // content, and failing the whole restore transaction for a - // repaint hiccup would be worse than the stale frame. - Err(error) if !self.0.has_drawn.get() => Err(error), - Err(error) => { - tracing::warn!("{}", error); - cleanup - }, - // `Ok(None)` is a view-delete cancellation — not a failure - // (`has_drawn` stays false; the deferred-resume paths owe a - // redraw). - Ok(_) => cleanup, - } - } - - fn draw_lock(&self) -> DebounceMutex { - self.draw_lock.clone() - } - pub fn render_timer(&self) -> MovingWindowRenderTimer { self.0.borrow().timer.clone() } @@ -1720,65 +722,3 @@ impl Renderer { } } } - -/// Drop entries from `map` whose value matches the schema-declared -/// default for that key. Used by both the plugin-config and -/// columns-config write paths to converge buckets to the -/// "empty ⇒ reads-default" invariant. For `ControlSpec::Number` -/// entries marked `include: Some(true)`, -/// [`matches_declared_default`] short-circuits so the value survives -/// (used when the declared default is data-dependent and unreliable — -/// e.g. Datagrid's `fg_gradient`, whose default is the column's -/// `abs_max`). -fn strip_default_values( - schema: &ColumnConfigSchema, - map: &mut serde_json::Map, -) { - map.retain(|key, value| { - !schema - .fields - .iter() - .any(|spec| matches_declared_default(spec, key, value)) - }); -} - -/// Does `value` for `key` match the `default` declared by `spec`? -/// Composite variants (`NumberSeriesStyle`, `DatetimeFormat`, etc.) own -/// nested defaults that don't have a single comparable scalar — the -/// widget is responsible for emitting empty values when the user -/// resets composite controls, so this helper returns `false` for them. -fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> bool { - match spec { - ControlSpec::Enum { - key: k, default, .. - } if k == key => value.as_str() == Some(default.as_str()), - ControlSpec::Bool { - key: k, default, .. - } if k == key => value.as_bool() == Some(*default), - ControlSpec::Number { - key: k, - include: Some(true), - .. - } if k == key => false, - ControlSpec::Number { - key: k, default, .. - } if k == key => value.as_f64() == Some(*default), - ControlSpec::String { - key: k, default, .. - } if k == key => value.as_str() == Some(default.as_str()), - ControlSpec::Color { - key: k, default, .. - } if k == key => value.as_str() == Some(default.as_str()), - ControlSpec::ColorRange { - key_pos, - default_pos, - .. - } if key_pos == key => value.as_str() == Some(default_pos.as_str()), - ControlSpec::ColorRange { - key_neg, - default_neg, - .. - } if key_neg == key => value.as_str() == Some(default_neg.as_str()), - _ => false, - } -} diff --git a/rust/perspective-viewer/src/rust/renderer/activate.rs b/rust/perspective-viewer/src/rust/renderer/activate.rs index 29f19fabea..020c922394 100644 --- a/rust/perspective-viewer/src/rust/renderer/activate.rs +++ b/rust/perspective-viewer/src/rust/renderer/activate.rs @@ -19,9 +19,11 @@ use web_sys::*; use crate::js::plugin::JsPerspectiveViewerPlugin; use crate::utils::RenderGuard; -/// Mount `plugin` (once) under its layout slot in the viewer's light DOM, -/// restyling it on first mount. Idempotent — a plugin that already has a -/// parent is left untouched (only its `slot` attribute is refreshed). +/// Mount `plugin` (once) under its layout slot in the viewer's light DOM. +/// Idempotent — a plugin that already has a parent is left untouched (only +/// its `slot` attribute is refreshed). No style read happens here: the +/// mount may precede the theme stamp, and the plugin's first `draw()` +/// captures its `--psp-*` CSS fresh by construction ("stamp before draw"). /// /// # Arguments /// - `viewer` the root `` element. @@ -43,7 +45,6 @@ pub fn mount_plugin( // but not visible during a tab switch. This is dumb - fix this with // a real life cycle. viewer.prepend_with_node_1(html_plugin)?; - plugin.restyle(); } Ok(()) diff --git a/rust/perspective-viewer/src/rust/renderer/dispatch.rs b/rust/perspective-viewer/src/rust/renderer/dispatch.rs new file mode 100644 index 0000000000..21b95ee93a --- /dev/null +++ b/rust/perspective-viewer/src/rust/renderer/dispatch.rs @@ -0,0 +1,436 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! The [`Renderer`]'s LOCKED dispatch tier: everything that touches the +//! active plugin element under the per-renderer draw lock — the +//! `render_task` pipeline entry, the `draw_fresh`/`update_bound` dispatches, +//! restyle, resize and the presize protocol. + +use std::future::Future; +use std::rc::Rc; + +use perspective_client::View; +use perspective_js::utils::{ApiResult, ResultTApiErrorExt}; +use wasm_bindgen::prelude::*; +use web_sys::*; + +use super::activate::*; +use super::limits::*; +use super::{ContextPin, RenderContext, Renderer, SUBPIXEL_EPSILON}; +use crate::js::plugin::*; +use crate::session::FreshView; +use crate::utils::*; + +impl Renderer { + /// Toggle the `active` and panel-count (`single`/`multi`) classes on + /// `plugin`. + fn stamp_active(&self, plugin: &JsPerspectiveViewerPlugin) { + let el = plugin.unchecked_ref::(); + let _ = el + .class_list() + .toggle_with_force("active", self.0.is_active_panel.get()); + + let is_solo = self.0.is_solo_panel.get(); + let _ = el.class_list().toggle_with_force("single", is_solo); + let _ = el.class_list().toggle_with_force("multi", !is_solo); + } + + /// Stamp this panel's effective `theme` attribute. + pub fn stamp_theme(&self, plugin: Option<&JsPerspectiveViewerPlugin>) { + 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() { + Some(theme) + if theme_elem.get_attribute("theme").as_deref() != Some(theme.as_str()) => + { + let _ = theme_elem.set_attribute("theme", &theme); + }, + Some(_) => {}, + None => { + let _ = theme_elem.remove_attribute("theme"); + }, + } + } + } + + /// Restyle the active plugin and re-draw. + pub async fn restyle_all(&self) -> ApiResult { + self.render_task(|guard| async move { + let Some(ctx) = self.cached_context() else { + return Ok(JsValue::UNDEFINED); + }; + + let _pin = self.pin_context(&guard, ctx.clone()); + let plugin = self.ensure_plugin_selected()?; + let meta = self.metadata(); + let stamped_theme = self.effective_theme(); + self.stamp_theme(Some(&plugin)); + self.stamp_active(&plugin); + plugin.restyle(); + *self.0.captured_theme.borrow_mut() = Some(stamped_theme); + let mut limits = + get_row_and_col_limits(&ctx.view, &meta, self.is_render_warning_enabled()).await?; + + limits.is_update = true; + plugin + .update( + ctx.view.clone().into(), + limits.max_cols, + limits.max_rows, + false, + ) + .await?; + + Ok(JsValue::UNDEFINED) + }) + .await + } + + pub async fn with_lock(self, task: impl Future>) -> ApiResult { + let draw_mutex = self.draw_lock(); + draw_mutex.lock(task).await + } + + /// The [`RenderContext`] pinned by the run currently holding this + /// renderer's draw lock, if any. The per-panel element getters answer + /// from this when present (invariant I5). + pub fn render_context(&self) -> Option> { + self.0.active_context.borrow().clone() + } + + /// The cached [`RenderContext`] of the currently-bound `View` (set by + /// the pipeline at bind time). + pub fn cached_context(&self) -> Option> { + self.0.cached_context.borrow().clone() + } + + /// Cache `ctx` as the bound view's context (pipeline, at bind time). + pub fn set_cached_context(&self, ctx: Rc) { + *self.0.cached_context.borrow_mut() = Some(ctx); + } + + /// Pin `ctx` as the active [`RenderContext`] for the duration of the + /// returned RAII guard. Requires the lock witness — a context can only + /// be pinned by a locked run. + pub fn pin_context(&self, _guard: &RenderGuard, ctx: Rc) -> ContextPin { + *self.0.active_context.borrow_mut() = Some(ctx); + ContextPin(self.clone()) + } + + /// `true` while a run is executing on this renderer's draw lock. + pub fn is_locked(&self) -> bool { + self.draw_lock.is_held() + } + + pub async fn resize(&self) -> ApiResult<()> { + let draw_mutex = self.draw_lock(); + let timer = self.render_timer(); + draw_mutex + .debounce_with(|_guard| async move { + set_timeout(timer.get_throttle()).await?; + let Some(jsplugin) = self.active_plugin() else { + return Ok(()); + }; + self.stamp_active(&jsplugin); + jsplugin.resize().await?; + Ok(()) + }) + .await + } + + /// Public one-shot resize to explicit dimensions (`viewer.resize({ + /// dimensions })`). + pub async fn resize_with_dimensions(&self, width: f64, height: f64) -> ApiResult<()> { + if let Some(present) = self.presize_with_dimensions(width, height).await? { + present.call0(&JsValue::NULL)?; + } + + Ok(()) + } + + /// Render every changed pixel of a pending `(width, height)` box. + pub async fn presize_with_dimensions( + &self, + width: f64, + height: f64, + ) -> ApiResult> { + self.0.presize_pending.set(self.0.presize_pending.get() + 1); + let result = self.presize_inner(None, width, height).await; + self.0.presize_pending.set(self.0.presize_pending.get() - 1); + result + } + + /// Pre-size AND pre-position the plugin to its target layout bo. + pub async fn presize_with_box( + &self, + dx: f64, + dy: f64, + width: f64, + height: f64, + ) -> ApiResult> { + self.0.presize_pending.set(self.0.presize_pending.get() + 1); + let result = self.presize_inner(Some((dx, dy)), width, height).await; + self.0.presize_pending.set(self.0.presize_pending.get() - 1); + result + } + + async fn presize_inner( + &self, + translate: Option<(f64, f64)>, + width: f64, + height: f64, + ) -> ApiResult> { + let draw_mutex = self.draw_lock(); + draw_mutex + .debounce_with(|_guard| async move { + let Some(plugin) = self.active_plugin() else { + return Ok(None); + }; + + self.stamp_active(&plugin); + self.clear_presize()?; + let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref(); + let rect = main_panel.get_bounding_client_rect(); + let size_changed = (height - rect.height()).abs() > SUBPIXEL_EPSILON + || (width - rect.width()).abs() > SUBPIXEL_EPSILON; + + let (dx, dy) = translate.unwrap_or((0.0, 0.0)); + let moved = dx.abs() > SUBPIXEL_EPSILON || dy.abs() > SUBPIXEL_EPSILON; + if !size_changed && !moved { + return Ok(None); + } + + if plugin.capabilities().presize { + if size_changed { + return plugin.presize(width, height).await; + } + + return Ok(None); + } + + if size_changed { + main_panel + .style() + .set_property("width", &format!("{width}px"))?; + main_panel + .style() + .set_property("height", &format!("{height}px"))?; + } + + if moved { + main_panel + .style() + .set_property("transform", &format!("translate({dx}px, {dy}px)"))?; + } + + if size_changed { + let result = plugin.resize().await; + if translate.is_none() { + main_panel.style().set_property("width", "")?; + main_panel.style().set_property("height", "")?; + } + + result?; + } + + Ok(None) + }) + .await + } + + /// Remove the inline presize styles applied by [`Self::presize_with_box`]. + pub fn clear_presize(&self) -> ApiResult<()> { + if let Some(plugin) = self.active_plugin() { + let el = plugin.unchecked_ref::(); + el.style().set_property("width", "")?; + el.style().set_property("height", "")?; + el.style().set_property("transform", "")?; + } + + Ok(()) + } + + pub async fn render_task(&self, f: F) -> ApiResult + where + F: FnOnce(RenderGuard) -> Fut, + Fut: Future>, + { + self.draw_lock().lock_with(f).await + } + + /// FULL-draw a NEW `View` on the active plugin (`plugin.draw`). + pub async fn draw_fresh(&self, guard: &RenderGuard, view: FreshView) -> ApiResult<()> { + let timer = self.render_timer(); + timer + .capture_time(self.draw_view(guard, view.view(), false)) + .await + } + + /// Repaint the ALREADY-BOUND `view` on the active plugin. + pub async fn update_bound(&self, guard: &RenderGuard, view: &View) -> ApiResult<()> { + let timer = self.render_timer(); + timer.capture_time(self.draw_view(guard, view, true)).await + } + + /// Mint the [`FreshView`] full-draw witness for a plugin that has never + /// painted the bound `View`. + pub fn promote_first_paint(&self, view: &View) -> Option { + if !self.0.has_drawn.get() { + Some(FreshView::assert_fresh(view.clone())) + } else { + None + } + } + + /// Whether the active plugin's element is currently hidden. + pub fn is_plugin_hidden(&self) -> bool { + match self.active_plugin() { + Some(plugin) => plugin + .unchecked_ref::() + .offset_parent() + .is_none(), + None => false, + } + } + + /// Record a `table_updated` redraw dropped while hidden (see + /// [`RendererData::data_stale`]). + pub fn set_data_stale(&self) { + self.0.data_stale.set(true); + } + + /// Consume the dropped-update marker; the caller owes a full + /// `plugin.update` dispatch when `true`. + pub fn take_data_stale(&self) -> bool { + self.0.data_stale.replace(false) + } + + pub async fn activation_repaint(&self, _guard: &RenderGuard) -> ApiResult<()> { + if let Some(plugin) = self.active_plugin() { + self.stamp_active(&plugin); + self.stamp_theme(Some(&plugin)); + let _ = plugin.resize().await?; + } + + Ok(()) + } + + /// Redraw an already-bound view, debounced. + pub async fn update_lazy( + &self, + view: impl Future>>, + ) -> ApiResult<()> { + let timer = self.render_timer(); + self.draw_lock() + .debounce_with(|guard| async move { + set_timeout(timer.get_throttle()).await?; + if self.0.presize_pending.get() > 0 { + tracing::debug!("Update skipped, presize pending"); + return Ok(()); + } + + self.mount_active_plugin()?; + if let Some(view) = view.await? { + let _pin = self + .cached_context() + .map(|ctx| self.pin_context(&guard, ctx)); + + let timer = self.render_timer(); + timer + .capture_time(self.draw_view(&guard, &view, true)) + .await + } else { + tracing::debug!("Render skipped, no `View` attached"); + Ok(()) + } + }) + .await + } + + async fn draw_view( + &self, + _guard: &RenderGuard, + view: &perspective_client::View, + is_update: bool, + ) -> ApiResult<()> { + debug_assert!( + !is_update || self.cached_context().is_none() || self.render_context().is_some(), + "I5: RenderContext not pinned at plugin dispatch" + ); + + let plugin = if is_update { + match self.active_plugin() { + Some(plugin) => plugin, + None => return Ok(()), + } + } else { + self.ensure_plugin_selected()? + }; + + let meta = self.metadata(); + let mut limits = + get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?; + + limits.is_update = is_update; + if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() { + cb.emit(limits); + } + + 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(); + self.stamp_active(&plugin); + self.stamp_theme(Some(&plugin)); + if self.needs_restyle() { + plugin.restyle(); + *self.0.captured_theme.borrow_mut() = Some(stamped_theme.clone()); + } + + self.0.data_stale.set(false); + let result = if is_update { + let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false); + activate_plugin(_guard, &viewer_elem, &plugin, slot.as_deref(), task).await + } else { + let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false); + activate_plugin(_guard, &viewer_elem, &plugin, slot.as_deref(), task).await + }; + + if result.is_ok() { + self.0.has_drawn.set(true); + if first_paint { + *self.0.captured_theme.borrow_mut() = Some(stamped_theme); + } + } + + let cleanup = remove_inactive_plugin( + &viewer_elem, + &plugin, + slot.as_deref(), + self.plugin_data.borrow_mut().plugin_store.plugins(), + ); + + match result.ignore_view_delete() { + Err(error) if !self.0.has_drawn.get() => Err(error), + Err(error) => { + tracing::warn!("{}", error); + cleanup + }, + Ok(_) => cleanup, + } + } + + fn draw_lock(&self) -> DebounceMutex { + self.draw_lock.clone() + } +} diff --git a/rust/perspective-viewer/src/rust/renderer/plugin_config.rs b/rust/perspective-viewer/src/rust/renderer/plugin_config.rs new file mode 100644 index 0000000000..4633bbb2a1 --- /dev/null +++ b/rust/perspective-viewer/src/rust/renderer/plugin_config.rs @@ -0,0 +1,495 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! The [`Renderer`]'s per-plugin config state: the plugin-level and +//! per-column buckets (`plugin_states`), their schema-aware strip/merge +//! write paths, and the restore-prep materialized snapshot. + +use std::collections::HashMap; + +use futures::future::join_all; +use perspective_client::config::ViewConfig; +use perspective_js::utils::{ApiResult, JsValueSerdeExt}; +use serde_json::Value; +use wasm_bindgen::prelude::*; + +use super::Renderer; +use crate::config::*; +use crate::queries::resolve_abs_max; +use crate::session::Session; + +/// A per-column config map. Each inner [`serde_json::Map`] is a flat collection +/// of plugin-defined JSON keys whose shape is dictated by the active plugin's +/// [`crate::config::ColumnConfigSchema`]. +pub type ColumnConfigMap = HashMap>; + +/// Per-plugin config bucket. Holds the per-column style map and the +/// plugin-level config map for one plugin. Buckets are keyed by plugin +/// name in [`RendererMutData::plugin_states`], so foreign keys from a +/// different plugin physically cannot appear in the active plugin's +/// bucket. +#[derive(Clone, Debug, Default)] +pub struct PluginScopedConfig { + pub columns: ColumnConfigMap, + pub plugin: serde_json::Map, +} + +impl Renderer { + /// Name of the currently-active plugin (used as the key into + /// `plugin_states`). Returns `None` when no plugin has been + /// activated yet. + fn active_plugin_name(&self) -> Option { + Some(self.borrow().metadata.name.clone()).filter(|n| !n.is_empty()) + } + + /// Snapshot of the active plugin's per-column config map. + pub fn all_columns_configs(&self) -> ColumnConfigMap { + self.active_plugin_name() + .and_then(|n| { + self.borrow() + .plugin_states + .get(&n) + .map(|b| b.columns.clone()) + }) + .unwrap_or_default() + } + + /// Restore-prep snapshot: like [`Self::all_columns_configs`], but + /// for each column also materializes any `ControlSpec::Number` + /// fields the schema declares with `include: true` that aren't + /// already in the bucket entry. The materialized value is the + /// schema's `default`, which the schema computes from cached + /// column stats (via [`Self::query_column_config_schema`]). + pub async fn all_columns_configs_materialized( + &self, + view_config: &ViewConfig, + session: &Session, + ) -> ColumnConfigMap { + let mut configs = self.all_columns_configs(); + let mut to_warm: Vec = vec![]; + for (col, entry) in &configs { + if session + .get_column_stats(col) + .and_then(|s| s.abs_max) + .is_some() + { + continue; + } + let Ok(schema) = + self.query_column_config_schema(view_config, session, col, Some(entry)) + else { + continue; + }; + let needs_warm = schema.fields.iter().any(|f| { + matches!( + f, + ControlSpec::Number { + key, + include: Some(true), + .. + } if !entry.contains_key(key) + ) + }); + if needs_warm { + to_warm.push(col.clone()); + } + } + + if !to_warm.is_empty() { + let metadata = session.metadata().clone(); + let view = session.get_view(); + let futs = to_warm + .iter() + .map(|c| resolve_abs_max(session, &metadata, view.as_ref(), c.as_str())); + join_all(futs).await; + } + + for (col, entry) in &mut configs { + let Ok(schema) = + self.query_column_config_schema(view_config, session, col, Some(entry)) + else { + continue; + }; + + for field in &schema.fields { + let ControlSpec::Number { + key, + default, + include: Some(true), + .. + } = field + else { + continue; + }; + + if entry.contains_key(key) { + continue; + } + + let Some(num) = serde_json::Number::from_f64(*default) else { + continue; + }; + + entry.insert(key.clone(), serde_json::Value::Number(num)); + } + } + + configs + } + + /// Clear the active plugin's per-column config map. + pub fn reset_columns_configs(&self) { + if let Some(n) = self.active_plugin_name() { + self.borrow_mut() + .plugin_states + .entry(n) + .or_default() + .columns + .clear(); + } + } + + /// Clone of the active plugin's per-column entry for `column_name`, + /// or `None` if no value is stored. + pub fn get_columns_config( + &self, + column_name: &str, + ) -> Option> { + let n = self.active_plugin_name()?; + self.borrow() + .plugin_states + .get(&n)? + .columns + .get(column_name) + .cloned() + } + + /// Wholesale update the active plugin's per-column config map + pub fn update_columns_configs( + &self, + view_config: &ViewConfig, + session: &Session, + update: ColumnConfigUpdate, + ) -> bool { + let Some(n) = self.active_plugin_name() else { + return false; + }; + + match update { + OptionalUpdate::SetDefault => { + let mut st = self.borrow_mut(); + let bucket = st.plugin_states.entry(n).or_default(); + let was_nonempty = !bucket.columns.is_empty(); + bucket.columns.clear(); + was_nonempty + }, + OptionalUpdate::Missing => false, + OptionalUpdate::Update(map) => { + let stripped: Vec<(String, serde_json::Map)> = map + .into_iter() + .map(|(col, mut cfg)| { + if let Ok(schema) = + self.query_column_config_schema(view_config, session, &col, Some(&cfg)) + { + let active = schema.active_keys(); + cfg.retain(|k, _| active.contains(k)); + strip_default_values(&schema, &mut cfg); + } + + (col, cfg) + }) + .collect(); + + let mut st = self.borrow_mut(); + let bucket = st.plugin_states.entry(n).or_default(); + let mut changed = false; + for (col, cfg) in stripped { + if cfg.is_empty() { + if bucket.columns.remove(&col).is_some() { + changed = true; + } + } else { + match bucket.columns.insert(col, cfg.clone()) { + None => changed = true, + Some(old) if old != cfg => changed = true, + _ => {}, + } + } + } + + changed + }, + } + } + + /// Apply a single schema-field update from the column-style UI to + /// the active plugin's bucket. Clears the keys the field owns, + /// then splices in the partial new sub-state. Drops empty + /// entries. + pub fn update_columns_config_field( + &self, + view_config: &ViewConfig, + session: &Session, + column_name: String, + mut update: ColumnConfigFieldUpdate, + ) { + let Some(n) = self.active_plugin_name() else { + return; + }; + + let current_value = self.get_columns_config(&column_name); + if let Ok(schema) = self.query_column_config_schema( + view_config, + session, + &column_name, + current_value.as_ref(), + ) { + strip_default_values(&schema, &mut update.value); + } + + let mut st = self.borrow_mut(); + let bucket = st.plugin_states.entry(n).or_default(); + let entry = bucket.columns.entry(column_name.clone()).or_default(); + for k in &update.keys { + entry.remove(k); + } + for (k, v) in update.value { + if update.keys.contains(&k) { + entry.insert(k, v); + } + } + if entry.is_empty() { + bucket.columns.remove(&column_name); + } + } + + /// Snapshot of the active plugin's plugin-level config map. + pub fn get_plugin_config(&self) -> serde_json::Map { + self.active_plugin_name() + .and_then(|n| { + self.borrow() + .plugin_states + .get(&n) + .map(|b| b.plugin.clone()) + }) + .unwrap_or_default() + } + + /// Clear the active plugin's plugin-level config map. + pub fn reset_plugin_config(&self) { + if let Some(n) = self.active_plugin_name() { + self.borrow_mut() + .plugin_states + .entry(n) + .or_default() + .plugin + .clear(); + } + } + + /// Synchronously query the active plugin's + /// [`ColumnConfigSchema`] used to gate plugin-config strip logic. + fn query_plugin_config_schema( + &self, + view_config: &ViewConfig, + ) -> ApiResult { + let plugin = self.ensure_plugin_selected()?; + let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL); + let raw = plugin._plugin_config_schema(&view_config_js)?; + serde_wasm_bindgen::from_value(raw).map_err(|e| e.into()) + } + + /// Per-column counterpart of [`query_plugin_config_schema`]. Used by + /// the columns-config write paths (strip-on-write) and the + /// restore-prep snapshot (materialize-on-read). + fn query_column_config_schema( + &self, + view_config: &ViewConfig, + session: &Session, + column_name: &str, + current_value: Option<&serde_json::Map>, + ) -> ApiResult { + let plugin = self.ensure_plugin_selected()?; + let plugin_config = self.metadata(); + let names = &plugin_config.config_column_names; + let group = view_config + .columns + .iter() + .position(|maybe_s| maybe_s.as_deref() == Some(column_name)) + .and_then(|idx| names.get(idx)) + .map(|s| s.as_str()); + + if !session.metadata().has_view_schema() { + return Err(JsValue::from("view_schema not initialized").into()); + } + let Some(view_type) = session.metadata().get_column_view_type(column_name) else { + return Ok(ColumnConfigSchema { fields: vec![] }); + }; + + let current_js = JsValue::from_serde_ext(¤t_value).unwrap_or(JsValue::NULL); + let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL); + let stats = session.get_column_stats(column_name).unwrap_or_default(); + let stats_json = serde_json::json!({ + "abs_max": stats.abs_max, + }); + let stats_js = JsValue::from_serde_ext(&stats_json).unwrap_or(JsValue::NULL); + + let raw = plugin._column_config_schema( + &view_type.to_string(), + group, + column_name, + ¤t_js, + &view_config_js, + &stats_js, + )?; + + serde_wasm_bindgen::from_value(raw).map_err(|e| e.into()) + } + + /// Wholesale update the active plugin's plugin-level config map. + /// Entries whose value equals the schema-declared default are + /// treated as "reset this key" — the corresponding bucket entry + /// is cleared rather than the default being stored literally. + /// Keys absent from the incoming map are left alone (merge + /// semantics for the non-default subset). + pub fn update_plugin_config( + &self, + view_config: &ViewConfig, + update: PluginConfigUpdate, + ) -> bool { + let Some(n) = self.active_plugin_name() else { + return false; + }; + + let schema = self.query_plugin_config_schema(view_config).ok(); + let mut st = self.borrow_mut(); + let bucket = st.plugin_states.entry(n).or_default(); + match update { + OptionalUpdate::SetDefault => { + let changed = !bucket.plugin.is_empty(); + bucket.plugin.clear(); + changed + }, + OptionalUpdate::Missing => false, + OptionalUpdate::Update(mut map) => { + let mut changed = false; + if let Some(s) = &schema { + let active = s.active_keys(); + map.retain(|k, _| active.contains(k)); + map.retain(|key, value| { + let is_default = s + .fields + .iter() + .any(|spec| matches_declared_default(spec, key, value)); + if is_default { + if bucket.plugin.remove(key).is_some() { + changed = true; + } + false + } else { + true + } + }); + } + + for (k, v) in map { + let prev = bucket.plugin.insert(k, v.clone()); + if prev.as_ref() != Some(&v) { + changed = true; + } + } + + changed + }, + } + } + + /// Apply a single schema-field update from the plugin-settings UI + pub fn update_plugin_config_field( + &self, + view_config: &ViewConfig, + mut update: ColumnConfigFieldUpdate, + ) -> bool { + let Some(n) = self.active_plugin_name() else { + return false; + }; + + if let Ok(schema) = self.query_plugin_config_schema(view_config) { + strip_default_values(&schema, &mut update.value); + } + + let mut st = self.borrow_mut(); + let bucket = st.plugin_states.entry(n).or_default(); + let mut changed = false; + + for k in &update.keys { + if let Some(v) = update.value.get(k) { + let prev = bucket.plugin.insert(k.to_string(), v.clone()); + if prev.as_ref() != Some(v) { + changed = true; + } + } else if bucket.plugin.remove(k).is_some() { + changed = true; + } + } + + changed + } +} + +fn strip_default_values( + schema: &ColumnConfigSchema, + map: &mut serde_json::Map, +) { + map.retain(|key, value| { + !schema + .fields + .iter() + .any(|spec| matches_declared_default(spec, key, value)) + }); +} + +fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> bool { + match spec { + ControlSpec::Enum { + key: k, default, .. + } if k == key => value.as_str() == Some(default.as_str()), + ControlSpec::Bool { + key: k, default, .. + } if k == key => value.as_bool() == Some(*default), + ControlSpec::Number { + key: k, + include: Some(true), + .. + } if k == key => false, + ControlSpec::Number { + key: k, default, .. + } if k == key => value.as_f64() == Some(*default), + ControlSpec::String { + key: k, default, .. + } if k == key => value.as_str() == Some(default.as_str()), + ControlSpec::Color { + key: k, default, .. + } if k == key => value.as_str() == Some(default.as_str()), + ControlSpec::ColorRange { + key_pos, + default_pos, + .. + } if key_pos == key => value.as_str() == Some(default_pos.as_str()), + ControlSpec::ColorRange { + key_neg, + default_neg, + .. + } if key_neg == key => value.as_str() == Some(default_neg.as_str()), + _ => false, + } +} diff --git a/rust/perspective-viewer/src/rust/renderer/registry.rs b/rust/perspective-viewer/src/rust/renderer/registry.rs index 0ca84698f7..2b1803da57 100644 --- a/rust/perspective-viewer/src/rust/renderer/registry.rs +++ b/rust/perspective-viewer/src/rust/renderer/registry.rs @@ -114,6 +114,18 @@ pub impl LocalKey>>> { self.with(|plugin| { let plugin_inst = create_plugin(tag_name); let config = Rc::new(plugin_inst.read_static_config()); + + // Warm the per-tag capability memo on the probe instance, so + // the first render never pays the `Reflect` sweep — and surface + // a missing REQUIRED `draw` at registration instead of as a + // `TypeError` at first paint. + if !plugin_inst.capabilities().draw { + tracing::warn!( + "Plugin '{}' does not implement the required `draw` method", + tag_name + ); + } + let record = PluginRecord { tag_name: tag_name.to_owned(), config, diff --git a/rust/perspective-viewer/src/rust/renderer/render_timer.rs b/rust/perspective-viewer/src/rust/renderer/render_timer.rs index 1a989487b5..20200ab636 100644 --- a/rust/perspective-viewer/src/rust/renderer/render_timer.rs +++ b/rust/perspective-viewer/src/rust/renderer/render_timer.rs @@ -33,6 +33,15 @@ pub struct RenderTimerState { render_times: VecDeque, total_render_count: u32, start_time: f64, + + /// Bumped by every `visibilitychange` reset. A [`MovingWindowRenderTimer:: + /// capture_time`] whose awaited render spans a visibility flip measured + /// the hidden gap, not the render — it compares its starting epoch + /// against this and discards the sample, closing the hole the reset + /// alone leaves: an in-flight draw resolves AFTER the show-time reset, + /// so its giant sample would land in the freshly-cleared window and pin + /// the throttle at its cap. + epoch: u64, } /// Serialization of snapshot stats for the JS API call. @@ -48,22 +57,24 @@ pub struct RenderTimerStats { impl MovingWindowRenderTimer { pub async fn capture_time(&self, f: impl Future) -> T { let perf = global::window().performance().unwrap(); - let start = match *self.0.borrow() { - RenderTimerType::Constant(_) => 0_f64, - RenderTimerType::Moving(..) => perf.now(), + let (start, epoch) = match &*self.0.borrow() { + RenderTimerType::Constant(_) => (0_f64, 0_u64), + RenderTimerType::Moving(_, timings) => (perf.now(), timings.borrow().epoch), }; let result = f.await; match &mut *self.0.borrow_mut() { RenderTimerType::Moving(_, timings) => { let mut stats = timings.borrow_mut(); - let now = perf.now(); - stats.render_times.push_back(now - start); - if stats.render_times.len() > 5 { - stats.render_times.pop_front(); + if stats.epoch == epoch { + let now = perf.now(); + stats.render_times.push_back(now - start); + if stats.render_times.len() > 5 { + stats.render_times.pop_front(); + } + + stats.total_render_count += 1; } - - stats.total_render_count += 1; }, RenderTimerType::Constant(_) => (), }; @@ -143,10 +154,17 @@ impl RenderTimerState { impl RefCell { /// We need to clear the throttle queue when the browser tab is hidden, else /// the next frame timing will be the time the tab was hidden + render time. + /// The epoch bump (not just the clear) is what invalidates an IN-FLIGHT + /// `capture_time` — its sample resolves after this reset runs. fn register_on_visibility_change(self: &Rc) -> Closure { let state = self.clone(); let closure = Closure::new(move |_| { - *state.borrow_mut() = Default::default(); + let mut state = state.borrow_mut(); + let epoch = state.epoch + 1; + *state = RenderTimerState { + epoch, + ..Default::default() + }; }); global::document() @@ -165,6 +183,7 @@ impl Default for RenderTimerState { render_times: Default::default(), total_render_count: Default::default(), start_time, + epoch: 0, } } } diff --git a/rust/perspective-viewer/src/rust/session.rs b/rust/perspective-viewer/src/rust/session.rs index 86430e51bd..89842a61e2 100644 --- a/rust/perspective-viewer/src/rust/session.rs +++ b/rust/perspective-viewer/src/rust/session.rs @@ -188,6 +188,7 @@ impl Deref for SessionHandle { pub struct SessionData { client: Option, table: Option, + pending_table: Option, metadata: SessionMetadata, config: ViewConfig, global_filter: Vec, @@ -363,10 +364,12 @@ impl Session { Some(TableIntermediateState::Ejected) => { self.borrow_mut().is_loading = false; self.borrow_mut().table = None; + self.borrow_mut().pending_table = None; }, Some(TableIntermediateState::Reloaded) => { self.borrow_mut().is_loading = true; self.borrow_mut().table = None; + self.borrow_mut().pending_table = None; }, _ => { self.borrow_mut().is_loading = false; @@ -467,26 +470,69 @@ impl Session { self.borrow().client.clone() } + /// The configured table name awaiting a host, if any (see + /// [`SessionData::pending_table`]). + pub fn pending_table(&self) -> Option { + self.borrow().pending_table.clone() + } + + /// Suspend a BOUND table to PENDING: delete the `View`, drop the `Table` + /// handle (freeing the engine name for a lazy `Table::delete` to + /// complete), and record the name as pending — the view config is KEPT, + /// so the reactive rebind (`tasks::table_lifecycle`) restores this panel + /// as it was when the name is hosted again. `None` when no table is + /// bound. + pub fn suspend_table(&self) -> Option> + use<>> { + let name = self.borrow().table.as_ref()?.get_name().to_owned(); + let fut = self.reset(ResetOptions { + table: Some(TableIntermediateState::Ejected), + stats: true, + ..ResetOptions::default() + }); + + self.borrow_mut().pending_table = Some(name); + Some(fut) + } + /// Reset this `Session`'s state with a new `Table`. Implicitly clears the /// `ViewSubscription`, which will need to be re-initialized later via /// `create_view()`. /// /// # Arguments /// - /// - `table_name` The name of the `Table` to load, which must exist on the - /// loaded `Client`. + /// - `table_name` The name of the `Table` to load. /// /// # Returns /// /// `table_name` is unique per `Client`, so if this value has not changed, /// `Session::set_table` does nothing and returns `Ok(false)`. + /// + /// A name NO loaded client hosts (yet) is not an error: the session + /// records it PENDING ([`SessionData::pending_table`]) and returns + /// `Ok(false)`; the element's hosted-tables subscription + /// (`tasks::table_lifecycle`) re-runs the bind when the table is created. pub async fn set_table(&self, table_name: String) -> ApiResult { if Some(table_name.as_str()) == self.0.borrow().table.as_ref().map(|x| x.get_name()) { + self.0.borrow_mut().pending_table = None; return Ok(false); } let client = self.0.borrow().client.clone().into_apierror()?; - let table = client.open_table(table_name.clone()).await?; + let table = match client.open_table(table_name.clone()).await { + Ok(table) => table, + Err(e) => { + // Distinguish "not hosted (yet)" — which PENDS — from a real + // open failure by the hosted set, not the error's text. + let hosted = client.get_hosted_table_names().await.unwrap_or_default(); + if hosted.iter().any(|n| n == &table_name) { + return Err(e.into()); + } + + self.0.borrow_mut().pending_table = Some(table_name); + return Ok(false); + }, + }; + match SessionMetadata::from_table(&table).await { Ok(metadata) => { let client = table.get_client(); @@ -517,6 +563,7 @@ impl Session { let sub = self.borrow_mut().view_sub.take(); self.borrow_mut().metadata = metadata; self.borrow_mut().table = Some(table); + self.borrow_mut().pending_table = None; self.borrow_mut().is_loading = false; self.borrow_mut().last_validated_expressions = None; sub.delete().await?; @@ -1258,10 +1305,6 @@ impl FreshView { pub fn view(&self) -> &View { &self.0 } - - pub fn into_view(self) -> View { - self.0 - } } /// How [`Session::bind_view`] reconciled a validated snapshot against the diff --git a/rust/perspective-viewer/src/rust/session/view_subscription.rs b/rust/perspective-viewer/src/rust/session/view_subscription.rs index f6f87aea0a..24ea369e63 100644 --- a/rust/perspective-viewer/src/rust/session/view_subscription.rs +++ b/rust/perspective-viewer/src/rust/session/view_subscription.rs @@ -60,8 +60,6 @@ impl ViewSubscriptionData { Ok(JsValue::UNDEFINED) } - /// TODO Use serde to serialize the full view config, instead of calculating - /// `is_aggregated` here. async fn update_view_stats(self) -> ApiResult { let dimensions = self.view.dimensions().await?; let num_rows = dimensions.num_table_rows as u32; @@ -81,13 +79,16 @@ impl ViewSubscriptionData { } async fn internal_delete(&self) -> ApiResult<()> { + if self.is_deleted.replace(true) { + return Ok(()); + } + let view = &self.view; if self.on_update.is_some() { view.remove_update(self.callback_id.get()).await?; } view.delete().await?; - self.is_deleted.set(true); Ok(()) } } diff --git a/rust/perspective-viewer/src/rust/tasks/apply_global_filters.rs b/rust/perspective-viewer/src/rust/tasks/apply_global_filters.rs index d6d2322162..ee0b5c2620 100644 --- a/rust/perspective-viewer/src/rust/tasks/apply_global_filters.rs +++ b/rust/perspective-viewer/src/rust/tasks/apply_global_filters.rs @@ -45,9 +45,11 @@ pub fn apply_global_filters(workspace: &Workspace) { if let Some(panel) = workspace.panel(&pid) && stamp_global_overlay(workspace, &pid, &panel.session) { + let effect = workspace.effects().guard(); let session = panel.session.clone(); let renderer = panel.renderer.clone(); spawn_owned("apply-global-filters", async move { + let _effect = effect; apply_and_render(&session, &renderer, ViewConfigUpdate::default())?.await?; Ok(()) }); diff --git a/rust/perspective-viewer/src/rust/tasks/auto_pause.rs b/rust/perspective-viewer/src/rust/tasks/auto_pause.rs new file mode 100644 index 0000000000..7463505bfd --- /dev/null +++ b/rust/perspective-viewer/src/rust/tasks/auto_pause.rs @@ -0,0 +1,171 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::cell::Cell; +use std::rc::Rc; + +use perspective_js::utils::global; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; +use web_sys::*; + +use crate::config::ViewerConfigUpdate; +use crate::js::*; +use crate::presentation::Presentation; +use crate::renderer::*; +use crate::session::Session; +use crate::utils::*; +use crate::workspace::Workspace; +use crate::*; + +/// The `auto-pause` driver: pauses every panel's [`Session`] (deleting its +/// `ViewSubscription`, which stops `table_updated` at the source) whenever +/// the host element cannot be seen, and resumes with a re-render when it +/// can again. "Cannot be seen" is the conjunction of TWO independent +/// signals, either of which alone would miss real cases: +/// +/// - An `IntersectionObserver` on the host element (scrolled out of the +/// viewport, `display: none`, disconnected). This CANNOT observe +/// browser-tab backgrounding — intersection entries are only generated +/// during the document's rendering steps, which are suspended while hidden, +/// and the element's viewport intersection doesn't change on a tab switch +/// anyway. +/// - A `document` `visibilitychange` listener (tab backgrounded, window +/// minimized/occluded). Without it, a fast-updating table kept dispatching +/// update-redraws whose draws hang on the suspended rAF — the +/// background-tab reactivation stall. +pub struct AutoPauseHandle { + elem: HtmlElement, + observer: IntersectionObserver, + _callback: Closure, + _visibility: Closure, +} + +impl AutoPauseHandle { + pub fn new(elem: &HtmlElement, presentation: &Presentation, workspace: &Workspace) -> Self { + let state = Rc::new(AutoPauseState { + intersecting: Cell::new(true), + doc_visible: Cell::new(!global::document().hidden()), + presentation: presentation.clone(), + workspace: workspace.clone(), + }); + + let _callback = Closure::new({ + clone!(state); + move |xs: js_sys::Array| { + let intersect = xs + .pop() + .unchecked_into::() + .is_intersecting(); + + state.intersecting.set(intersect); + state.apply(); + } + }); + + let func = _callback.as_ref().unchecked_ref::(); + let observer = IntersectionObserver::new(func); + observer.observe(elem); + + let _visibility = Closure::new({ + clone!(state); + move |_: JsValue| { + state.doc_visible.set(!global::document().hidden()); + state.apply(); + } + }); + + global::document() + .add_event_listener_with_callback( + "visibilitychange", + _visibility.as_ref().unchecked_ref(), + ) + .unwrap(); + + Self { + elem: elem.clone(), + observer, + _callback, + _visibility, + } + } +} + +impl Drop for AutoPauseHandle { + fn drop(&mut self) { + self.observer.unobserve(&self.elem); + let _ = global::document().remove_event_listener_with_callback( + "visibilitychange", + self._visibility.as_ref().unchecked_ref(), + ); + } +} + +struct AutoPauseState { + intersecting: Cell, + doc_visible: Cell, + presentation: Presentation, + workspace: Workspace, +} + +impl AutoPauseState { + fn apply(self: &Rc) { + let visible = self.intersecting.get() && self.doc_visible.get(); + clone!(self.workspace, self.presentation); + ApiFuture::spawn(async move { + let panels = workspace.panels(); + futures::future::join_all(panels.iter().map(|panel| { + let presentation = presentation.clone(); + async move { + set_panel_paused(&panel.session, &panel.renderer, &presentation, visible).await + } + })) + .await + .into_iter() + .collect::>>()?; + + Ok(()) + }); + } +} + +/// Apply one panel's pause/resume transition. Shared by the +/// [`AutoPauseState`] fan-out and `setAutoPause(false)`'s force-resume. +pub(crate) async fn set_panel_paused( + session: &Session, + renderer: &Renderer, + presentation: &Presentation, + visible: bool, +) -> ApiResult<()> { + if visible { + if session.set_pause(false) { + let result = super::restore_and_render( + session, + renderer, + presentation, + super::RunOrigin::Internal, + ViewerConfigUpdate::default(), + async move { Ok(()) }, + ) + .await; + + if let Err(e) = result.ignore_view_delete() { + session.set_run_error(e.clone()).await?; + return Err(e); + } + } + } else { + session.set_pause(true); + }; + + Ok(()) +} diff --git a/rust/perspective-viewer/src/rust/tasks/create_panel.rs b/rust/perspective-viewer/src/rust/tasks/create_panel.rs index 5eaad4499a..cc56f8e2e7 100644 --- a/rust/perspective-viewer/src/rust/tasks/create_panel.rs +++ b/rust/perspective-viewer/src/rust/tasks/create_panel.rs @@ -10,8 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -#![allow(non_snake_case)] - use web_sys::HtmlElement; use yew::Callback; @@ -44,10 +42,21 @@ pub(crate) fn wire_panel_subs( /// Wire a panel's data-refresh subscription: when its [`Session`]'s table emits /// an update, redraw its [`Renderer`]. The returned [`Subscription`] must be /// owned for the panel's lifetime (see [`Panel`]). +/// +/// Hidden panels (an unslotted tab-stack panel, a `display: none` host) +/// don't redraw — a full charts dispatch per update is a fetch + render +/// nobody can see. The dropped update is recorded on the [`Renderer`] +/// instead, and the activation nudge (`tasks::pipeline::activation_render`) +/// consumes the marker to catch the panel up with ONE `plugin.update`. fn wire_panel_render_sub(session: &Session, renderer: &Renderer) -> Subscription { session.table_updated.add_listener({ clone!(renderer, session); move |_| { + if renderer.is_plugin_hidden() { + renderer.set_data_stale(); + return; + } + clone!(renderer, session); ApiFuture::spawn(async move { renderer @@ -76,29 +85,81 @@ pub(crate) async fn create_panel( update: ViewerConfigUpdate, client: Option, ) -> ApiResult { - let (id, session, renderer, update) = - create_panel_model(elem, presentation, workspace, id, update, client); + let (id, session, renderer, update) = create_panel_model( + elem, + presentation, + workspace, + id, + update, + client, + Placement::Placed, + ); 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. + { + clone!(workspace, id, notify); + ApiFuture::spawn(async move { + set_timeout(STAGING_DEADLINE_MS).await?; + if workspace.clear_staged(&id) { + notify.emit(()); + } + + Ok(()) + }); + } + // A fresh panel is never the active one, so it needs no `root` for the // (active-only) settings-sidebar sequencing. - restore_panel( + let result = restore_panel( &session, &renderer, presentation, + workspace, None, RestoreMode::Fresh, update, ) - .await?; + .await; + + // Promote on completion AND error — a failed restore's error state + // must become visible too. + if workspace.clear_staged(&id) { + notify.emit(()); + } + + result?; Ok(id) } +/// 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. + Placed, + + /// 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. + Reserved, +} + /// The synchronous *model* half of [`create_panel`]: build and register a new /// panel's engine handles (own `Session` + `Renderer` + id) in the -/// [`Workspace`], apply-and-strip the element-level config fields -/// (`settings`/`theme`), and bind `client` (falling back to the default +/// [`Workspace`] (per `placement`), apply-and-strip the element-level config +/// fields (`settings`/`theme`), and bind `client` (falling back to the default /// client). `id` is the panel's id, or `None` to generate a fresh one. pub(crate) fn create_panel_model( elem: &HtmlElement, @@ -107,23 +168,33 @@ pub(crate) fn create_panel_model( id: Option, mut update: ViewerConfigUpdate, 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); - workspace.insert_panel(Panel::new( - id.clone(), - session.clone(), - renderer.clone(), - subs, - )); + let panel = Panel::new(id.clone(), session.clone(), renderer.clone(), subs); + match placement { + Placement::Placed => workspace.insert_panel(panel), + 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)); } update.theme = OptionalUpdate::Missing; @@ -140,6 +211,13 @@ 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()?; + stamp_global_overlay(workspace, &panel.id, &panel.session); + notify.emit(()); + Some(panel) +} + /// 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 diff --git a/rust/perspective-viewer/src/rust/tasks/dismiss_render_warning.rs b/rust/perspective-viewer/src/rust/tasks/dismiss_render_warning.rs index d9e912304b..c0fe630f42 100644 --- a/rust/perspective-viewer/src/rust/tasks/dismiss_render_warning.rs +++ b/rust/perspective-viewer/src/rust/tasks/dismiss_render_warning.rs @@ -27,7 +27,9 @@ pub fn dismiss_render_warning_callback(session: &Session, renderer: &Renderer) - clone!(session, renderer); ApiFuture::spawn(async move { renderer.disable_active_plugin_render_warning(); - renderer.update(session.get_view()).await + renderer + .update_lazy(async move { Ok(session.get_view()) }) + .await }); }) } diff --git a/rust/perspective-viewer/src/rust/tasks/eject.rs b/rust/perspective-viewer/src/rust/tasks/eject.rs index 9d33677ee2..421406b447 100644 --- a/rust/perspective-viewer/src/rust/tasks/eject.rs +++ b/rust/perspective-viewer/src/rust/tasks/eject.rs @@ -29,9 +29,14 @@ pub fn delete_all(workspace: &Workspace, root: &Root) -> ApiFut clone!(workspace, root); ApiFuture::new(async move { let panels = workspace - .panel_ids() + .take_reserved() .into_iter() - .filter_map(|id| workspace.panel(&id)) + .chain( + workspace + .panel_ids() + .into_iter() + .filter_map(|id| workspace.panel(&id)), + ) .collect::>(); // Each panel owns its own `Renderer`/draw lock, so dispose them @@ -44,17 +49,22 @@ pub fn delete_all(workspace: &Workspace, root: &Root) -> ApiFut let results = join_all(panels.iter().map(|panel| { clone!(panel.session, panel.renderer); session.table_unloaded.emit(false); + let was_errored = session.is_errored(); renderer.clone().with_lock(async move { renderer.delete()?; - session + let reset = session .reset(ResetOptions { config: true, expressions: true, table: Some(TableIntermediateState::Ejected), ..ResetOptions::default() }) - .await?; - Ok(()) + .await; + + match reset { + Err(_) if was_errored => Ok(()), + other => other, + } }) })) .await; diff --git a/rust/perspective-viewer/src/rust/tasks/intersection_observer.rs b/rust/perspective-viewer/src/rust/tasks/intersection_observer.rs deleted file mode 100644 index c6ac0f3c16..0000000000 --- a/rust/perspective-viewer/src/rust/tasks/intersection_observer.rs +++ /dev/null @@ -1,119 +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). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -use wasm_bindgen::JsCast; -use wasm_bindgen::prelude::*; -use web_sys::*; - -use crate::config::ViewerConfigUpdate; -use crate::js::*; -use crate::presentation::Presentation; -use crate::renderer::*; -use crate::session::Session; -use crate::utils::*; -use crate::workspace::Workspace; -use crate::*; - -pub struct IntersectionObserverHandle { - elem: HtmlElement, - observer: IntersectionObserver, - _callback: Closure, -} - -impl IntersectionObserverHandle { - pub fn new(elem: &HtmlElement, presentation: &Presentation, workspace: &Workspace) -> Self { - clone!(workspace, presentation); - let _callback = Closure::new(move |xs: js_sys::Array| { - // https://stackoverflow.com/questions/53862160/intersectionobserver-multiple-entries - let intersect = xs - .pop() - .unchecked_into::() - .is_intersecting(); - - // All panels share the host's visibility, so this single host - // observer fans the pause/resume out to every panel's `Session` - // (each its own) — not just the seed. - clone!(workspace, presentation); - ApiFuture::spawn(async move { - for id in workspace.panel_ids() { - if let Some(panel) = workspace.panel(&id) { - let state = IntersectionObserverState { - presentation: presentation.clone(), - session: panel.session, - renderer: panel.renderer, - }; - - state.set_pause(intersect).await?; - } - } - - Ok(()) - }); - }); - - let func = _callback.as_ref().unchecked_ref::(); - let observer = IntersectionObserver::new(func); - observer.observe(elem); - Self { - elem: elem.clone(), - _callback, - observer, - } - } -} - -impl Drop for IntersectionObserverHandle { - fn drop(&mut self) { - self.observer.unobserve(&self.elem); - } -} - -struct IntersectionObserverState { - session: Session, - renderer: Renderer, - presentation: Presentation, -} - -impl IntersectionObserverState { - async fn set_pause(self, intersect: bool) -> ApiResult<()> { - if intersect { - if self.session.set_pause(false) { - let result = super::restore_and_render( - &self.session, - &self.renderer, - &self.presentation, - // Host-initiated resume: a paused-era commit renders - // (rebuild / first-paint promotion); a clean resume - // dispatches nothing. - super::RunOrigin::Internal, - ViewerConfigUpdate::default(), - async move { Ok(()) }, - ) - .await; - - // An unpause resume can carry a panel's deferred FIRST - // paint (`draw_view` propagates first-draw failures), and - // nothing user-awaited observes this task — surface it as - // a panel error or it vanishes entirely. - if let Err(e) = &result { - self.session.set_error(false, e.clone()).await?; - } - - result?; - } - } else { - self.session.set_pause(true); - }; - - Ok(()) - } -} diff --git a/rust/perspective-viewer/src/rust/tasks/mod.rs b/rust/perspective-viewer/src/rust/tasks/mod.rs index 5205e1baf1..25c0797170 100644 --- a/rust/perspective-viewer/src/rust/tasks/mod.rs +++ b/rust/perspective-viewer/src/rust/tasks/mod.rs @@ -22,12 +22,12 @@ //! [`Presentation`]: crate::presentation::Presentation mod apply_global_filters; +mod auto_pause; mod copy_export; mod create_panel; mod dismiss_render_warning; mod edit_expression; mod eject; -mod intersection_observer; mod pipeline; mod presize_panels; mod reset_all; @@ -38,16 +38,27 @@ mod send_column_config; mod send_plugin_config; mod set_edit_mode; mod sync_update_panels; +mod table_lifecycle; mod update_theme; mod validate_expression; +/// How long staged work (a hidden first draw, a presize sweep) may withhold +/// a layout transition before it is released anyway — the progressive-reveal +/// fallback for slow (e.g. remote) tables. +pub(crate) const STAGING_DEADLINE_MS: i32 = 500; + +/// Fallback panel chrome `(width, height)` px (margin + border + titlebar) +/// when no frame is available to measure live (see +/// [`presize_panels::plugin_chrome`]). +pub(crate) const CHROME_FALLBACK: (f64, f64) = (8.0, 33.0); + pub use self::apply_global_filters::*; +pub use self::auto_pause::*; pub use self::copy_export::*; pub(crate) use self::create_panel::*; pub use self::dismiss_render_warning::*; pub use self::edit_expression::*; pub use self::eject::*; -pub use self::intersection_observer::*; pub use self::pipeline::*; pub use self::presize_panels::*; pub use self::reset_all::*; @@ -58,5 +69,6 @@ pub use self::send_column_config::*; pub use self::send_plugin_config::*; pub use self::set_edit_mode::*; pub(crate) use self::sync_update_panels::*; +pub(crate) use self::table_lifecycle::*; pub use self::update_theme::*; pub use self::validate_expression::*; diff --git a/rust/perspective-viewer/src/rust/tasks/pipeline.rs b/rust/perspective-viewer/src/rust/tasks/pipeline.rs index e623ce4cbc..c559373c69 100644 --- a/rust/perspective-viewer/src/rust/tasks/pipeline.rs +++ b/rust/perspective-viewer/src/rust/tasks/pipeline.rs @@ -27,11 +27,15 @@ use std::rc::Rc; +use futures::future::LocalBoxFuture; use perspective_client::config::ViewConfigUpdate; use perspective_client::{View, clone}; use perspective_js::utils::*; +use wasm_bindgen::JsValue; use yew::prelude::*; +use crate::config::{ColumnConfigUpdate, PluginConfigUpdate}; +use crate::presentation::Presentation; use crate::renderer::{RenderContext, Renderer}; use crate::session::{BindDisposition, Session}; use crate::utils::RenderGuard; @@ -191,74 +195,154 @@ pub fn just_render(session: &Session, renderer: &Renderer) -> ApiResult, + + /// Plugin-level bucket update, applied inside the run after the bind + /// (so strip-on-write sees fresh schemas). + pub plugin_config: PluginConfigUpdate, + + /// Per-column bucket update; same timing as `plugin_config`. + pub columns_config: ColumnConfigUpdate, + + /// Pre-bind hook awaited inside the lock (table binding, resets) — + /// BEFORE the error guard, so a task that recovers the session (e.g. + /// `restorePanel`'s errored-recovery reset) unblocks its own run. + pub task: Option>>, + + /// When set, plugin dispatch is skipped while the host element is not + /// visible (`Presentation::is_visible`) — the `restore` family's gate + /// (the eventual auto-pause resume rebuilds and redraws). `None` + /// dispatches unconditionally (UI-commit runs, whose callers gate on + /// drawn/loaded state themselves). + pub presentation: Option, +} + +impl Default for RunSpec { + fn default() -> Self { + Self { + origin: RunOrigin::Internal, + plugin_idx: None, + plugin_config: PluginConfigUpdate::Missing, + columns_config: ColumnConfigUpdate::Missing, + task: None, + presentation: None, + } + } +} + +/// One locked, witnessed, snapshot-consuming render run — the SINGLE lock +/// body shared by every config-driven run (`apply_and_render` &co. via +/// [`render_run`]'s tail, the `restore` family via +/// [`super::restore_and_render`]): eager mount → pre-bind `task` → +/// plugin-swap commit → theme stamp → error guard → [`bind_snapshot`] +/// (gated on a bound `Table`, else `Deferred` — the config is already +/// committed, so the eventual `load()` run binds from it) → bucket updates +/// + materialized `plugin.restore` → [`dispatch_bound`]. +/// +/// A pre-existing session error skips the run for `Internal` origins and +/// fails it for `Public` ones (the caller asked and must hear the failure — +/// I6). Returns the RAW run result; error-reporting policy (`set_run_error` +/// vs. propagate) belongs to the caller. +pub(crate) async fn locked_run( + session: &Session, + renderer: &Renderer, + spec: RunSpec, +) -> ApiResult<()> { + clone!(session, renderer); + renderer + .clone() + .render_task(|guard| async move { + renderer.mount_active_plugin()?; + if let Some(task) = spec.task { + task.await?; + } + + let plugin_swapped = renderer.commit_plugin(spec.plugin_idx)?; + let plugin = renderer.active_plugin().ok_or("No Plugin")?; + renderer.stamp_theme(Some(&plugin)); + if let Some(error) = session.get_error() { + return match spec.origin { + RunOrigin::Public => Err(error), + RunOrigin::Internal => Ok(()), + }; + } + + let (disposition, _pin) = if session.get_table().is_some() { + bind_snapshot(&guard, &session, &renderer).await? + } else { + (BindDisposition::Deferred, None) + }; + + let view_config_snapshot = session.get_view_config().clone(); + let plugin_config_changed = + renderer.update_plugin_config(&view_config_snapshot, spec.plugin_config); + let columns_config_changed = renderer.update_columns_configs( + &view_config_snapshot, + &session, + spec.columns_config, + ); + + let changed = plugin_config_changed || columns_config_changed; + if changed || plugin_swapped { + let plugin_config_snapshot = renderer.get_plugin_config(); + let plugin_update = + JsValue::from_serde_ext(&plugin_config_snapshot).unwrap_or(JsValue::NULL); + let columns_config = renderer + .all_columns_configs_materialized(&view_config_snapshot, &session) + .await; + plugin.restore(&plugin_update, Some(&columns_config))?; + if plugin_config_changed { + renderer.plugin_config_changed.emit(plugin_config_snapshot); + } + } + + if spec + .presentation + .as_ref() + .map(|p| p.is_visible()) + .unwrap_or(true) + { + dispatch_bound(&guard, &renderer, disposition, changed, spec.origin).await?; + } + + Ok(()) + }) + .await +} + +/// [`locked_run`]'s host-internal tail: a failed RUN sets error state (with +/// the reset-reconnect affordance); the committed config is NOT rolled back +/// (I4). Cancellation by a superseding run ("View already deleted") is not +/// a failure. async fn render_run( session: Session, renderer: Renderer, plugin_idx: Option, ) -> ApiResult<()> { - let result = { - clone!(session, renderer); - renderer - .clone() - .render_task(|guard| async move { - // The previous run errored; skip until the error clears. - if session.get_error().is_some() { - return Ok(()); - } - - // Mount eagerly — BEFORE the (possibly slow) view query — so - // the panel's frame is never empty while the query runs. - renderer.mount_active_plugin()?; - let plugin_swapped = renderer.commit_plugin(plugin_idx)?; - let (disposition, _pin) = bind_snapshot(&guard, &session, &renderer).await?; - if plugin_swapped { - // `commit_plugin_idx` already restored the new plugin from - // its raw bucket; re-run with the materialized snapshot so - // any `include: true` schema defaults make it into the - // plugin's state before the first draw. - let view_config_snapshot = session.get_view_config().clone(); - let plugin_token = - wasm_bindgen::JsValue::from_serde_ext(&renderer.get_plugin_config()) - .unwrap_or(wasm_bindgen::JsValue::NULL); - let columns_config = renderer - .all_columns_configs_materialized(&view_config_snapshot, &session) - .await; - renderer - .ensure_plugin_selected()? - .restore(&plugin_token, Some(&columns_config))?; - } - - // `render_run` initiators are host-internal UI flows and - // never touch plugin buckets (`commit_plugin` swaps route - // through the first-paint promotion), so: no state delta, - // internal origin — a reconciled no-op dispatches nothing. - dispatch_bound(&guard, &renderer, disposition, false, RunOrigin::Internal).await - }) - .await + let spec = RunSpec { + plugin_idx, + ..RunSpec::default() }; - // A failed RUN sets error state (with the reset-reconnect affordance); - // the committed config is NOT rolled back (I4). Cancellation by a - // superseding run ("View already deleted") is not a failure. - match result.ignore_view_delete() { + match locked_run(&session, &renderer, spec) + .await + .ignore_view_delete() + { Err(e) => session.set_run_error(e).await, Ok(_) => Ok(()), } } -/// Create a [`Callback`] that re-renders the current commit. Replaces the -/// old `render_callback` (which drew the bound view without validation). -pub fn render_callback(session: &Session, renderer: &Renderer) -> Callback<()> { - clone!(session, renderer); - Callback::from(move |_| { - clone!(renderer, session); - crate::utils::spawn_owned("render-callback", async move { - just_render(&session, &renderer)?.await - }); - }) -} - /// One TRANSACTIONAL activation repaint (see the I5 audit gap in /// `SESSION_CONFIG_COHERENCE_PLAN.md` §4): under the panel's draw lock, /// stamp the `active` class + theme and `plugin.resize()` in ONE dispatch, @@ -276,6 +360,14 @@ pub fn render_callback(session: &Session, renderer: &Renderer) -> Callback<()> { /// debounced/throttled: activation is a one-shot interaction, not a data /// stream, and the throttle timer is a task boundary the browser paints /// across. +/// +/// EXCEPTION: a panel whose `table_updated` redraws were dropped while it +/// was hidden ([`Renderer::take_data_stale`]) HAS changed data — its +/// retained frame is stale, so this activation dispatches one full +/// `plugin.update` (same `View`, fresh data; `draw` stays reserved for new +/// `View`s) instead of the chrome-only repaint. `draw_view` stamps the +/// activation class + theme itself, so the atomicity contract above holds +/// on this branch too. pub async fn activation_render(session: Session, renderer: Renderer) -> ApiResult<()> { let result = { clone!(session, renderer); @@ -283,11 +375,15 @@ pub async fn activation_render(session: Session, renderer: Renderer) -> ApiResul .clone() .render_task(|guard| async move { match session.get_view() { - Some(_) => { + Some(view) => { let _pin = renderer .cached_context() .map(|ctx| renderer.pin_context(&guard, ctx)); - renderer.activation_repaint(&guard).await + if renderer.take_data_stale() { + renderer.update_bound(&guard, &view).await + } else { + renderer.activation_repaint(&guard).await + } }, None => Ok(()), } @@ -298,24 +394,18 @@ pub async fn activation_render(session: Session, renderer: Renderer) -> ApiResul result.ignore_view_delete().map(|_| ()) } -/// Resize from the current `View` and `Plugin`, or run a full render when -/// the plugin has never drawn. The future form — callers that must own -/// completion (invariant I6) join THIS; [`resize_callback`] is its -/// event-listener-leaf wrapper. -pub async fn resize_or_render(session: Session, renderer: Renderer) -> ApiResult<()> { - if !renderer.is_plugin_activated()? { - just_render(&session, &renderer)?.await - } else { - renderer.resize().await - } -} - /// Create a [`Callback`] that resizes from the current `View` and `Plugin`, /// or runs a full render when the plugin has never drawn. pub fn resize_callback(session: &Session, renderer: &Renderer) -> Callback<()> { clone!(session, renderer); Callback::from(move |_| { clone!(renderer, session); - crate::utils::spawn_owned("resize-callback", resize_or_render(session, renderer)); + crate::utils::spawn_owned("resize-callback", async move { + if !renderer.is_plugin_activated()? { + just_render(&session, &renderer)?.await + } else { + renderer.resize().await + } + }); }) } diff --git a/rust/perspective-viewer/src/rust/tasks/presize_panels.rs b/rust/perspective-viewer/src/rust/tasks/presize_panels.rs index 848d1744f7..dcffc25584 100644 --- a/rust/perspective-viewer/src/rust/tasks/presize_panels.rs +++ b/rust/perspective-viewer/src/rust/tasks/presize_panels.rs @@ -16,6 +16,8 @@ //! geometry change renders each plugin at its *target* box before the layout //! commits, so content never lags its container. +use std::future::Future; + use futures::channel::oneshot::channel; use futures::future::join_all; use perspective_js::utils::*; @@ -31,20 +33,57 @@ use crate::workspace::Workspace; /// are skipped; they resize when revealed. `resize()` debounces per renderer. pub async fn resize_visible_panels(workspace: &Workspace) { let panels = workspace - .panel_ids() + .panels() .into_iter() - .filter_map(|id| { - let panel = workspace.panel(&id)?; - let plugin = panel.renderer.active_plugin()?; - let el = plugin.unchecked_ref::(); + .filter(|panel| { // `offset_parent` is `None` for a `display:none` cell. - el.offset_parent().map(|_| panel) + panel + .renderer + .active_plugin() + .and_then(|plugin| { + plugin + .unchecked_ref::() + .offset_parent() + }) + .is_some() }) .collect::>(); join_all(panels.iter().map(|p| p.renderer.resize())).await; } +/// The present closures collected from a presize sweep — the deferred +/// "reveal" half of the staged-presize protocol +/// ([`crate::renderer::Renderer::presize_with_dimensions`]). Call +/// [`Self::reveal`] in the same task as the layout commit the sweep +/// anticipated, so every panel's staged pixels and the geometry change +/// reach the screen in a single paint. The reveal runs through `Drop`, so +/// an error or early-return path between sweep and commit releases every +/// hold instead of stranding the displays — the pairing invariant is +/// carried by the value, not by convention. +#[derive(Default)] +pub struct StagedPresents(Vec); + +impl StagedPresents { + /// Reveal every staged frame now (a semantically-named drop; the + /// closures are lock-free blits, safe in any task). + pub fn reveal(self) {} +} + +impl From> for StagedPresents { + fn from(presents: Vec) -> Self { + Self(presents) + } +} + +impl Drop for StagedPresents { + fn drop(&mut self) { + for f in self.0.drain(..) { + let _ = f.call0(&wasm_bindgen::JsValue::NULL); + } + } +} + /// The `(width, height)` factors each plugin's cell grows by when the settings /// pane collapses. The plugin area (`#main_panel_container`) grows to the full /// `#layout_area` width (the side settings pane is gone) AND to the full @@ -84,7 +123,10 @@ fn settings_close_grow_ratios(elem: &web_sys::HtmlElement) -> (f64, f64) { /// plugin — one clean resize instead of grow-then-resize. The cell grows in /// BOTH width (the side settings pane is gone) and height (the status bar goes /// floating). -pub async fn presize_visible_panels_grown(workspace: &Workspace, elem: &web_sys::HtmlElement) { +pub async fn presize_visible_panels_grown( + workspace: &Workspace, + elem: &web_sys::HtmlElement, +) -> StagedPresents { let (width_ratio, height_ratio) = settings_close_grow_ratios(elem); presize_visible_panels_scaled(workspace, elem, width_ratio, height_ratio).await } @@ -99,14 +141,14 @@ pub async fn presize_visible_panels_open( elem: &web_sys::HtmlElement, delta_w: f64, delta_h: f64, -) { +) -> StagedPresents { let Some(mpc) = shadow_rect(elem, "#main_panel_container") else { - return; + return StagedPresents::default(); }; let (mpc_w0, mpc_h0) = (mpc.width(), mpc.height()); if mpc_w0 <= 0.0 || mpc_h0 <= 0.0 { - return; + return StagedPresents::default(); } let width_ratio = (mpc_w0 - delta_w) / mpc_w0; @@ -122,18 +164,18 @@ pub async fn presize_visible_panels_pane_width( workspace: &Workspace, elem: &web_sys::HtmlElement, pane_width: f64, -) { +) -> StagedPresents { let Some(mpc) = shadow_rect(elem, "#main_panel_container") else { - return; + return StagedPresents::default(); }; let Some(pane) = shadow_rect(elem, "#app_panel > .split-panel-child") else { - return; + return StagedPresents::default(); }; let mpc_w0 = mpc.width(); if mpc_w0 <= 0.0 { - return; + return StagedPresents::default(); } let width_ratio = (mpc_w0 + (pane.width() - pane_width)) / mpc_w0; @@ -148,21 +190,20 @@ async fn presize_visible_panels_scaled( elem: &web_sys::HtmlElement, width_ratio: f64, height_ratio: f64, -) { +) -> StagedPresents { if !width_ratio.is_finite() || !height_ratio.is_finite() || width_ratio <= 0.0 || height_ratio <= 0.0 { - return; + return StagedPresents::default(); } let mut last_chrome: Option<(f64, f64)> = None; let targets = workspace - .panel_ids() + .panels() .into_iter() - .filter_map(|id| { - let panel = workspace.panel(&id)?; + .filter_map(|panel| { let plugin = panel.renderer.active_plugin()?; let el = plugin.unchecked_ref::(); el.unchecked_ref::().offset_parent()?; // skip hidden @@ -170,35 +211,70 @@ async fn presize_visible_panels_scaled( let chrome = elem .shadow_root() .and_then(|r| { - r.query_selector(&format!("regular-layout-frame[name=\"{}\"]", id.as_str())) - .ok() - .flatten() + r.query_selector(&format!( + "regular-layout-frame[name=\"{}\"]", + panel.id.as_str() + )) + .ok() + .flatten() }) .and_then(|frame| plugin_chrome(&frame, el)); last_chrome = chrome.or(last_chrome); - let (cw, ch) = last_chrome.unwrap_or((8.0, 33.0)); + let (cw, ch) = last_chrome.unwrap_or(super::CHROME_FALLBACK); 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)) }) .collect::>(); - let (done, on_done) = channel::<()>(); + staged_presents_with_deadline( + workspace.effects().guard(), + async move { + join_all( + targets + .iter() + .map(|(p, w, h)| p.renderer.presize_with_dimensions(*w, *h)), + ) + .await + .into_iter() + .filter_map(|r| r.ok().flatten()) + .collect() + }, + || {}, + ) + .await +} + +/// Run `work` (a presize sweep producing staged present closures) against +/// the [`super::STAGING_DEADLINE_MS`] deadline. When `work` wins, its +/// presents are returned for the caller to reveal in its commit task. When +/// the deadline wins, an empty [`StagedPresents`] is returned (the caller's +/// commit proceeds); `work` then finishes late — `late` runs its cleanup and +/// the presents are revealed immediately, so a hold can never outlive its +/// prepare. The single consumer makes a double release inexpressible. +pub(crate) async fn staged_presents_with_deadline( + effect: crate::utils::EffectGuard, + work: impl Future> + 'static, + late: impl FnOnce() + 'static, +) -> StagedPresents { + let (done, on_done) = channel::>(); ApiFuture::spawn(async move { - join_all( - targets - .iter() - .map(|(p, w, h)| p.renderer.resize_with_dimensions(*w, *h)), - ) - .await; + let _effect = effect; + let presents = work.await; + if let Err(presents) = done.send(presents) { + late(); + StagedPresents::from(presents).reveal(); + } - let _ = done.send(()); Ok(()) }); - let deadline = crate::utils::set_timeout(500); - let _ = futures::future::select(Box::pin(on_done), Box::pin(deadline)).await; + let deadline = crate::utils::set_timeout(super::STAGING_DEADLINE_MS); + match futures::future::select(Box::pin(on_done), Box::pin(deadline)).await { + futures::future::Either::Left((Ok(presents), _)) => StagedPresents::from(presents), + _ => StagedPresents::default(), + } } /// Measure a shadow-DOM descendant's border-box rect. @@ -246,24 +322,3 @@ pub fn plugin_chrome(frame: &web_sys::Element, plugin: &web_sys::Element) -> Opt let height = frame_box.height() + px("margin-top") + px("margin-bottom") - plugin_box.height(); Some((width.max(0.0), height.max(0.0))) } - -/// The current screen origin of a frame's grid TRACK (its margin box — the -/// coordinate space `real_coordinates` reports), measured live from the frame. -/// `target_track − current_track` is the presize translate delta: the plugin's -/// offset *within* its track is constant across a layout transition, so the -/// track delta is exactly the plugin delta (see `Renderer::presize_with_box`). -pub fn frame_track_origin(frame: &web_sys::Element) -> Option<(f64, f64)> { - let style = web_sys::window()?.get_computed_style(frame).ok()??; - let px = |prop: &str| { - style - .get_property_value(prop) - .ok() - .and_then(|v| v.trim().trim_end_matches("px").parse::().ok()) - .unwrap_or(0.0) - }; - let rect = frame.get_bounding_client_rect(); - Some(( - rect.left() - px("margin-left"), - rect.top() - px("margin-top"), - )) -} diff --git a/rust/perspective-viewer/src/rust/tasks/resize_observer.rs b/rust/perspective-viewer/src/rust/tasks/resize_observer.rs index 361fb54548..7b637ec778 100644 --- a/rust/perspective-viewer/src/rust/tasks/resize_observer.rs +++ b/rust/perspective-viewer/src/rust/tasks/resize_observer.rs @@ -11,239 +11,163 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ use futures::future::join_all; +use perspective_client::clone; use perspective_js::utils::{ApiFuture, ApiResult}; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use web_sys::*; -use yew::prelude::*; +use super::update_theme::seed_default_themes; use crate::components::viewer::{PerspectiveViewer, PerspectiveViewerMsg}; -use crate::js::*; +use crate::js::{ResizeObserver, ResizeObserverEntry}; use crate::presentation::Presentation; use crate::renderer::Renderer; use crate::root::Root; use crate::session::TableLoadState; -use crate::utils::*; use crate::workspace::Workspace; -pub struct ResizeObserverHandle { +struct SizeObserver { elem: HtmlElement, observer: ResizeObserver, _callback: Closure, } -impl ResizeObserverHandle { - pub fn new( - elem: &HtmlElement, - workspace: &Workspace, - presentation: &Presentation, - root: &Root, - ) -> Self { - let on_resize = root - .borrow() - .as_ref() - .unwrap() - .callback(|()| PerspectiveViewerMsg::Resize); +impl SizeObserver { + fn new(elem: &HtmlElement, mut on_change: impl FnMut() + 'static) -> Self { + let mut width = elem.offset_width(); + let mut height = elem.offset_height(); + let target = elem.clone(); + let _callback = Closure::new(move |entries: js_sys::Array| { + let is_visible = target + .offset_parent() + .map(|x| !x.is_null()) + .unwrap_or(false); + for y in entries.iter() { + let entry: ResizeObserverEntry = y.unchecked_into(); + let content = entry.content_rect(); + let content_width = content.width().floor() as i32; + let content_height = content.height().floor() as i32; + let resized = width != content_width || height != content_height; + if resized && is_visible { + on_change(); + } + + width = content_width; + height = content_height; + } + }); - let mut state = ResizeObserverState { - elem: elem.clone(), - workspace: workspace.clone(), - presentation: presentation.clone(), - width: elem.offset_width(), - height: elem.offset_height(), - on_resize, - }; - - let _callback = Closure::new(move |xs: js_sys::Array| state.on_resize(&xs)); let observer = ResizeObserver::new(_callback.as_ref().unchecked_ref::()); observer.observe(elem); Self { elem: elem.clone(), - _callback, observer, + _callback, } } } -impl Drop for ResizeObserverHandle { +impl Drop for SizeObserver { fn drop(&mut self) { self.observer.unobserve(&self.elem); } } -#[derive(Clone)] -struct ResizeObserverState { - elem: HtmlElement, - workspace: Workspace, - presentation: Presentation, - width: i32, - height: i32, - on_resize: Callback<()>, -} +/// The host element's `ResizeObserver`: fans a host box change out to every +/// panel — a resize for drawn panels, a deferred first render for loaded +/// panels whose first draw was discarded while invisible. +pub struct ResizeObserverHandle(#[allow(dead_code)] SizeObserver); + +impl ResizeObserverHandle { + pub fn new( + elem: &HtmlElement, + workspace: &Workspace, + presentation: &Presentation, + root: &Root, + ) -> Self { + let on_resize = root + .borrow() + .as_ref() + .unwrap() + .callback(|()| PerspectiveViewerMsg::Resize); -impl ResizeObserverState { - fn on_resize(&mut self, entries: &js_sys::Array) { - let is_visible = self - .elem - .offset_parent() - .map(|x| !x.is_null()) - .unwrap_or(false); - - for y in entries.iter() { - let entry: ResizeObserverEntry = y.unchecked_into(); - let content = entry.content_rect(); - let content_width = content.width().floor() as i32; - let content_height = content.height().floor() as i32; - let resized = self.width != content_width || self.height != content_height; - if resized && is_visible { - let state = self.clone(); - clone!(self.on_resize); - ApiFuture::spawn_throttled(async move { - let mut plans = Vec::new(); - for id in state.workspace.panel_ids() { - let Some(panel) = state.workspace.panel(&id) else { - continue; - }; - - // Skip only a plugin that HAS DRAWN but is currently - // hidden (an unselected stacked tab → null - // `offset_parent`); it renders when revealed. - // `is_plugin_activated` is an explicit has-drawn flag, - // so an eagerly-MOUNTED but never-drawn plugin (its - // deferred first render was discarded by the - // `is_visible` gate) falls through to the - // `needs_render` branch below, which owes it a draw. - if panel.renderer.is_plugin_activated()? - && let Some(plugin) = panel.renderer.active_plugin() - && plugin - .unchecked_ref::() - .offset_parent() - .is_none() - { - continue; - } - - let needs_render = !panel.renderer.is_plugin_activated()? - && matches!(panel.session.has_table(), Some(TableLoadState::Loaded)); - - plans.push((panel, needs_render)); + clone!(workspace, presentation); + Self(SizeObserver::new(elem, move || { + clone!(workspace, presentation, on_resize); + ApiFuture::spawn_throttled(async move { + let mut plans = Vec::new(); + for panel in workspace.panels() { + // Skip only a plugin that HAS DRAWN but is currently + // hidden (an unselected stacked tab → null + // `offset_parent`); it renders when revealed. + // `is_plugin_activated` is an explicit has-drawn flag, + // so an eagerly-MOUNTED but never-drawn plugin (its + // deferred first render was discarded by the + // `is_visible` gate) falls through to the + // `needs_render` branch below, which owes it a draw. + if panel.renderer.is_plugin_activated()? + && let Some(plugin) = panel.renderer.active_plugin() + && plugin + .unchecked_ref::() + .offset_parent() + .is_none() + { + continue; } - let default_theme = if plans.iter().any(|(_, needs_render)| *needs_render) { - state.presentation.reset_attached(); - // For first mounts below: "stamp before restyle" — the - // plugin captures its `--psp-*` CSS at first draw. - state.presentation.get_default_theme_name().await + let needs_render = !panel.renderer.is_plugin_activated()? + && matches!(panel.session.has_table(), Some(TableLoadState::Loaded)); + + plans.push((panel, needs_render)); + } + + // For first mounts below: "stamp before restyle" — the + // plugin captures its `--psp-*` CSS at first draw. + if plans.iter().any(|(_, needs_render)| *needs_render) { + presentation.reset_attached(); + seed_default_themes(&presentation, &workspace).await; + } + + join_all(plans.into_iter().map(|(panel, needs_render)| async move { + // A concurrent run (e.g. the `load()` that made + // `needs_render` true) is harmless: this run queues + // behind it, reconciles `Unchanged` with no state delta + // and dispatches nothing — never a duplicate draw. + if needs_render { + super::just_render(&panel.session, &panel.renderer)?.await?; } else { - None - }; - - join_all(plans.into_iter().map(|(panel, needs_render)| { - let default_theme = default_theme.clone(); - async move { - // REQUIRED gate (perf, safe under I3): when a run - // already holds this renderer's lock — e.g. the - // `load()` that made `needs_render` true is still - // drawing — it will render this panel; a second - // full run here would be a duplicate - // `plugin.draw` per panel at creation time. - if needs_render && !panel.renderer.is_locked() { - // Seed the default-theme cache; the locked - // run's own draw stamps the effective theme - // from it ("stamp before draw", `draw_view`). - panel.renderer.set_default_theme(default_theme.clone()); - super::just_render(&panel.session, &panel.renderer)?.await?; - } else if !needs_render { - panel.renderer.resize().await?; - } - - ApiResult::<()>::Ok(()) - } - })) - .await - .into_iter() - .collect::>>()?; - - on_resize.emit(()); - Ok(()) - }); - } + panel.renderer.resize().await?; + } - self.width = content_width; - self.height = content_height; - } + ApiResult::<()>::Ok(()) + })) + .await + .into_iter() + .collect::>>()?; + + on_resize.emit(()); + Ok(()) + }); + })) } } /// A per-panel [`ResizeObserver`] bound to a single panel's slotted plugin /// element, resizing only that panel's [`Renderer`] when its box changes. -pub struct PanelResizeObserverHandle { - elem: HtmlElement, - observer: ResizeObserver, - _callback: Closure, -} +pub struct PanelResizeObserverHandle(#[allow(dead_code)] SizeObserver); impl PanelResizeObserverHandle { pub fn new(elem: &HtmlElement, renderer: &Renderer) -> Self { - let mut state = PanelResizeObserverState { - elem: elem.clone(), - renderer: renderer.clone(), - width: elem.offset_width(), - height: elem.offset_height(), - }; - - let _callback = Closure::new(move |xs: js_sys::Array| state.on_resize(&xs)); - let observer = ResizeObserver::new(_callback.as_ref().unchecked_ref::()); - observer.observe(elem); - Self { - elem: elem.clone(), - observer, - _callback, - } - } -} - -impl Drop for PanelResizeObserverHandle { - fn drop(&mut self) { - self.observer.unobserve(&self.elem); - } -} - -#[derive(Clone)] -struct PanelResizeObserverState { - elem: HtmlElement, - renderer: Renderer, - width: i32, - height: i32, -} - -impl PanelResizeObserverState { - fn on_resize(&mut self, entries: &js_sys::Array) { - let is_visible = self - .elem - .offset_parent() - .map(|x| !x.is_null()) - .unwrap_or(false); - - for y in entries.iter() { - let entry: ResizeObserverEntry = y.unchecked_into(); - let content = entry.content_rect(); - let content_width = content.width().floor() as i32; - let content_height = content.height().floor() as i32; - let resized = self.width != content_width || self.height != content_height; - if resized && is_visible { - let renderer = self.renderer.clone(); - ApiFuture::spawn_throttled(async move { - if renderer.is_plugin_activated()? { - renderer.resize().await?; - } - - Ok(()) - }); - } - - self.width = content_width; - self.height = content_height; - } + clone!(renderer); + Self(SizeObserver::new(elem, move || { + clone!(renderer); + ApiFuture::spawn_throttled(async move { + if renderer.is_plugin_activated()? { + renderer.resize().await?; + } + + Ok(()) + }); + })) } } 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 75b232f2ca..1f1314a06a 100644 --- a/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs +++ b/rust/perspective-viewer/src/rust/tasks/restore_and_render.rs @@ -17,7 +17,7 @@ use futures::Future; use perspective_client::clone; -use super::pipeline::{RunOrigin, bind_snapshot, dispatch_bound}; +use super::pipeline::{RunOrigin, RunSpec, locked_run}; use crate::config::{OptionalUpdate, ViewerConfigUpdate}; use crate::presentation::Presentation; use crate::renderer::Renderer; @@ -33,6 +33,13 @@ use crate::*; /// `Unchanged` reconcile still repaints via `update`); an `Internal` /// restore that reconciles `Unchanged` and changes no plugin state /// dispatches nothing. +/// +/// This function owns the PRE-LOCK prologue only (element-level settings / +/// title / host-theme mirror, plugin resolution, the synchronous config +/// commit and spinner token); the run itself is +/// [`locked_run`] with the caller's `task` awaited inside the lock. The +/// `update`'s `table` field is NOT applied here — table binding is the +/// `task`'s job (see `restore_panel` / `table_lifecycle`). pub fn restore_and_render( session: &Session, renderer: &Renderer, @@ -119,7 +126,7 @@ pub fn restore_and_render( session.commit_view_config(view_config)?; // Spinner accounting (RAII): held to the end of this restore — - // INCLUDING the deferred-draw exit below (no table yet → no + // 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(); @@ -129,99 +136,16 @@ pub fn restore_and_render( // 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); - let run_result = { - clone!(session, renderer, presentation); - renderer - .clone() - .render_task(|guard| async move { - // Mount eagerly — BEFORE the (possibly slow) `task` - // completion gate — so the panel's frame is never empty - // while it resolves. Idempotent; pre-swap this is the - // outgoing plugin, exactly as before. - renderer.mount_active_plugin()?; - task.await?; - let plugin_swapped = renderer.commit_plugin(plugin_idx)?; - // `commit_plugin` above already selected, so the pure query - // returns it (no need to re-select via - // `ensure_plugin_selected`). - let plugin = renderer.active_plugin().ok_or("No Plugin")?; - - // "Stamp before restyle": the plugin captures its `--psp-*` - // CSS at first `draw()`, so stamp the effective `theme` attr - // NOW, inside the locked run, before any plugin style read — - // this covers the `plugin.restore` below even when the run - // draws nothing (hidden panel / no table yet); the draw - // paths stamp for themselves (`draw_view`). - renderer.stamp_theme(Some(&plugin)); - - // The previous call which acquired the lock errored, so skip - // this render - if let Some(error) = session.get_error() { - return Err(error); - } - - // Snapshot + validate + bind BEFORE applying - // columns_config / plugin_config updates, so the - // strip-on-write and materialize passes see fresh - // `expression_schema` and `view_schema`. - // - // Guard on a bound `Table`: a `restore()` with no table of - // its own can land before a `load()` that sets it. The - // config is already committed above, so defer the draw: the - // eventual `load()` run binds the view from this commit. - let (disposition, _pin) = if session.get_table().is_some() { - bind_snapshot(&guard, &session, &renderer).await? - } else { - (crate::session::BindDisposition::Deferred, None) - }; - - // Apply incoming updates into the now-active plugin's - // bucket on `Renderer`. Per-plugin storage means no - // schema filter is needed before restore — foreign keys - // cannot appear in the bucket by construction. - let view_config_snapshot = session.get_view_config().clone(); - let plugin_config_changed = - renderer.update_plugin_config(&view_config_snapshot, plugin_config); - let columns_config_changed = renderer.update_columns_configs( - &view_config_snapshot, - &session, - columns_config, - ); - let changed = plugin_config_changed || columns_config_changed; - - // Force a materialized restore when the plugin just - // swapped — `commit_plugin_idx` already restored from the - // raw bucket, but the materialized restore is needed for - // schema-revealed `include: true` defaults to reach the - // plugin before its first draw. - if changed || plugin_swapped { - let plugin_config_snapshot = renderer.get_plugin_config(); - let plugin_update = - wasm_bindgen::JsValue::from_serde_ext(&plugin_config_snapshot).unwrap(); - let columns_config = renderer - .all_columns_configs_materialized(&view_config_snapshot, &session) - .await; - plugin.restore(&plugin_update, Some(&columns_config))?; - if plugin_config_changed { - renderer.plugin_config_changed.emit(plugin_config_snapshot); - } - } - - if presentation.is_visible() { - // `plugin.draw` iff the bind REBUILT (or this - // plugin owes its first paint); `update` iff a - // source changed — the `Adopted` config delta, the - // `changed` plugin-config restore above, or an - // explicit `Public` API request; else nothing. - dispatch_bound(&guard, &renderer, disposition, changed, origin).await?; - } - - Ok(()) - }) - .await - }; + locked_run(&session, &renderer, RunSpec { + origin, + plugin_idx, + plugin_config, + columns_config, + task: Some(Box::pin(task)), + presentation: Some(presentation.clone()), + }) + .await?; - run_result?; Ok(()) }) } diff --git a/rust/perspective-viewer/src/rust/tasks/restore_panel.rs b/rust/perspective-viewer/src/rust/tasks/restore_panel.rs index b079646ba3..3bd58f471a 100644 --- a/rust/perspective-viewer/src/rust/tasks/restore_panel.rs +++ b/rust/perspective-viewer/src/rust/tasks/restore_panel.rs @@ -21,6 +21,7 @@ use crate::renderer::Renderer; use crate::root::Root; use crate::session::{ResetOptions, Session}; use crate::tasks::*; +use crate::workspace::Workspace; use crate::*; /// How a [`restore_panel`] call reached the pipeline — the only two genuine @@ -30,6 +31,24 @@ pub(crate) enum RestoreMode { Fresh, } +pub(crate) async fn bind_table_task( + session: &Session, + workspace: &Workspace, + name: String, +) -> ApiResult<()> { + let current = session.get_client(); + if let Some(client) = workspace + .resolve_client_for_table(&name, current.as_ref()) + .await + { + session.set_client(client); + } + + session.set_table(name).await?; + session.commit_table_defaults(); + Ok(()) +} + /// 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). @@ -37,6 +56,7 @@ pub(crate) async fn restore_panel( session: &Session, renderer: &Renderer, presentation: &Presentation, + workspace: &Workspace, root: Option<&Root>, mode: RestoreMode, mut update: ViewerConfigUpdate, @@ -44,16 +64,8 @@ pub(crate) async fn restore_panel( let active = matches!(mode, RestoreMode::Existing { active: true }); let fresh = matches!(mode, RestoreMode::Fresh); match &update.theme { - OptionalUpdate::Update(theme) => { - renderer.set_theme(Some(theme.clone())); - renderer.stamp_theme(None); - }, - OptionalUpdate::SetDefault => { - renderer.set_theme(None); - if renderer.default_theme().is_some() { - renderer.stamp_theme(None); - } - }, + OptionalUpdate::Update(theme) => renderer.set_theme_stamped(Some(theme.clone())), + OptionalUpdate::SetDefault => renderer.set_theme_stamped(None), OptionalUpdate::Missing => {}, } @@ -100,15 +112,14 @@ pub(crate) async fn restore_panel( RunOrigin::Public, update.clone(), { - clone!(session, update.table); + clone!(session, update.table, workspace); async move { if let OptionalUpdate::Update(name) = table { if let Some(reset) = reset { reset.await?; } - session.set_table(name).await?; - session.commit_table_defaults(); + bind_table_task(&session, &workspace, name).await?; } receiver.await.unwrap_or_log(); diff --git a/rust/perspective-viewer/src/rust/tasks/send_column_config.rs b/rust/perspective-viewer/src/rust/tasks/send_column_config.rs index ec4c2ad626..69c6c21948 100644 --- a/rust/perspective-viewer/src/rust/tasks/send_column_config.rs +++ b/rust/perspective-viewer/src/rust/tasks/send_column_config.rs @@ -47,7 +47,10 @@ pub fn send_column_config( .ensure_plugin_selected()? .restore(&plugin_token, Some(&columns_configs))?; - renderer.update(session.get_view()).await?; + clone!(session); + renderer + .update_lazy(async move { Ok(session.get_view()) }) + .await?; renderer.column_style_changed.emit(columns_configs); Ok(()) }) diff --git a/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs b/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs index 69a512dd79..e5d0cfe52a 100644 --- a/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs +++ b/rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs @@ -42,7 +42,10 @@ pub fn send_plugin_config(session: &Session, renderer: &Renderer, update: Column renderer .ensure_plugin_selected()? .restore(&plugin_token, Some(&columns_configs))?; - renderer.update(session.get_view()).await?; + clone!(session); + renderer + .update_lazy(async move { Ok(session.get_view()) }) + .await?; renderer.plugin_config_changed.emit(plugin_config); } diff --git a/rust/perspective-viewer/src/rust/tasks/set_edit_mode.rs b/rust/perspective-viewer/src/rust/tasks/set_edit_mode.rs index 77fd244c9f..e5bb411a55 100644 --- a/rust/perspective-viewer/src/rust/tasks/set_edit_mode.rs +++ b/rust/perspective-viewer/src/rust/tasks/set_edit_mode.rs @@ -47,7 +47,10 @@ pub fn set_edit_mode(session: &Session, renderer: &Renderer, mode: &str) { renderer .ensure_plugin_selected()? .restore(&plugin_token, Some(&columns_configs))?; - renderer.update(session.get_view()).await?; + clone!(session); + renderer + .update_lazy(async move { Ok(session.get_view()) }) + .await?; renderer.plugin_config_changed.emit(plugin_config); } diff --git a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs index 237c358be6..ddcb408994 100644 --- a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs +++ b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs @@ -10,8 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -#![allow(non_snake_case)] - use std::collections::HashMap; use wasm_bindgen::prelude::*; @@ -26,9 +24,6 @@ use crate::*; #[wasm_bindgen] extern "C" { - #[wasm_bindgen(typescript_type = "Promise")] - pub type JsViewerConfigPromise; - #[wasm_bindgen(typescript_type = "ViewerConfigUpdate")] pub type JsViewerConfigUpdate; } @@ -75,6 +70,7 @@ pub fn sync_update_panels( None, config, None, + Placement::Placed, ); if active.as_deref() == Some(saved_id.as_str()) { @@ -94,27 +90,31 @@ pub fn sync_update_panels( None, ViewerConfigUpdate::default(), None, + Placement::Placed, ); fallback_fresh = Some(fresh.as_str().to_owned()); contents.push((fresh, session, renderer, config)); } - // Phase 2 — remove + eject the pre-existing panels. let mut eject_tasks = Vec::new(); + if let Some(panel) = this.workspace.take_reserved() { + eject_tasks.push(eject_panel(panel)); + } + for old in old_ids { if let Some(panel) = this.workspace.remove_panel(&old) { eject_tasks.push(eject_panel(panel)); } } - // Phase 3 — stage the remapped layout tree on the Workspace + // Phase 2 — stage the remapped layout tree on the Workspace if let Some(layout) = layout { this.workspace .set_pending_layout(layout.remap(&|name| id_map.get(name).cloned())); } - // Phase 4 — the single visible commit + // Phase 3 — the single visible commit if let Some(saved) = &active && active_fresh.is_none() { diff --git a/rust/perspective-viewer/src/rust/tasks/table_lifecycle.rs b/rust/perspective-viewer/src/rust/tasks/table_lifecycle.rs new file mode 100644 index 0000000000..d659dc3af0 --- /dev/null +++ b/rust/perspective-viewer/src/rust/tasks/table_lifecycle.rs @@ -0,0 +1,158 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::cell::RefCell; +use std::rc::Rc; + +use perspective_client::utils::PerspectiveResultExt; +use perspective_client::{Client, clone}; +use perspective_js::utils::{ApiFuture, ApiResult, LocalPollLoop}; +use wasm_bindgen::JsValue; + +use super::pipeline::RunOrigin; +use super::restore_and_render::restore_and_render; +use crate::config::ViewerConfigUpdate; +use crate::presentation::Presentation; +use crate::utils::{AddListener, Subscription}; +use crate::workspace::{Panel, Workspace}; + +/// The per-client hosted-tables-update subscriptions this element holds, for +/// removal at `delete()`. +pub(crate) type HostedTableSubs = Rc>>; + +/// Wire reactive table binding for an element: subscribe every registered +/// [`Client`]'s hosted-tables updates (current and future registrations) to +/// [`sweep_table_bindings`]. Returns the registration listener (owned for the +/// element's lifetime) and the accumulated client subscriptions (removed at +/// element `delete()`). +pub(crate) fn wire_table_lifecycle( + workspace: &Workspace, + presentation: &Presentation, +) -> (Subscription, HostedTableSubs) { + let subs: HostedTableSubs = Rc::default(); + let sub = workspace.client_registered().add_listener({ + clone!(workspace, presentation, subs); + move |client: Client| { + clone!(workspace, presentation, subs); + ApiFuture::spawn(async move { + let poll_loop = LocalPollLoop::new({ + clone!(workspace, presentation); + move |()| { + clone!(workspace, presentation); + ApiFuture::spawn(async move { + sweep_table_bindings(&workspace, &presentation).await + }); + + Ok(JsValue::UNDEFINED) + } + }); + + let id = client + .on_hosted_tables_update(move || { + let poll_loop = poll_loop.clone(); + async move { + poll_loop.poll(()).await; + } + }) + .await?; + + subs.borrow_mut().push((client, id)); + + // A table created between this client's registration and the + // subscription above fired no callback — sweep NOW, after + // subscribing, so no creation can be missed. + sweep_table_bindings(&workspace, &presentation).await + }) + } + }); + + (sub, subs) +} + +/// One reconciliation pass over every panel's table binding vs. the loaded +/// clients' hosted sets: releases first (a delete and a re-create in the same +/// update settle as suspend-then-rebind), then completes pending binds. +/// Per-panel failures are logged, never aborting the sweep. +pub(crate) async fn sweep_table_bindings( + workspace: &Workspace, + presentation: &Presentation, +) -> ApiResult<()> { + for panel in workspace.panels() { + if let Some(name) = panel.session.get_table().map(|t| t.get_name().to_owned()) + && let Some(client) = panel.session.get_client() + && let Ok(hosted) = client.get_hosted_table_names().await + && !hosted.iter().any(|n| n == &name) + { + suspend_panel(&panel).await.unwrap_or_log(); + } + } + + for panel in workspace.panels() { + if panel.session.pending_table().is_some() { + bind_pending(&panel, workspace, presentation) + .await + .unwrap_or_log(); + } + } + + Ok(()) +} + +/// Suspend one panel's binding under its draw lock (see +/// [`Session::suspend_table`]) and blank its display. The suspend re-derives +/// the bound name inside the lock — a panel that rebound or ejected since the +/// sweep's observation suspends its CURRENT state or no-ops. +async fn suspend_panel(panel: &Panel) -> ApiResult<()> { + clone!(panel.session, panel.renderer); + renderer + .clone() + .render_task(|_guard| async move { + if let Some(reset) = session.suspend_table() { + reset.await?; + if let Some(plugin) = renderer.active_plugin() { + plugin.clear().await?; + } + } + + Ok(()) + }) + .await +} + +/// Complete a PENDING panel's bind through the shared restore pipeline: the +/// committed view config is untouched, the pending name re-derived inside the +/// locked run (capture-free), the client federated exactly as a `restore` +/// would. A name still un-hosted (the sweep raced a delete) simply re-pends. +async fn bind_pending( + panel: &Panel, + workspace: &Workspace, + presentation: &Presentation, +) -> ApiResult<()> { + restore_and_render( + &panel.session, + &panel.renderer, + presentation, + RunOrigin::Internal, + ViewerConfigUpdate::default(), + { + clone!(panel.session, workspace); + async move { + let Some(name) = session.pending_table() else { + return Ok(()); + }; + + super::restore_panel::bind_table_task(&session, &workspace, name).await + } + }, + ) + .await +} diff --git a/rust/perspective-viewer/src/rust/tasks/update_theme.rs b/rust/perspective-viewer/src/rust/tasks/update_theme.rs index 84d02e0432..223ccc0ac6 100644 --- a/rust/perspective-viewer/src/rust/tasks/update_theme.rs +++ b/rust/perspective-viewer/src/rust/tasks/update_theme.rs @@ -10,15 +10,31 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -//! Theme reset / set task. Replaces the inlined StatusBar callback that -//! reached into `Session::get_view()` for the post-update restyle. +//! Theme reset / set task. +use futures::future::join_all; use perspective_js::utils::*; 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()); + } + + default +} + /// Apply a theme change and restyle the affected panel's view. /// /// `theme = None` resets to the first available theme; `theme = Some(name)` @@ -47,18 +63,7 @@ pub fn update_theme( // 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 - // diverges from the host. Sync `RefCell` write, done before the spawn so - // the re-render `set_theme_name` triggers observes the new value. - renderer.set_theme(theme.clone()); - - // Stamp-with-commit: flip the picked panel's plugin `theme` attribute - // synchronously with the own-theme record (a named pick needs no - // registry; a reset stamps sync only from a warm default cache — a - // cold one would stamp attribute-removal). The `restyle_all` in the - // spawned tail below still owns the expensive var re-read + redraw. - if theme.is_some() || renderer.default_theme().is_some() { - renderer.stamp_theme(None); - } + renderer.set_theme_stamped(theme.clone()); let presentation = presentation.clone(); let workspace = workspace.clone(); @@ -72,30 +77,18 @@ pub fn update_theme( }, } - // Re-seed every renderer's default cache from the (now-initialized) - // registry before consulting `needs_restyle`, whose effective-theme - // side reads it — a reset (`theme = None`) on a cold cache would - // otherwise compare against `None` and restyle to attribute-removal. - 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()); - - // State-keyed (captured-theme vs. effective — see - // `Renderer::needs_restyle`): only the picked panel's effective - // theme changed in this task, so only it can read stale — and a - // pick that lands on the value the plugin already captured (or - // a panel that has yet to first-paint) restyles nothing. + seed_default_themes(&presentation, &workspace).await; + let panels = workspace.panels(); + join_all(panels.iter().map(|panel| async move { if panel.renderer.needs_restyle() { - // `restyle_all` resolves the bound `View` itself, INSIDE the - // draw lock (no-op when nothing is bound) — a handle captured - // here would race an in-flight rebuild. panel.renderer.restyle_all().await?; } - } + + ApiResult::<()>::Ok(()) + })) + .await + .into_iter() + .collect::>>()?; Ok(()) }); diff --git a/rust/perspective-viewer/src/rust/utils/debounce.rs b/rust/perspective-viewer/src/rust/utils/debounce.rs index d868016774..f2152becb2 100644 --- a/rust/perspective-viewer/src/rust/utils/debounce.rs +++ b/rust/perspective-viewer/src/rust/utils/debounce.rs @@ -17,6 +17,8 @@ use std::rc::Rc; use async_lock::Mutex; use perspective_js::utils::ApiResult; +use super::pubsub::PubSub; + /// Proof that the bearer is executing inside a [`DebounceMutex`]-locked task. /// /// Every plugin-dispatching function (`draw_view`, `activate_plugin`, the @@ -36,6 +38,9 @@ struct DebounceMutexData { id: Cell, held: Cell, mutex: Mutex, + parked: Cell, + settled_id: Cell, + on_settle: PubSub<()>, } /// Clears the `held` flag on drop, so cancellation of a locked task (its @@ -56,10 +61,42 @@ impl Drop for HeldFlag<'_> { } } +/// RAII for the parked debounce runner: clears `parked` and broadcasts the +/// runner's settle on drop. Running through `Drop` (not a happy-path call) +/// makes the release unconditional — a runner cancelled mid-park or +/// mid-run still frees the parked slot and releases its coalesced waiters, +/// so they can never be stranded awaiting a settle that no task will emit. +struct SettleGuard<'a> { + data: &'a DebounceMutexData, + next: u64, +} + +impl<'a> SettleGuard<'a> { + fn park(data: &'a DebounceMutexData, next: u64) -> Self { + data.parked.set(true); + Self { data, next } + } +} + +impl Drop for SettleGuard<'_> { + fn drop(&mut self) { + self.data.parked.set(false); + if self.data.settled_id.get() < self.next { + self.data.settled_id.set(self.next); + } + + self.data.on_settle.emit(()); + } +} + /// An async `Mutex` type specialized for Perspective's rendering, which -/// debounces calls in addition to providing exclusivity. Calling `debounce` -/// with a _cancellable_ [`Future`] will resolve only after at least one -/// _complete_ evaluation of a call awaiting the lock. +/// debounces calls in addition to providing exclusivity. Calling +/// [`Self::debounce`] resolves only after at least one complete evaluation +/// of a call that began no earlier than this one — either by running the +/// caller's own future, or by coalescing onto the single parked trailing +/// runner. At most TWO debounce evaluations are ever outstanding (one +/// running, one parked); every additional concurrent caller resolves +/// without queueing on the lock. #[derive(Clone, Default)] pub struct DebounceMutex(Rc); @@ -75,13 +112,7 @@ impl DebounceMutex { /// Lock like a normal `Mutex`. pub async fn lock(&self, f: impl Future) -> T { - let mut last = self.0.mutex.lock().await; - let next = self.0.id.get(); - let held = HeldFlag::set(&self.0.held); - let result = f.await; - drop(held); - *last = next; - result + self.lock_with(|_| f).await } /// Lock, passing a [`RenderGuard`] witness into the task builder. The @@ -103,34 +134,26 @@ impl DebounceMutex { /// Lock and also debounce `f`, which should be cancellable. pub async fn debounce(&self, f: impl Future>) -> ApiResult<()> { - let next = self.0.id.get() + 1; - let mut last = self.0.mutex.lock().await; - if *last < next { - let next = self.0.id.get() + 1; - self.0.id.set(next); - let held = HeldFlag::set(&self.0.held); - let result = f.await; - drop(held); - if result.is_ok() { - *last = next; - } - - result - } else { - Ok(()) - } + self.debounce_with(|_| f).await } /// [`Self::debounce`] with a [`RenderGuard`] witness (see - /// [`Self::lock_with`]). - pub async fn debounce_with(&self, f: F) -> ApiResult<()> + pub async fn debounce_with(&self, f: F) -> ApiResult where + T: Default, F: FnOnce(RenderGuard) -> Fut, - Fut: Future>, + Fut: Future>, { let next = self.0.id.get() + 1; + if self.0.parked.get() { + self.await_settled(next).await; + return Ok(T::default()); + } + + let settle = SettleGuard::park(&self.0, next); let mut last = self.0.mutex.lock().await; - if *last < next { + self.0.parked.set(false); + let result = if *last < next { let next = self.0.id.get() + 1; self.0.id.set(next); let held = HeldFlag::set(&self.0.held); @@ -142,7 +165,19 @@ impl DebounceMutex { result } else { - Ok(()) + Ok(T::default()) + }; + + drop(last); + drop(settle); + result + } + + async fn await_settled(&self, next: u64) { + while self.0.settled_id.get() < next { + if self.0.on_settle.read_next().await.is_err() { + break; + } } } } diff --git a/rust/perspective-viewer/src/rust/utils/mod.rs b/rust/perspective-viewer/src/rust/utils/mod.rs index 9e1a699475..4ab5fce168 100644 --- a/rust/perspective-viewer/src/rust/utils/mod.rs +++ b/rust/perspective-viewer/src/rust/utils/mod.rs @@ -24,6 +24,7 @@ mod debounce; mod hooks; mod modal_position; mod number_format; +mod pending_effects; mod ptr_eq_rc; mod pubsub; mod spawn; @@ -40,6 +41,7 @@ pub use debounce::*; pub use hooks::*; pub use modal_position::*; pub use number_format::*; +pub use pending_effects::*; pub use perspective_client::clone; pub use ptr_eq_rc::*; pub use pubsub::*; diff --git a/rust/perspective-viewer/src/rust/utils/pending_effects.rs b/rust/perspective-viewer/src/rust/utils/pending_effects.rs new file mode 100644 index 0000000000..cedcc7b661 --- /dev/null +++ b/rust/perspective-viewer/src/rust/utils/pending_effects.rs @@ -0,0 +1,62 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::cell::Cell; +use std::rc::Rc; + +use super::pubsub::PubSub; + +/// The element-scoped ledger of in-flight EFFECTS: every effectful public +/// method (and the internal flows they schedule, e.g. the `before-resize` +/// presize) registers an [`EffectGuard`] for the duration of its async +/// work, whether or not the caller awaited it. `flush()` resolves only +/// after the ledger drains, so "called before `flush`" implies "applied +/// before `flush` resolves". +#[derive(Clone, Default)] +pub struct EffectLedger(Rc); + +#[derive(Default)] +struct EffectLedgerData { + count: Cell, + on_drain: PubSub<()>, +} + +pub struct EffectGuard(EffectLedger); + +impl EffectLedger { + pub fn guard(&self) -> EffectGuard { + self.0.count.set(self.0.count.get() + 1); + EffectGuard(self.clone()) + } + + pub fn is_empty(&self) -> bool { + self.0.count.get() == 0 + } + + pub async fn settle(&self) { + while self.0.count.get() > 0 { + if self.0.on_drain.read_next().await.is_err() { + break; + } + } + } +} + +impl Drop for EffectGuard { + fn drop(&mut self) { + let data = &self.0.0; + data.count.set(data.count.get() - 1); + if data.count.get() == 0 { + data.on_drain.emit(()); + } + } +} diff --git a/rust/perspective-viewer/src/rust/workspace.rs b/rust/perspective-viewer/src/rust/workspace.rs index 44692aa848..60c36cde2d 100644 --- a/rust/perspective-viewer/src/rust/workspace.rs +++ b/rust/perspective-viewer/src/rust/workspace.rs @@ -21,7 +21,7 @@ use perspective_client::config::Filter; use crate::renderer::Renderer; use crate::session::Session; -use crate::utils::{PubSub, Subscription}; +use crate::utils::{EffectLedger, PubSub, Subscription}; /// A unique identifier for a [`Panel`] within a [`Workspace`]. #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -196,81 +196,87 @@ impl Panel { #[derive(Clone)] pub struct Workspace(Rc>); +impl PartialEq for Workspace { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } +} + struct WorkspaceData { /// Panels in insertion order. panels: Vec, - /// The currently active/selected panel. Always refers to a live panel in - /// `panels`. - active: PanelId, + /// The currently active/selected panel (a live panel in `panels`). + active: Option, - /// The first [`Client`] loaded via the element's `load()`, used as the - /// default for panels which don't bind a `Table` from another client. - /// First-wins (see [`Workspace::set_default_client`]). + /// The first [`Client`] loaded via the element's `load()`. default_client: Option, - /// Every [`Client`] ever loaded into this element (registration order, - /// deduped by client name — globally unique). Feeds "all loaded clients" - /// consumers (the context menu's New sub-menu) and client-by-name - /// resolution; see [`Workspace::register_client`]. + /// Every [`Client`] ever loaded into this element (registration order. clients: Vec, + /// 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>, + /// Monotonic counter backing [`Workspace::generate_id`]. next_id: usize, - /// Panels designated as master/detail filter sources: a master's - /// selection or click applies a derived filter to the other (detail) - /// panels. Toggled via the context menu; persisted (as ids) in the - /// whole-element config. + /// Panels designated as master/detail filter sources. masters: HashSet, - /// The element-level global filters (fed by master-panel selections), - /// applied as a transient overlay to every non-master panel's view — see - /// `tasks::apply_global_filters`. Never persisted per-panel. One - /// REPLACEABLE contribution per master (plus a restored bucket), so - /// bucket ownership both implements replace-semantics and tells - /// `tasks::clear_master_selections` whose selection visual to clear. + /// The element-level global filters. filters: GlobalFilterSet, - - /// Fires after any change to `global_filters`, so observers (the root - /// component's render snapshot) can refresh. `Rc`-wrapped so a handle can - /// be taken out of a borrow (to emit outside it) and shared with - /// subscribers. filters_changed: Rc>, - /// A layout tree staged by whole-element `restore` for `MainPanel` to - /// apply (via `restoreSync`) at its next `rendered` pass, BEFORE the - /// insert reconcile — so restored panels mount directly at their saved - /// positions instead of transiting the synthetic equal-split inserts. - /// Taken (consumed) by the applier; regular-layout stays a slave view - /// synced from this model exactly once per restore. + /// A layout tree staged by whole-element `restore`. 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`]. + effects: EffectLedger, +} + +impl Default for Workspace { + fn default() -> Self { + Self::new() + } } impl Workspace { - /// Create a `Workspace` seeded with a single active [`Panel`] built from - /// the supplied engine handles. `subs` are the seed panel's owned - /// subscriptions (redraw + custom-event fanout; see [`Panel`]); the caller - /// wires them so this module stays clear of the render/event plumbing. - pub fn new(session: Session, renderer: Renderer, subs: Vec) -> Self { - let id = PanelId("PERSPECTIVE_GENERATED_ID_0".to_owned()); - renderer.set_slot_name(id.as_str()); - renderer.set_active_flag(true); - renderer.set_solo_flag(true); - let panel = Panel::new(id.clone(), session, renderer, subs); + /// Create an EMPTY `Workspace`. + pub fn new() -> Self { Self(Rc::new(RefCell::new(WorkspaceData { - panels: vec![panel], - active: id, + panels: Vec::new(), + active: None, default_client: None, clients: Vec::new(), - next_id: 1, + client_registered: Rc::new(PubSub::default()), + next_id: 0, masters: HashSet::new(), filters: GlobalFilterSet::default(), filters_changed: Rc::new(PubSub::default()), pending_layout: None, + staged: HashSet::new(), + staged_changed: Rc::new(PubSub::default()), + reserved: None, + effects: EffectLedger::default(), }))) } + /// The element's in-flight effect ledger (see [`EffectLedger`]). + pub fn effects(&self) -> EffectLedger { + self.0.borrow().effects.clone() + } + /// Stage a layout tree for `MainPanel` to apply at its next `rendered` /// pass (see `WorkspaceData::pending_layout`). pub fn set_pending_layout(&self, layout: crate::js::Layout) { @@ -282,6 +288,46 @@ impl Workspace { self.0.borrow_mut().pending_layout.take() } + /// A handle to the `staged_changed` PubSub (see + /// [`WorkspaceData::staged_changed`]). + pub fn staged_changed(&self) -> Rc> { + 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) = { + let mut data = self.0.borrow_mut(); + (data.staged.remove(id), data.staged_changed.clone()) + }; + + if removed { + pubsub.emit(()); + } + + removed + } + + /// 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) + } + /// The EFFECTIVE element-level global filters (master/detail /// cross-filter): the restored bucket then each master's contribution, /// deduped, as a snapshot clone. @@ -388,29 +434,27 @@ impl Workspace { PanelId(format!("PERSPECTIVE_GENERATED_ID_{n}")) } - /// The id of the active panel. - pub fn active_id(&self) -> PanelId { + /// The id of the active panel, or `None` with zero panels. + pub fn active_id(&self) -> Option { self.0.borrow().active.clone() } - /// The active [`Panel`] (clone; shares engine state). - pub fn active_panel(&self) -> Panel { + /// The active [`Panel`] (clone; shares engine state), or `None` with zero + /// panels. + pub fn active_panel(&self) -> Option { let data = self.0.borrow(); - data.panels - .iter() - .find(|p| p.id == data.active) - .cloned() - .expect("Workspace has no active panel") + let active = data.active.as_ref()?; + data.panels.iter().find(|p| &p.id == active).cloned() } - /// The active panel's [`Session`]. - pub fn active_session(&self) -> Session { - self.active_panel().session + /// The active panel's [`Session`], or `None` with zero panels. + pub fn active_session(&self) -> Option { + self.active_panel().map(|p| p.session) } - /// The active panel's [`Renderer`]. - pub fn active_renderer(&self) -> Renderer { - self.active_panel().renderer + /// The active panel's [`Renderer`], or `None` with zero panels. + pub fn active_renderer(&self) -> Option { + self.active_panel().map(|p| p.renderer) } /// Look up a [`Panel`] by id. @@ -423,10 +467,28 @@ impl Workspace { pub fn panel_or_active(&self, id: Option<&PanelId>) -> Option { match id { Some(id) => self.panel(id), - None => Some(self.active_panel()), + None => self.active_panel(), } } + /// Snapshot of all [`Panel`]s, in insertion order — the canonical + /// 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() + } + + /// 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 + .iter() + .filter(|p| !data.staged.contains(&p.id)) + .count() + } + /// All panel ids, in insertion order. pub fn panel_ids(&self) -> Vec { self.0 @@ -437,25 +499,62 @@ impl Workspace { .collect() } - /// The number of panels (always ≥1). + /// The number of panels (may be `0` — an empty stage). pub fn len(&self) -> usize { self.0.borrow().panels.len() } - /// Always `false` — a `Workspace` always has at least one panel. Provided - /// for API completeness / clippy. + /// Whether the element has zero panels. pub fn is_empty(&self) -> bool { self.0.borrow().panels.is_empty() } - /// Append a [`Panel`]. + /// 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) { let mut data = self.0.borrow_mut(); - panel.renderer.set_active_flag(panel.id == data.active); + if data.active.is_none() { + data.active = Some(panel.id.clone()); + } + + panel + .renderer + .set_active_flag(data.active.as_ref() == Some(&panel.id)); data.panels.push(panel); Self::sync_solo_flags(&data); } + /// Hold `panel` in the reservation slot (see [`WorkspaceData::reserved`]): + /// NOT placed, invisible to every placed-panel consumer, awaiting a + /// pending `load()`'s payload classification or an interim claimant. + pub fn reserve_panel(&self, panel: Panel) { + self.0.borrow_mut().reserved = Some(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() + } + + /// PLACE 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()); + 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. + pub fn take_reserved(&self) -> Option { + self.0.borrow_mut().reserved.take() + } + /// Sync every panel renderer's solo (lone-panel) flag with the current /// panel count — called from each count-changing mutation site (data /// only; the `single`/`multi` CSS classes land inside each panel's next @@ -473,24 +572,39 @@ impl Workspace { /// replacement) drops the panel's master role and its global-filter /// contribution here, so neither can outlive the panel. pub fn remove_panel(&self, id: &PanelId) -> Option { - let (removed, changed, pubsub) = { + let (removed, changed, pubsub, staged_removed, 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)); - Self::sync_solo_flags(&data); - (removed, changed, data.filters_changed.clone()) + if data.active.as_ref() == Some(id) { + data.active = None; + } + + Self::sync_solo_flags(&data); + ( + removed, + changed, + data.filters_changed.clone(), + staged_removed, + data.staged_changed.clone(), + ) }; if changed { pubsub.emit(()); } + if staged_removed { + staged_pubsub.emit(()); + } + removed } @@ -499,12 +613,11 @@ impl Workspace { pub fn set_active(&self, id: PanelId) -> bool { let mut data = self.0.borrow_mut(); if data.panels.iter().any(|p| p.id == id) { - data.active = id; - // Sync the per-renderer activation flags (data only; the CSS - // class lands inside each panel's next locked plugin dispatch — - // see `Renderer::stamp_active`). + data.active = Some(id); for panel in data.panels.iter() { - panel.renderer.set_active_flag(panel.id == data.active); + panel + .renderer + .set_active_flag(data.active.as_ref() == Some(&panel.id)); } true @@ -521,7 +634,7 @@ impl Workspace { /// The active panel's bound [`Client`], if any — the default target of a /// no-argument `eject()`. pub fn active_client(&self) -> Option { - self.active_panel().session.get_client() + self.active_panel().and_then(|p| p.session.get_client()) } /// The ids of every panel whose session is bound to the [`Client`] named @@ -566,16 +679,30 @@ impl Workspace { } /// Add a [`Client`] to the loaded-clients registry, if a client with the - /// same (globally unique) name isn't already present. + /// 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 mut data = self.0.borrow_mut(); - if !data - .clients - .iter() - .any(|c| c.get_name() == client.get_name()) - { - data.clients.push(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); + } + + /// A handle to the `client_registered` PubSub (see + /// [`WorkspaceData::client_registered`]). + pub fn client_registered(&self) -> Rc> { + self.0.borrow().client_registered.clone() } /// All loaded [`Client`]s: the registry, unioned with every panel @@ -594,6 +721,39 @@ impl Workspace { clients } + + /// Resolve which loaded [`Client`] a panel should bind to serve the `Table` + /// named `table_name`, given its `current` binding (if any). + pub async fn resolve_client_for_table( + &self, + table_name: &str, + current: Option<&Client>, + ) -> Option { + let clients = self.clients(); + + // A single bound client is already correct — no probe, no rebind. + if current.is_some() && clients.len() <= 1 { + return None; + } + + let mut host = None; + for client in &clients { + if let Ok(names) = client.get_hosted_table_names().await + && names.iter().any(|n| n.as_str() == table_name) + { + host = Some(client.clone()); + break; + } + } + + match host { + Some(host) if current.map(|c| c.get_name()) != Some(host.get_name()) => Some(host), + Some(_) => None, + // No host: seed an unbound session with the default client; leave a + // bound session as-is. + None => current.is_none().then(|| self.default_client()).flatten(), + } + } } #[cfg(test)] diff --git a/rust/perspective-viewer/src/svg/active-indicator.svg b/rust/perspective-viewer/src/svg/active-indicator.svg new file mode 100644 index 0000000000..be7150b2b1 --- /dev/null +++ b/rust/perspective-viewer/src/svg/active-indicator.svg @@ -0,0 +1,3 @@ + + + diff --git a/rust/perspective-viewer/src/themes/icons.css b/rust/perspective-viewer/src/themes/icons.css index 0911781eaf..f71fb6d897 100644 --- a/rust/perspective-viewer/src/themes/icons.css +++ b/rust/perspective-viewer/src/themes/icons.css @@ -18,7 +18,8 @@ perspective-dropdown, perspective-date-column-style, perspective-datetime-column-style, perspective-number-column-style, -perspective-string-column-style { +perspective-string-column-style, +perspective-viewer-tab { /* Icons */ --psp-column-type--integer--mask-image: url("../svg/number-type.svg"); --psp-column-type--float--mask-image: var( @@ -55,6 +56,7 @@ perspective-string-column-style { --psp-label--active-column-selector--content: url("../svg/checkbox-checked-icon.svg"); --psp-icon--select-arrow-light--mask-image: url("../svg/dropdown-selector-light.svg"); --psp-icon--select-arrow-dark--mask-image: url("../svg/dropdown-selector.svg"); + --psp-icon--active-indicator--mask-image: url("../svg/active-indicator.svg"); --psp-icon-overflow-hint--content: "!"; --psp-label--reset-button-icon--content: "refresh"; --psp-label--save-button-icon--content: "save"; diff --git a/rust/perspective-viewer/test/js/multi_panel/add_panel.spec.ts b/rust/perspective-viewer/test/js/multi_panel/add_panel.spec.ts new file mode 100644 index 0000000000..2c5831b30f --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/add_panel.spec.ts @@ -0,0 +1,230 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Full-stack panel ADDITION coverage (`.plan/WORKSPACE_TEST_PLAN.md` §2.2): +// every add path (context-menu Duplicate / New, `addPanel`, `restoreWorkspace`, +// upsert `restore`) must land as exactly ONE layout commit, with the model, +// tree, frame geometry and plugin projection coherent — not merely the right +// `getPanelNames().length`, which stayed green through both the +// element-replacement and NaN-sizes regressions. + +import { test, expect } from "../helpers.ts"; +import { + armInvariants, + armCommitCounter, + readCommitCount, + assertCoherent, + treeSlots, + type LayoutTree, +} from "./harness.ts"; + +const TABLE = "load-viewer-csv"; + +const SPLIT_CONFIG = { + layout: { + type: "split-layout", + orientation: "horizontal", + sizes: [0.5, 0.5], + children: [ + { type: "tab-layout", tabs: ["one"], selected: 0 }, + { type: "tab-layout", tabs: ["two"], selected: 0 }, + ], + }, + panels: { + one: { table: TABLE, title: "One", group_by: ["State"] }, + two: { table: TABLE, title: "Two", columns: ["Sales", "Profit"] }, + }, +}; + +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)); + } + }); +}); + +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + +async function panelNames(page): Promise { + return await page.evaluate(() => { + // @ts-ignore + return document.querySelector("perspective-viewer")!.getPanelNames(); + }); +} + +async function layoutTree(page): Promise { + return await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + return ( + viewer.shadowRoot!.querySelector("regular-layout") as any + ).save(); + }); +} + +/// Remove every panel, leaving the empty stage (the `load(client)`-style +/// boot base for the from-empty cases). +async function emptyElement(page): Promise { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")! as any; + for (const id of viewer.getPanelNames()) { + await viewer.removePanel(id); + } + }); +} + +test.describe("Adding panels, full-stack", () => { + test("Duplicate commits ONCE and splits the stage in half", async ({ + page, + }) => { + await armCommitCounter(page); + const [source] = await panelNames(page); + const viewer = page.locator("perspective-viewer"); + await viewer + .locator("perspective-viewer-plugin") + .first() + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await menu + .locator(".context-menu-item", { hasText: "Duplicate" }) + .click(); + + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 2, + ); + + await assertCoherent(page); + expect(await readCommitCount(page)).toBe(1); + + // An equal horizontal split of the two panels. + const tree = await layoutTree(page); + expect(tree.type).toBe("split-layout"); + expect((tree as any).sizes).toEqual([0.5, 0.5]); + expect(treeSlots(tree).length).toBe(2); + + // The duplicate carries the source panel's exact config. + const [dup] = (await panelNames(page)).filter((n) => n !== source); + const [source_config, dup_config] = await page.evaluate( + async ([a, b]) => { + const viewer = document.querySelector( + "perspective-viewer", + )! as any; + return [ + await viewer.save({ panel: a }), + await viewer.save({ panel: b }), + ]; + }, + [source, dup], + ); + + expect(dup_config).toEqual(source_config); + }); + + test("'New' from the menu commits ONCE and is coherent", async ({ + page, + }) => { + await armCommitCounter(page); + const viewer = page.locator("perspective-viewer"); + await viewer + .locator("perspective-viewer-plugin") + .first() + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + const new_item = menu.locator(".context-menu-item.has-submenu", { + hasText: "New", + }); + + await new_item.hover(); + await new_item + .locator(".context-menu-submenu .context-menu-item", { + hasText: TABLE, + }) + .click(); + + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 2, + ); + + await assertCoherent(page); + expect(await readCommitCount(page)).toBe(1); + const tree = await layoutTree(page); + expect((tree as any).sizes).toEqual([0.5, 0.5]); + }); + + test("addPanel commits ONCE and is coherent", async ({ page }) => { + await armCommitCounter(page); + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.addPanel({ table, columns: ["Sales"] }); + }, TABLE); + + await assertCoherent(page); + expect(await readCommitCount(page)).toBe(1); + expect((await panelNames(page)).length).toBe(2); + const tree = await layoutTree(page); + expect((tree as any).sizes).toEqual([0.5, 0.5]); + }); + + test("restoreWorkspace from empty lands the whole tree in ONE commit", async ({ + page, + }) => { + await emptyElement(page); + await armCommitCounter(page); + await page.evaluate(async (config) => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.restoreWorkspace(config); + }, SPLIT_CONFIG); + + await assertCoherent(page); + + // The saved tree mounts as ONE `restore` commit — restored panels + // must never transit synthetic equal-split inserts. + expect(await readCommitCount(page)).toBe(1); + // Panel ids are GENERATED (the config's `panels` keys are + // restore-time aliases) — assert the structure, not the names. + const tree = await layoutTree(page); + expect((tree as any).sizes).toEqual([0.5, 0.5]); + expect(treeSlots(tree).length).toBe(2); + }); + + test("upsert restore() on an empty element places one panel in ONE commit", async ({ + page, + }) => { + await emptyElement(page); + await armCommitCounter(page); + + // The blocks/editable boot path: `restore({table})` against an empty + // element creates and places the first panel. + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.restore({ table, title: "First" }); + }, TABLE); + + await assertCoherent(page); + expect(await readCommitCount(page)).toBe(1); + expect((await panelNames(page)).length).toBe(1); + }); +}); diff --git a/rust/perspective-viewer/test/js/multi_panel/api_remove.spec.ts b/rust/perspective-viewer/test/js/multi_panel/api_remove.spec.ts new file mode 100644 index 0000000000..d4227708aa --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/api_remove.spec.ts @@ -0,0 +1,239 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Full-stack API panel REMOVAL coverage (`.plan/WORKSPACE_TEST_PLAN.md` +// §2.7): `removePanel` and shrinking/round-tripping `restoreWorkspace` must +// dispose panels completely (layout cell, plugin element, activation) and +// leave the model, tree and geometry coherent — the layers the existing +// model-level assertions could not see. + +import { test, expect } from "../helpers.ts"; +import { + armInvariants, + armCommitCounter, + readCommitCount, + assertCoherent, + treeSlots, + type LayoutTree, +} from "./harness.ts"; + +const TABLE = "load-viewer-csv"; + +const SPLIT_CONFIG = { + layout: { + type: "split-layout", + orientation: "horizontal", + sizes: [0.5, 0.5], + children: [ + { type: "tab-layout", tabs: ["one"], selected: 0 }, + { type: "tab-layout", tabs: ["two"], selected: 0 }, + ], + }, + panels: { + one: { table: TABLE, title: "One" }, + two: { table: TABLE, title: "Two", group_by: ["Category"] }, + }, +}; + +const THIRDS_CONFIG = { + layout: { + type: "split-layout", + orientation: "horizontal", + sizes: [1 / 3, 1 / 3, 1 / 3], + children: [ + { type: "tab-layout", tabs: ["one"], selected: 0 }, + { type: "tab-layout", tabs: ["two"], selected: 0 }, + { type: "tab-layout", tabs: ["three"], selected: 0 }, + ], + }, + panels: { + one: { table: TABLE, title: "One" }, + two: { table: TABLE, title: "Two" }, + three: { table: TABLE, title: "Three" }, + }, +}; + +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)); + } + }); +}); + +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + +async function restoreWorkspace(page, config): Promise { + await page.evaluate(async (config) => { + // @ts-ignore + await document + .querySelector("perspective-viewer")! + .restoreWorkspace(config); + }, config); +} + +async function panelNames(page): Promise { + return await page.evaluate(() => { + // @ts-ignore + return document.querySelector("perspective-viewer")!.getPanelNames(); + }); +} + +async function layoutTree(page): Promise { + return await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + return ( + viewer.shadowRoot!.querySelector("regular-layout") as any + ).save(); + }); +} + +/// `restoreWorkspace` REPLACES the panel set with freshly-GENERATED panel +/// ids (the config's `panels` keys are restore-time aliases, not ids) - +/// resolve the live ids from the committed tree, whose slot order follows +/// the config's layout order. +async function slotIds(page): Promise { + return treeSlots(await layoutTree(page)); +} + +/// A layout tree with slot names replaced by their traversal INDEX - +/// restore round-trips regenerate ids by design, so only the id-agnostic +/// structure is comparable. +function normalizeTree(tree: LayoutTree): LayoutTree { + const ids = treeSlots(tree); + const index = new Map(ids.map((id, i) => [id, `#${i}`])); + const walk = (node: LayoutTree): LayoutTree => + node.type === "tab-layout" + ? { ...node, tabs: node.tabs.map((t) => index.get(t)!) } + : { ...node, children: node.children.map(walk) }; + + return walk(tree); +} + +test.describe("Removing panels via the API", () => { + test("removePanel commits ONCE, disposes and renormalizes", async ({ + page, + }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + const [one, two] = await slotIds(page); + await armCommitCounter(page); + await page.evaluate(async (id) => { + // @ts-ignore + await document.querySelector("perspective-viewer")!.removePanel(id); + }, one); + + await assertCoherent(page); + expect(await readCommitCount(page)).toBe(1); + expect(await panelNames(page)).toEqual([two]); + expect(treeSlots(await layoutTree(page))).toEqual([two]); + + // Disposal reaches the light DOM. + expect( + await page.locator(`perspective-viewer > [slot="${one}"]`).count(), + ).toBe(0); + }); + + test("removePanel(active) retargets activation to a survivor", async ({ + page, + }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + const [one, two] = await slotIds(page); + await page.evaluate(async (id) => { + const viewer = document.querySelector("perspective-viewer")! as any; + viewer.setActivePanel(id); + await viewer.removePanel(id); + }, one); + + await assertCoherent(page); + const active = await page.evaluate(() => { + // @ts-ignore + return document + .querySelector("perspective-viewer")! + .getActivePanel(); + }); + + expect(active).toBe(two); + }); + + test("removePanel to zero is coherent; addPanel revives the stage", async ({ + page, + }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")! as any; + for (const id of viewer.getPanelNames()) { + await viewer.removePanel(id); + } + }); + + await assertCoherent(page); + expect(await panelNames(page)).toEqual([]); + + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.addPanel({ table }); + }, TABLE); + + await assertCoherent(page); + expect((await panelNames(page)).length).toBe(1); + }); + + test("restoreWorkspace shrink disposes the removed panels", async ({ + page, + }) => { + await restoreWorkspace(page, THIRDS_CONFIG); + const prev = await slotIds(page); + expect(prev.length).toBe(3); + + await restoreWorkspace(page, SPLIT_CONFIG); + await assertCoherent(page); + const names = await panelNames(page); + expect(names.length).toBe(2); + + // Every replaced panel's plugin element left the light DOM. + for (const id of prev.filter((id) => !names.includes(id))) { + expect( + await page + .locator(`perspective-viewer > [slot="${id}"]`) + .count(), + ).toBe(0); + } + + const tree = await layoutTree(page); + expect((tree as any).sizes).toEqual([0.5, 0.5]); + }); + + test("restoreWorkspace(saveWorkspace()) round-trip is tree-stable", async ({ + page, + }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + const before = await layoutTree(page); + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.restoreWorkspace(await viewer.saveWorkspace()); + }); + + await assertCoherent(page); + + // The round-trip REPLACES the panels (fresh generated ids), so the + // tree is comparable only modulo ids. + expect(normalizeTree(await layoutTree(page))).toEqual( + normalizeTree(before), + ); + + expect((await panelNames(page)).length).toBe(2); + }); +}); diff --git a/rust/perspective-viewer/test/js/multi_panel/close_panel.spec.ts b/rust/perspective-viewer/test/js/multi_panel/close_panel.spec.ts new file mode 100644 index 0000000000..8ba6a9e0da --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/close_panel.spec.ts @@ -0,0 +1,272 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Full-stack UI panel CLOSE coverage (`.plan/WORKSPACE_TEST_PLAN.md` §2.6): +// close via the context menu and the tab × button. Closing is model-FIRST +// (the Workspace drops the panel, then reconcile removes the cell) — these +// specs assert the observable consequences: one layout commit, complete +// plugin disposal, activation retargeting, and coherence — including across +// a close racing an in-flight create. + +import { test, expect } from "../helpers.ts"; +import { + armInvariants, + armCommitCounter, + readCommitCount, + assertCoherent, + treeSlots, +} from "./harness.ts"; + +const TABLE = "load-viewer-csv"; + +const SPLIT_CONFIG = { + layout: { + type: "split-layout", + orientation: "horizontal", + sizes: [0.5, 0.5], + children: [ + { type: "tab-layout", tabs: ["one"], selected: 0 }, + { type: "tab-layout", tabs: ["two"], selected: 0 }, + ], + }, + panels: { + one: { table: TABLE, title: "One" }, + two: { table: TABLE, title: "Two", group_by: ["Category"] }, + }, +}; + +const STACK_CONFIG = { + layout: { type: "tab-layout", tabs: ["one", "two"], selected: 1 }, + panels: { + one: { table: TABLE, title: "One" }, + two: { table: TABLE, title: "Two" }, + }, +}; + +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)); + } + }); +}); + +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + +async function restoreWorkspace(page, config): Promise { + await page.evaluate(async (config) => { + // @ts-ignore + await document + .querySelector("perspective-viewer")! + .restoreWorkspace(config); + }, config); +} + +async function panelNames(page): Promise { + return await page.evaluate(() => { + // @ts-ignore + return document.querySelector("perspective-viewer")!.getPanelNames(); + }); +} + +/// `restoreWorkspace` REPLACES the panel set with freshly-GENERATED panel +/// ids (the config's `panels` keys are restore-time aliases, not ids) - +/// resolve the live ids from the committed tree, whose slot order follows +/// the config's layout order. +async function slotIds(page): Promise { + return treeSlots( + await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + return ( + viewer.shadowRoot!.querySelector("regular-layout") as any + ).save(); + }), + ); +} + +async function menuClose(page, slot: string): Promise { + await page + .locator(`perspective-viewer-plugin[slot="${slot}"]`) + .click({ button: "right" }); + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await menu.locator(".context-menu-item", { hasText: "Close" }).click(); +} + +test.describe("Closing panels via the UI", () => { + test("context-menu Close commits ONCE and disposes the panel", async ({ + page, + }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + const [one, two] = await slotIds(page); + await armCommitCounter(page); + await menuClose(page, one); + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 1, + ); + + await assertCoherent(page); + expect(await readCommitCount(page)).toBe(1); + expect(await panelNames(page)).toEqual([two]); + + // The closed panel's plugin element left the light DOM. + expect( + await page.locator(`perspective-viewer > [slot="${one}"]`).count(), + ).toBe(0); + }); + + test("the tab × button closes its own panel", async ({ page }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + const [one, two] = await slotIds(page); + + // The close button occupies the settings button's spot, so it only + // renders while the settings sidebar is OPEN (see `panel_tab.rs`). + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.restore({ settings: true }); + }); + + await page + .locator(`perspective-viewer-tab[slot="tab-${one}"] .psp-tab-close`) + .click(); + + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 1, + ); + + await assertCoherent(page); + expect(await panelNames(page)).toEqual([two]); + }); + + test("closing the ACTIVE panel retargets activation to a survivor", async ({ + page, + }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + const [one, two] = await slotIds(page); + await page.evaluate((id) => { + // @ts-ignore + document.querySelector("perspective-viewer")!.setActivePanel(id); + }, one); + + await menuClose(page, one); + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 1, + ); + + await assertCoherent(page); + const active = await page.evaluate(() => { + // @ts-ignore + return document + .querySelector("perspective-viewer")! + .getActivePanel(); + }); + + expect(active).toBe(two); + }); + + test("the LAST panel's Close menu item is disabled", async ({ page }) => { + await restoreWorkspace(page, SPLIT_CONFIG); + const [one, two] = await slotIds(page); + await menuClose(page, one); + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 1, + ); + + // A lone panel can't be closed to zero from the UI (API-only). + await page + .locator(`perspective-viewer-plugin[slot="${two}"]`) + .click({ button: "right" }); + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await expect( + menu.locator(".context-menu-item.disabled", { hasText: "Close" }), + ).toHaveCount(1); + + await page.keyboard.press("Escape"); + }); + + test("closing a stack's visible member reveals the survivor", async ({ + page, + }) => { + await restoreWorkspace(page, STACK_CONFIG); + const [one, two] = await slotIds(page); + + // `selected: 1` — the visible (projected) member is the second tab. + await menuClose(page, two); + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 1, + ); + + await assertCoherent(page); + expect(await panelNames(page)).toEqual([one]); + }); + + test("closing a panel DURING its own create settles coherent (no deadline resurrection)", async ({ + page, + }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + // Start `addPanel`, catch the new id from the (synchronous) model + // create, and remove it while its restore/first-draw is still in + // flight. The staging deadline (500ms) must NOT re-insert it. + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")! as any; + const before = new Set(viewer.getPanelNames()); + const pending = viewer.addPanel({ table }); + let fresh: string | undefined; + for (let i = 0; i < 100 && fresh === undefined; i++) { + fresh = viewer + .getPanelNames() + .find((n: string) => !before.has(n)); + + if (fresh === undefined) { + await new Promise((x) => setTimeout(x, 5)); + } + } + + if (fresh !== undefined) { + await viewer.removePanel(fresh); + } + + await pending.catch(() => {}); + }, TABLE); + + await assertCoherent(page); + expect((await panelNames(page)).length).toBe(1); + + // Outlast the staging deadline — the removed panel must stay gone. + await page.waitForTimeout(800); + await assertCoherent(page); + expect((await panelNames(page)).length).toBe(1); + expect(errors).toEqual([]); + }); +}); diff --git a/rust/perspective-viewer/test/js/multi_panel/global_filter.spec.ts b/rust/perspective-viewer/test/js/multi_panel/global_filter.spec.ts index c8b5e8a35c..68c0e0124f 100644 --- a/rust/perspective-viewer/test/js/multi_panel/global_filter.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/global_filter.spec.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "../helpers.ts"; +import { armInvariants } from "./harness.ts"; const TABLE = "load-viewer-csv"; @@ -67,6 +68,11 @@ test.beforeEach(async ({ page }) => { }); }); +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + async function restore(page, config) { await page.evaluate(async (config) => { const viewer = document.querySelector("perspective-viewer")!; diff --git a/rust/perspective-viewer/test/js/multi_panel/harness.ts b/rust/perspective-viewer/test/js/multi_panel/harness.ts new file mode 100644 index 0000000000..9cabb41919 --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/harness.ts @@ -0,0 +1,410 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Workspace structural invariants (see `.plan/WORKSPACE_TEST_PLAN.md` §1). +// Each helper asserts a layer the model-level APIs cannot see, locking the +// failure classes of the panel-materialization arc: +// +// - I1 `armLayoutCanary`/`assertLayoutStable`: the `` ELEMENT +// is never replaced (a Yew unkeyed list diff once recreated it, silently +// re-booting an EMPTY layout with no listeners). +// - I2 `assertTreeSane`: `layout.save()` sizes are finite, positive and +// normalized (an insert into an empty split once poisoned the tree with +// `NaN` sizes that rendered plausibly). +// - I3 `assertCoherent`: model panel set ≡ layout tree ≡ frame geometry ≡ +// slotted plugin projection (a duplicate once committed into an empty tree, +// orphaning the original panel from the layout while the model kept both — +// and the two frames rendered pixel-superimposed). +// +// `armInvariants(test)` retrofits I1+I2+I3 onto a whole spec file; the +// invariants then gate EVERY test's end state. + +import type { Page, TestType } from "@playwright/test"; +import { expect } from "../helpers.ts"; + +/// `regular-layout`'s serialized tree. +export type LayoutTree = + | { + type: "split-layout"; + orientation: "horizontal" | "vertical"; + sizes: number[]; + children: LayoutTree[]; + } + | { type: "tab-layout"; tabs: string[]; selected?: number }; + +interface Rect { + x: number; + y: number; + width: number; + height: number; + top: number; + right: number; + bottom: number; + left: number; +} + +/// One viewer's structural snapshot, gathered in a single `evaluate`. +interface CoherenceSnapshot { + names: string[]; + tree: LayoutTree | null; + stage: Rect | null; + frames: { name: string | null; rect: Rect; display: string }[]; + plugins: { slot: string; rect: Rect; visible: boolean }[]; + canary: "stable" | "replaced" | "unarmed"; +} + +/// Every slot name in a layout tree, in traversal order. +export function treeSlots(tree: LayoutTree | null): string[] { + if (tree == null) { + return []; + } + + if (tree.type === "tab-layout") { + return [...tree.tabs]; + } + + return tree.children.flatMap(treeSlots); +} + +/// The visible member of every stack: `tabs[selected ?? 0]`. Hidden stack +/// members legally have an unprojected (hidden) plugin. +function visibleSlots(tree: LayoutTree | null): string[] { + if (tree == null) { + return []; + } + + if (tree.type === "tab-layout") { + const selected = tree.selected ?? 0; + return tree.tabs[selected] != null ? [tree.tabs[selected]] : []; + } + + return tree.children.flatMap(visibleSlots); +} + +/// Group slots by their containing `tab-layout` (stack) — same-stack frames +/// legally overlap (they share a cell); any other overlap is a bug. +function stackGroups(tree: LayoutTree | null): string[][] { + if (tree == null) { + return []; + } + + if (tree.type === "tab-layout") { + return [[...tree.tabs]]; + } + + return tree.children.flatMap(stackGroups); +} + +/// I2 — every `sizes` entry finite and positive, per-split sum ≈ 1, arity +/// matches, no duplicate or empty slots. The empty root split (zero panels) +/// is legal. +export function assertTreeSane(tree: LayoutTree | null): void { + expect(tree, "layout.save() returned no tree").not.toBeNull(); + const slots: string[] = []; + const walk = (node: LayoutTree, is_root: boolean) => { + if (node.type === "tab-layout") { + expect( + node.tabs.length, + "empty tab-layout in tree", + ).toBeGreaterThan(0); + if (node.selected != null) { + expect(node.selected).toBeGreaterThanOrEqual(0); + expect(node.selected).toBeLessThan(node.tabs.length); + } + + slots.push(...node.tabs); + return; + } + + expect(node.sizes.length, "sizes/children arity mismatch").toBe( + node.children.length, + ); + + if (node.children.length === 0) { + expect(is_root, "empty split below the root").toBe(true); + return; + } + + let total = 0; + for (const size of node.sizes) { + expect(Number.isFinite(size), `non-finite size ${size}`).toBe(true); + expect(size, "non-positive size").toBeGreaterThan(0); + total += size; + } + + expect(Math.abs(total - 1), "sizes not normalized").toBeLessThan(1e-6); + for (const child of node.children) { + walk(child, false); + } + }; + + walk(tree!, true); + expect(new Set(slots).size, "duplicate slot in tree").toBe(slots.length); +} + +/// I1 — record the layout element instance(s) present now; `assertCoherent` +/// (and `assertLayoutStable`) later require the SAME instance, still +/// connected. +export async function armLayoutCanary(page: Page): Promise { + await page.waitForFunction(() => + Array.from(document.querySelectorAll("perspective-viewer")).some((v) => + v.shadowRoot?.querySelector("regular-layout"), + ), + ); + + await page.evaluate(() => { + const canaries = new Map(); + for (const viewer of document.querySelectorAll("perspective-viewer")) { + const layout = viewer.shadowRoot?.querySelector("regular-layout"); + if (layout) { + canaries.set(viewer, layout); + } + } + + (globalThis as any).__PSP_LAYOUT_CANARIES = canaries; + }); +} + +function snapshotInPage(): CoherenceSnapshot[] { + const toRect = (r: DOMRect): any => ({ + x: r.x, + y: r.y, + width: r.width, + height: r.height, + top: r.top, + right: r.right, + bottom: r.bottom, + left: r.left, + }); + + const canaries: Map | undefined = (globalThis as any) + .__PSP_LAYOUT_CANARIES; + + return Array.from(document.querySelectorAll("perspective-viewer")).map( + (viewer: any) => { + const layout = viewer.shadowRoot?.querySelector( + "regular-layout", + ) as any; + + const recorded = canaries?.get(viewer); + const canary = + recorded === undefined + ? "unarmed" + : recorded === layout && layout?.isConnected + ? "stable" + : "replaced"; + + const frames = Array.from( + layout?.querySelectorAll("regular-layout-frame") ?? [], + ).map((f: any) => ({ + name: f.getAttribute("name"), + rect: toRect(f.getBoundingClientRect()), + display: getComputedStyle(f).display, + })); + + const plugins = Array.from(viewer.children) + .filter((el: any) => { + const slot = el.getAttribute("slot"); + return slot && !slot.startsWith("tab-"); + }) + .map((el: any) => ({ + slot: el.getAttribute("slot"), + rect: toRect(el.getBoundingClientRect()), + visible: el.offsetParent != null, + })); + + return { + names: viewer.getPanelNames?.() ?? [], + tree: layout?.save?.() ?? null, + stage: layout ? toRect(layout.getBoundingClientRect()) : null, + frames, + plugins, + canary, + }; + }, + ); +} + +function intersectionArea(a: Rect, b: Rect): number { + const w = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + return Math.max(0, w) * Math.max(0, h); +} + +function rectsEqual(a: Rect, b: Rect, tol: number): boolean { + return ( + Math.abs(a.left - b.left) <= tol && + Math.abs(a.top - b.top) <= tol && + Math.abs(a.right - b.right) <= tol && + Math.abs(a.bottom - b.bottom) <= tol + ); +} + +function contains(outer: Rect, inner: Rect, tol: number): boolean { + return ( + inner.left >= outer.left - tol && + inner.top >= outer.top - tol && + inner.right <= outer.right + tol && + inner.bottom <= outer.bottom + tol + ); +} + +/// I1 alone — the layout element was not replaced. +export async function assertLayoutStable(page: Page): Promise { + const snaps = await page.evaluate(snapshotInPage); + for (const snap of snaps) { + expect(snap.canary, " element was REPLACED").not.toBe( + "replaced", + ); + } +} + +/// I1 + I2 + I3 at a SETTLE point. Polls first until every viewer's model +/// panel set equals its layout tree's slot set — staged panels are the only +/// legal difference, and they must drain within the staging deadline — then +/// asserts structure and geometry. +export async function assertCoherent(page: Page): Promise { + const deadline = Date.now() + 2000; + let snaps = await page.evaluate(snapshotInPage); + const settled = (s: CoherenceSnapshot) => + [...s.names].sort().join("") === treeSlots(s.tree).sort().join(""); + + while (!snaps.every(settled) && Date.now() < deadline) { + await new Promise((x) => setTimeout(x, 50)); + snaps = await page.evaluate(snapshotInPage); + } + + for (const snap of snaps) { + // I1 — element identity. + expect(snap.canary, " element was REPLACED").not.toBe( + "replaced", + ); + + // Model ≡ tree (staged set drained). + expect( + [...snap.names].sort(), + "model panel set != layout tree slots", + ).toEqual(treeSlots(snap.tree).sort()); + + // I2 — tree data sanity. + assertTreeSane(snap.tree); + + if (snap.names.length === 0) { + continue; + } + + // Every slot has a placed frame inside the stage. + const frames = new Map(snap.frames.map((f) => [f.name, f])); + for (const slot of treeSlots(snap.tree)) { + const frame = frames.get(slot); + expect(frame, `no frame for panel [${slot}]`).toBeTruthy(); + expect(frame!.display, `frame [${slot}] not placed`).not.toBe( + "none", + ); + + expect( + frame!.rect.width, + `frame [${slot}] has no width`, + ).toBeGreaterThan(1); + + expect( + frame!.rect.height, + `frame [${slot}] has no height`, + ).toBeGreaterThan(1); + + expect( + contains(snap.stage!, frame!.rect, 2), + `frame [${slot}] outside the stage`, + ).toBe(true); + } + + // Frames overlap ONLY within a stack (where they must coincide) — + // any cross-stack overlap is the superimposition bug class. + const groups = stackGroups(snap.tree); + const group_of = new Map(); + groups.forEach((group, i) => + group.forEach((slot) => group_of.set(slot, i)), + ); + + const slots = treeSlots(snap.tree); + for (let i = 0; i < slots.length; i++) { + for (let j = i + 1; j < slots.length; j++) { + const [a, b] = [frames.get(slots[i])!, frames.get(slots[j])!]; + if (group_of.get(slots[i]) === group_of.get(slots[j])) { + expect( + rectsEqual(a.rect, b.rect, 2), + `stack members [${slots[i]}]/[${slots[j]}] diverge`, + ).toBe(true); + } else { + expect( + intersectionArea(a.rect, b.rect), + `frames [${slots[i]}]/[${slots[j]}] overlap`, + ).toBeLessThan(25); + } + } + } + + // Every VISIBLE panel's plugin is slotted, projected and inside its + // frame; hidden stack members are legally unprojected. + const visible = new Set(visibleSlots(snap.tree)); + for (const slot of visible) { + const plugin = snap.plugins.find( + (p) => p.slot === slot && p.visible, + ); + expect( + plugin, + `no projected plugin for visible panel [${slot}]`, + ).toBeTruthy(); + + expect( + contains(frames.get(slot)!.rect, plugin!.rect, 3), + `plugin [${slot}] escapes its frame`, + ).toBe(true); + } + } +} + +/// Retrofit I1+I2+I3 onto a spec file: arm the canary after the file's own +/// `beforeEach` (registration order), and gate every PASSING test's end +/// state on full coherence. Call AFTER the file's `test.beforeEach`. +export function armInvariants(test: TestType): void { + test.beforeEach(async ({ page }) => { + await armLayoutCanary(page); + }); + + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await assertCoherent(page); + } + }); +} + +/// Count `regular-layout-update` events (layout COMMITS) from now on. Most +/// actions must commit exactly once — extra commits are redundant layout +/// churn, zero is a silently-dropped mutation. +export async function armCommitCounter(page: Page): Promise { + await page.evaluate(() => { + const g = globalThis as any; + g.__PSP_COMMITS = 0; + for (const viewer of document.querySelectorAll("perspective-viewer")) { + viewer.shadowRoot + ?.querySelector("regular-layout") + ?.addEventListener("regular-layout-update", () => { + g.__PSP_COMMITS += 1; + }); + } + }); +} + +export async function readCommitCount(page: Page): Promise { + return await page.evaluate(() => (globalThis as any).__PSP_COMMITS ?? 0); +} 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 new file mode 100644 index 0000000000..6c00d91a39 --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/inert_load.spec.ts @@ -0,0 +1,142 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { armInvariants } from "./harness.ts"; +const TABLE = "load-viewer-csv"; + +const SECOND_CLIENT = async (name: string) => { + const { default: perspective } = await import( + "/node_modules/@perspective-dev/client/dist/cdn/perspective.js" + ); + + const worker = await perspective.worker(); + await worker.table("a,b\n1,2", { name }); + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore Client payload — registered inertly, no panel rebind. + await viewer.load(worker); +}; + +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)); + } + }); +}); + +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + +test.describe("Inert load(Client)", () => { + test("loading a second Client does not blank the active panel", async ({ + page, + }) => { + const before = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return (await viewer.save()).table; + }); + expect(before).toBe(TABLE); + await page.evaluate(SECOND_CLIENT, "other-table"); + const after = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return (await viewer.save()).table; + }); + expect(after).toBe(TABLE); + }); + + test("a panel federates its table to a second loaded client", async ({ + page, + }) => { + await page.evaluate(SECOND_CLIENT, "other-table"); + const config = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + const id = await viewer.addPanel({ table: "other-table" }); + // @ts-ignore + return await viewer.save({ panel: id }); + }); + expect(config.table).toBe("other-table"); + }); +}); + +// `load(Promise)` can't classify its payload synchronously, so it RESERVES +// the first panel for exactly this call pattern; when the payload proves to +// be a `Client` (inert), the reserved panel is dropped in the epilogue — +// UNLESS a racing `restore()` claimed it (`in_flight_config_runs`). The +// docs-site gallery hit the unguarded drop: blank overlay, zero panels, an +// uncaught "No panel named" from the mid-flight restore. +test.describe("Reserved panel vs. `load(Promise)`", () => { + test("unawaited load(Promise) + restore({table}) keeps the claimed panel", async ({ + page, + }) => { + const result = await page.evaluate(async (tableName) => { + const worker = (window as any).__TEST_WORKER__; + const v = document.createElement("perspective-viewer") as any; + document.body.appendChild(v); + v.load(Promise.resolve(worker)); + let restoreError: string | null = null; + try { + await v.restore({ + table: tableName, + columns: ["Sales"], + settings: true, + }); + } catch (e) { + restoreError = String(e); + } + + // The reserved-panel drop lands in `load`'s async epilogue, + // after `restore` resolves. + await new Promise((x) => setTimeout(x, 250)); + let saved: any = null; + let saveError: string | null = null; + try { + saved = await v.save(); + } catch (e) { + saveError = String(e); + } + + return { + restoreError, + saveError, + panels: v.getPanelNames(), + table: saved?.table, + }; + }, TABLE); + + expect(result.restoreError).toBeNull(); + expect(result.saveError).toBeNull(); + expect(result.panels.length).toBe(1); + expect(result.table).toBe(TABLE); + }); + + test("unawaited load(Promise) with NO restore still drops the reserved panel", async ({ + page, + }) => { + const panels = await page.evaluate(async () => { + const worker = (window as any).__TEST_WORKER__; + const v = document.createElement("perspective-viewer") as any; + document.body.appendChild(v); + await v.load(Promise.resolve(worker)); + await new Promise((x) => setTimeout(x, 250)); + return v.getPanelNames(); + }); + + expect(panels).toEqual([]); + }); +}); diff --git a/rust/perspective-viewer/test/js/multi_panel/load_restore_race.spec.ts b/rust/perspective-viewer/test/js/multi_panel/load_restore_race.spec.ts index d360189fe0..c3f988ab9a 100644 --- a/rust/perspective-viewer/test/js/multi_panel/load_restore_race.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/load_restore_race.spec.ts @@ -13,6 +13,7 @@ // Regression spec for the `load()` → immediate `restore()` lost-update race import { test, expect } from "../helpers.ts"; +import { armInvariants } from "./harness.ts"; const TABLE = "load-viewer-csv"; const COLUMNS = ["Quantity", "Postal Code"]; @@ -27,6 +28,11 @@ test.beforeEach(async ({ page }) => { }); }); +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + for (const plugin of ["Datagrid", "Debug"]) { test(`load + immediate restore holds \`columns\` across ${VIEWER_COUNT} viewers (${plugin})`, async ({ page, diff --git a/rust/perspective-viewer/test/js/multi_panel/panel_chrome.spec.ts b/rust/perspective-viewer/test/js/multi_panel/panel_chrome.spec.ts new file mode 100644 index 0000000000..eedddb68f6 --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/panel_chrome.spec.ts @@ -0,0 +1,139 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// The panel-count chrome (`single`/`multi` frame classes, tab +// closable/draggable) must flip ON THE LAYOUT COMMIT that changes the +// placed-panel count — with NO further interaction. It once stayed stale +// after a first Duplicate/New (both tabs kept `single` chrome) because the +// chrome derives from `inserted.len()` at render time, `reconcile` grows +// `inserted` AFTER that render, and the commit's `LayoutUpdated` only +// re-rendered on a hidden-tab delta — which a side-by-side insert never +// produces. Fixed by a rendered-count staleness check in +// `on_layout_updated`. + +import { test, expect } from "../helpers.ts"; +import { armInvariants, assertCoherent } from "./harness.ts"; + +const TABLE = "load-viewer-csv"; + +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)); + } + }); +}); + +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + +function singleFrames(page) { + return page.locator("regular-layout-frame.single"); +} + +function frames(page) { + return page.locator("regular-layout-frame"); +} + +test.describe("Panel-count chrome", () => { + test("Duplicate flips single→multi with no further interaction", async ({ + page, + }) => { + await expect(singleFrames(page)).toHaveCount(1); + + const viewer = page.locator("perspective-viewer"); + await viewer + .locator("perspective-viewer-plugin") + .first() + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + await menu + .locator(".context-menu-item", { hasText: "Duplicate" }) + .click(); + + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 2, + ); + + // The flip must arrive with the commit itself — deliberately NO + // clicks/activation between the menu action and these assertions. + await expect(frames(page)).toHaveCount(2); + await expect(singleFrames(page)).toHaveCount(0); + await assertCoherent(page); + }); + + test("'New' flips single→multi with no further interaction", async ({ + page, + }) => { + await expect(singleFrames(page)).toHaveCount(1); + + const viewer = page.locator("perspective-viewer"); + await viewer + .locator("perspective-viewer-plugin") + .first() + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + const new_item = menu.locator(".context-menu-item.has-submenu", { + hasText: "New", + }); + + await new_item.hover(); + await new_item + .locator(".context-menu-submenu .context-menu-item", { + hasText: TABLE, + }) + .click(); + + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 2, + ); + + await expect(frames(page)).toHaveCount(2); + await expect(singleFrames(page)).toHaveCount(0); + await assertCoherent(page); + }); + + test("addPanel flips single→multi; removePanel flips the survivor back", async ({ + page, + }) => { + await expect(singleFrames(page)).toHaveCount(1); + const added = await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")! as any; + return await viewer.addPanel({ table }); + }, TABLE); + + await expect(frames(page)).toHaveCount(2); + await expect(singleFrames(page)).toHaveCount(0); + + await page.evaluate(async (id) => { + // @ts-ignore + await document.querySelector("perspective-viewer")!.removePanel(id); + }, added); + + await expect(frames(page)).toHaveCount(1); + await expect(singleFrames(page)).toHaveCount(1); + await assertCoherent(page); + }); +}); 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 b40726a81e..a01f01d48e 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 @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "../helpers.ts"; +import { armInvariants } from "./harness.ts"; const TABLE = "load-viewer-csv"; @@ -58,6 +59,11 @@ test.beforeEach(async ({ page }) => { }); }); +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + async function save(page) { return await page.evaluate(async () => { const viewer = document.querySelector("perspective-viewer")!; diff --git a/rust/perspective-viewer/test/js/multi_panel/theme_mirror.spec.ts b/rust/perspective-viewer/test/js/multi_panel/theme_mirror.spec.ts new file mode 100644 index 0000000000..8378d71e46 --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/theme_mirror.spec.ts @@ -0,0 +1,128 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Regression spec for the activation host-theme mirror. The host `theme` +// attribute is DERIVED state — always the active panel's own theme, else the +// registry default. `retarget_active`'s mirror once captured that value +// eagerly and stamped it from a spawned future, so a `restore({theme})` +// racing an unawaited `load()` (the docs-site boot pattern) set the pin and +// stamped the host in between — and the stale default then clobbered the +// host ~1ms later, leaving light chrome around a dark panel. + +import { test, expect } from "../helpers.ts"; +import { armInvariants } from "./harness.ts"; + +const TABLE = "load-viewer-csv"; +const VIEWER_COUNT = 10; + +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)); + } + }); +}); + +armInvariants(test); + +test(`unawaited load + restore({theme}) holds the host theme attribute across ${VIEWER_COUNT} viewers`, async ({ + page, +}) => { + test.setTimeout(120_000); + const results = await page.evaluate( + async ({ tableName, count }) => { + const worker = (window as any).__TEST_WORKER__; + const out: { + i: number; + immediate: string | null; + settled: string | null; + saved: string | undefined; + }[] = []; + const viewers: any[] = []; + for (let i = 0; i < count; i++) { + const table = await worker.open_table(tableName); + const v = document.createElement("perspective-viewer") as any; + document.body.appendChild(v); + viewers.push(v); + while (v.getAttribute("theme") === null) { + await new Promise((x) => setTimeout(x, 5)); + } + + v.load(table); + await v.restore({ theme: "Pro Dark", columns: ["Sales"] }); + out.push({ + i, + immediate: v.getAttribute("theme"), + settled: null, + saved: undefined, + }); + } + + // The stale-capture clobber landed ~1ms after `restore` + // resolved; it surfaces only after settle. + await new Promise((x) => setTimeout(x, 250)); + for (let i = 0; i < count; i++) { + out[i].settled = viewers[i].getAttribute("theme"); + out[i].saved = (await viewers[i].save()).theme; + } + + return out; + }, + { tableName: TABLE, count: VIEWER_COUNT }, + ); + + for (const { i, immediate, settled, saved } of results) { + expect + .soft(immediate, `viewer ${i} immediately after restore`) + .toBe("Pro Dark"); + expect.soft(settled, `viewer ${i} after 250ms settle`).toBe("Pro Dark"); + expect.soft(saved, `viewer ${i} save().theme`).toBe("Pro Dark"); + } +}); + +test("tab activation re-derives the host theme from the newly-active panel", async ({ + page, +}) => { + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.resetThemes(["Pro Light", "Pro Dark"]); + await viewer.restoreWorkspace({ + layout: { type: "tab-layout", tabs: ["one", "two"], selected: 0 }, + panels: { + one: { table, title: "One", theme: "Pro Dark" }, + two: { table, title: "Two" }, + }, + }); + }, TABLE); + + // `restoreWorkspace` regenerates panel ids; resolve them from the tree + // (slot order follows the config's layout order). + const [one, two] = await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + const tree = ( + viewer.shadowRoot!.querySelector("regular-layout") as any + ).save(); + return tree.tabs; + }); + + const hostTheme = () => + page.evaluate(() => + document.querySelector("perspective-viewer")!.getAttribute("theme"), + ); + + await expect.poll(hostTheme).toBe("Pro Dark"); + await page.locator(`perspective-viewer-tab[slot="tab-${two}"]`).click(); + await expect.poll(hostTheme).toBe("Pro Light"); + await page.locator(`perspective-viewer-tab[slot="tab-${one}"]`).click(); + await expect.poll(hostTheme).toBe("Pro Dark"); +}); 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 7226501080..ae34717409 100644 --- a/rust/perspective-viewer/test/js/multi_panel/workspace.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/workspace.spec.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "../helpers.ts"; +import { armInvariants } from "./harness.ts"; const TABLE = "load-viewer-csv"; @@ -57,6 +58,11 @@ test.beforeEach(async ({ page }) => { }); }); +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + async function restore(page, config) { await page.evaluate(async (config) => { const viewer = document.querySelector("perspective-viewer")!; @@ -157,7 +163,7 @@ test.describe("Multi-panel API", () => { expect(config.title).toBe("Added"); }); - test("removePanel disposes a panel; the last panel is protected", async ({ + test("removePanel disposes a panel; removing the last empties the element", async ({ page, }) => { await restore(page, SPLIT_CONFIG); @@ -177,7 +183,9 @@ test.describe("Multi-panel API", () => { .length === 1, ); - // Removing the last panel is a no-op. + // Removing the last panel empties the element — zero panels is a + // supported state (see `zero_panel.spec.ts`), with the persistent + // `` depopulated rather than torn down. const last = await panel_names(page); await page.evaluate((name) => { const viewer = document.querySelector("perspective-viewer")!; @@ -185,8 +193,19 @@ test.describe("Multi-panel API", () => { viewer.removePanel(name); }, last[0]); - await page.waitForTimeout(100); - expect((await panel_names(page)).length).toBe(1); + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 0, + ); + + await expect( + page.locator("perspective-viewer perspective-viewer-plugin"), + ).toHaveCount(0); + await expect( + page.locator("perspective-viewer regular-layout"), + ).toHaveCount(1); }); test("reset({panel}) resets only the named panel", async ({ page }) => { @@ -369,16 +388,26 @@ test.describe("Panel context menu", () => { ]); await expect(submenu.locator(".context-menu-header")).toHaveCount(0); + const prev_active = await page.evaluate(() => + // @ts-ignore + document.querySelector("perspective-viewer")!.getActivePanel(), + ); await submenu .locator(".context-menu-item", { hasText: "second-table" }) .click(); - await page.waitForFunction( - () => + // The menu inserts the panel synchronously but activates it only + // AFTER its restore/first-draw resolves — wait for the activation + // flip, not just the panel count. + await page.waitForFunction((prev) => { + const viewer = document.querySelector("perspective-viewer")!; + return ( // @ts-ignore - document.querySelector("perspective-viewer")!.getPanelNames() - .length === 3, - ); + viewer.getPanelNames().length === 3 && + // @ts-ignore + viewer.getActivePanel() !== prev + ); + }, prev_active); // The new panel is active and bound to the selected table. const config = await page.evaluate(async () => { @@ -434,16 +463,26 @@ test.describe("Panel context menu", () => { // "other-client-table" exists ONLY on the second client, so a // successful bind proves the sub-menu targeted the right client. + const prev_active = await page.evaluate(() => + // @ts-ignore + document.querySelector("perspective-viewer")!.getActivePanel(), + ); await submenu .locator(".context-menu-item", { hasText: "other-client-table" }) .click(); - await page.waitForFunction( - () => + // The menu inserts the panel synchronously but activates it only + // AFTER its restore/first-draw resolves — wait for the activation + // flip, not just the panel count. + await page.waitForFunction((prev) => { + const viewer = document.querySelector("perspective-viewer")!; + return ( // @ts-ignore - document.querySelector("perspective-viewer")!.getPanelNames() - .length === 3, - ); + viewer.getPanelNames().length === 3 && + // @ts-ignore + viewer.getActivePanel() !== prev + ); + }, prev_active); const config = await page.evaluate(async () => { const viewer = document.querySelector("perspective-viewer")!; 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 new file mode 100644 index 0000000000..b2df7de74d --- /dev/null +++ b/rust/perspective-viewer/test/js/multi_panel/zero_panel.spec.ts @@ -0,0 +1,234 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { armInvariants } from "./harness.ts"; + +const TABLE = "load-viewer-csv"; + +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)); + } + }); +}); + +// Structural invariants (I1 element identity / I2 tree sanity / I3 +// model-layout-DOM coherence) gate every passing test's end state - see +// `harness.ts` and `.plan/WORKSPACE_TEST_PLAN.md`. +armInvariants(test); + +function panel_names(page): Promise { + return page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return viewer.getPanelNames(); + }); +} + +/// Remove every panel, emptying the element. +async function empty(page) { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + for (const id of viewer.getPanelNames()) { + // @ts-ignore + await viewer.removePanel(id); + } + }); +} + +test.describe("Zero panels", () => { + test("removePanel can empty the element", async ({ page }) => { + await empty(page); + expect(await panel_names(page)).toEqual([]); + + const active = await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return viewer.getActivePanel(); + }); + expect(active).toBeNull(); + await expect( + page.locator("perspective-viewer perspective-viewer-plugin"), + ).toHaveCount(0); + await expect( + page.locator("perspective-viewer regular-layout-frame"), + ).toHaveCount(0); + await expect( + page.locator("perspective-viewer regular-layout"), + ).toHaveCount(1); + }); + + test("restore from empty creates and activates a panel", async ({ + page, + }) => { + await empty(page); + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.restore({ table }, { panel: "solo" }); + }, TABLE); + + expect(await panel_names(page)).toEqual(["solo"]); + const active = await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return viewer.getActivePanel(); + }); + expect(active).toBe("solo"); + }); + + test("addPanel from empty activates the new panel", async ({ page }) => { + await empty(page); + const id = await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return await viewer.addPanel({ table }); + }, TABLE); + + expect(await panel_names(page)).toEqual([id]); + const active = await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return viewer.getActivePanel(); + }); + expect(active).toBe(id); + }); + + test("panel-scoped read methods reject with no active panel", async ({ + page, + }) => { + await empty(page); + const rejected = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + try { + // @ts-ignore + await viewer.getTable(); + return false; + } catch { + return true; + } + }); + expect(rejected).toBe(true); + }); + + test("settings sidebar binds to the first panel created from empty", async ({ + page, + }) => { + await empty(page); + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.restore({ table }, { panel: "solo" }); + // @ts-ignore + await viewer.restore({ settings: true }); + }, TABLE); + + await expect( + page.locator("perspective-viewer #settings_panel"), + ).toBeVisible(); + }); + + test("empty stage offers a New context menu that creates the first panel", async ({ + page, + }) => { + await empty(page); + await page + .locator("perspective-viewer #main_panel_container") + .click({ button: "right" }); + + const menu = page.locator("perspective-context-menu"); + await menu.waitFor(); + const new_item = menu.locator(".context-menu-item.has-submenu", { + hasText: "New", + }); + + await new_item.hover(); + const submenu = new_item.locator(".context-menu-submenu"); + await submenu.locator(".context-menu-item", { hasText: TABLE }).click(); + await page.waitForFunction( + () => + // @ts-ignore + document.querySelector("perspective-viewer")!.getPanelNames() + .length === 1, + ); + + await expect( + page.locator("perspective-viewer perspective-viewer-plugin"), + ).toBeVisible(); + }); + + test("re-adding after empty yields a working layout", async ({ page }) => { + await empty(page); + await page.evaluate(async (table) => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + await viewer.restore({ table }, { panel: "a" }); + // @ts-ignore + await viewer.addPanel({ table }); + }, TABLE); + + expect((await panel_names(page)).length).toBe(2); + + await page + .locator("perspective-viewer perspective-viewer-plugin") + .first() + .click({ button: "right" }); + await expect(page.locator("perspective-context-menu")).toBeVisible(); + }); +}); + +test.describe("Boots empty", () => { + async function goto_blank(page) { + await page.goto("/rust/perspective-viewer/test/html/blank.html"); + await page.waitForFunction( + () => + customElements.get("perspective-viewer") !== undefined && + window["WORKER"] !== undefined, + ); + } + + test("an unconfigured element has zero panels and no frame", async ({ + page, + }) => { + await goto_blank(page); + const names = await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + // @ts-ignore + return viewer.getPanelNames(); + }); + expect(names).toEqual([]); + await expect( + page.locator("perspective-viewer regular-layout-frame"), + ).toHaveCount(0); + await expect( + page.locator("perspective-viewer regular-layout"), + ).toHaveCount(1); + }); + + test("load(client) is inert — no phantom panel", async ({ page }) => { + await goto_blank(page); + const names = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + const worker = await window["WORKER"]; + // @ts-ignore + await viewer.load(worker); + // @ts-ignore + return viewer.getPanelNames(); + }); + // A `Client`-only load registers the client but creates no panel. + expect(names).toEqual([]); + }); +}); diff --git a/rust/perspective-viewer/test/js/settings_panel/settings_chrome.spec.ts b/rust/perspective-viewer/test/js/settings_panel/settings_chrome.spec.ts new file mode 100644 index 0000000000..ba91c936aa --- /dev/null +++ b/rust/perspective-viewer/test/js/settings_panel/settings_chrome.spec.ts @@ -0,0 +1,225 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// `#app_panel` settings-pane chrome (`.plan/SETTINGS_PANE_SKIP_PLAN.md`): +// with settings CLOSED the SplitPanel must render NO divider and NO empty +// leading pane (the phantom pair was interactable, pinned to the container +// edge), and toggling the sidebar must reconcile the main pane IN PLACE — +// the `` and the datagrid's `regular-table` are +// identity-stable across toggles. Runs on `basic-test.html` (real datagrid +// plugin) so the regular-table invariant is checkable. + +import { test, expect } from "../helpers.ts"; +import { armLayoutCanary, assertCoherent } from "../multi_panel/harness.ts"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +async function setSettings(page, open: boolean): Promise { + await page.evaluate(async (open) => { + const viewer = document.querySelector("perspective-viewer")! as any; + await viewer.restore({ settings: open }); + }, open); +} + +/// Direct-child chrome of the `#app_panel` SplitPanel — a skipped pane must +/// take BOTH its `SplitPanelChild` wrapper and its divider with it. +function chrome(page) { + return { + dividers: page.locator("#app_panel > .split-panel-divider"), + panes: page.locator("#app_panel > .split-panel-child"), + }; +} + +async function widths(page): Promise<{ app: number; main: number }> { + return await page.evaluate(() => { + const shadow = + document.querySelector("perspective-viewer")!.shadowRoot!; + return { + app: shadow.querySelector("#app_panel")!.getBoundingClientRect() + .width, + main: shadow + .querySelector("#main_column_container")! + .getBoundingClientRect().width, + }; + }); +} + +/// Drag the settings divider by `dx` CSS pixels (negative = left, which +/// WIDENS the right-docked sidebar under `reverse` orientation). +async function dragDivider(page, dx: number): Promise { + const divider = page.locator("#app_panel > .split-panel-divider"); + const box = (await divider.boundingBox())!; + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + for (let i = 1; i <= 4; i++) { + await page.mouse.move(x + (dx * i) / 4, y); + } + + await page.mouse.up(); +} + +async function settingsPaneWidth(page): Promise { + const box = await page + .locator("#app_panel > .split-panel-child") + .boundingBox(); + return box!.width; +} + +test.describe("Settings pane chrome", () => { + test("closed: no divider, no empty pane, main column spans the stage", async ({ + page, + }) => { + const { dividers, panes } = chrome(page); + await expect(dividers).toHaveCount(0); + await expect(panes).toHaveCount(0); + + const { app, main } = await widths(page); + expect(Math.abs(app - main)).toBeLessThanOrEqual(1); + }); + + test("open: exactly one divider and one settings pane; close removes both", async ({ + page, + }) => { + await setSettings(page, true); + const { dividers, panes } = chrome(page); + await expect(dividers).toHaveCount(1); + await expect(panes).toHaveCount(1); + + await setSettings(page, false); + await expect(dividers).toHaveCount(0); + await expect(panes).toHaveCount(0); + + const { app, main } = await widths(page); + expect(Math.abs(app - main)).toBeLessThanOrEqual(1); + }); + + test("toggling preserves regular-layout AND regular-table identity", async ({ + page, + }) => { + await armLayoutCanary(page); + + // The datagrid's elements must survive the toggles as the SAME + // instances — a remount here would tear down the regular-table. + await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + const datagrid = viewer.querySelector( + "perspective-viewer-datagrid", + )! as any; + const table = + datagrid.querySelector("regular-table") ?? + datagrid.shadowRoot?.querySelector("regular-table"); + (globalThis as any).__PSP_DATAGRID_CANARY = { datagrid, table }; + }); + + for (let i = 0; i < 3; i++) { + await setSettings(page, true); + await expect(chrome(page).dividers).toHaveCount(1); + await setSettings(page, false); + await expect(chrome(page).dividers).toHaveCount(0); + } + + await assertCoherent(page); + const identity = await page.evaluate(() => { + const viewer = document.querySelector("perspective-viewer")!; + const canary = (globalThis as any).__PSP_DATAGRID_CANARY; + const datagrid = viewer.querySelector( + "perspective-viewer-datagrid", + ) as any; + const table = + datagrid?.querySelector("regular-table") ?? + datagrid?.shadowRoot?.querySelector("regular-table"); + return { + same_datagrid: canary.datagrid === datagrid, + same_table: canary.table != null && canary.table === table, + connected: !!datagrid?.isConnected && !!table?.isConnected, + }; + }); + + expect(identity).toEqual({ + same_datagrid: true, + same_table: true, + connected: true, + }); + }); + + test("dragged sidebar width is remembered across close/reopen", async ({ + page, + }) => { + await setSettings(page, true); + const w0 = await settingsPaneWidth(page); + await dragDivider(page, -80); + + // The deferred presize pump commits the width asynchronously. + await expect + .poll(async () => Math.abs((await settingsPaneWidth(page)) - w0)) + .toBeGreaterThan(40); + + const w1 = await settingsPaneWidth(page); + await setSettings(page, false); + await setSettings(page, true); + await expect + .poll(async () => Math.abs((await settingsPaneWidth(page)) - w1)) + .toBeLessThanOrEqual(2); + }); + + test("closing settings mid-drag ends the drag cleanly", async ({ + page, + }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + await setSettings(page, true); + + // Start a drag but do NOT release. + const divider = page.locator("#app_panel > .split-panel-divider"); + const box = (await divider.boundingBox())!; + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x - 30, y); + await expect + .poll(() => page.evaluate(() => document.body.style.cursor)) + .toBe("col-resize"); + + // Close BY CONFIGURATION mid-drag: the pane (and its divider) + // unrender, and dropping the drag must restore the global cursor + // WITHOUT waiting for a pointerup that may never come. + await setSettings(page, false); + await expect + .poll(() => page.evaluate(() => document.body.style.cursor)) + .toBe(""); + + await expect(chrome(page).dividers).toHaveCount(0); + + // The stray release must be harmless, and a fresh open + drag must + // work end-to-end. + await page.mouse.move(x - 60, y); + await page.mouse.up(); + await setSettings(page, true); + const w0 = await settingsPaneWidth(page); + await dragDivider(page, -50); + await expect + .poll(async () => Math.abs((await settingsPaneWidth(page)) - w0)) + .toBeGreaterThan(20); + + expect(errors).toEqual([]); + }); +}); diff --git a/rust/perspective-viewer/test/js/viewer_api/table_lifecycle.spec.ts b/rust/perspective-viewer/test/js/viewer_api/table_lifecycle.spec.ts new file mode 100644 index 0000000000..3bebcd50ae --- /dev/null +++ b/rust/perspective-viewer/test/js/viewer_api/table_lifecycle.spec.ts @@ -0,0 +1,161 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Reactive named-table binding: a panel's `table` config is a live function +// of the client's hosted-tables set. A name that isn't hosted yet PENDS +// (no error) and binds when the table is created; a bound table deleted +// under the viewer is released (its `View` closed, so a lazy delete can +// complete and the name recreated) and rebinds when the name reappears. + +import { test, expect } from "../helpers.ts"; + +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)); + } + }); +}); + +test.describe("Reactive table lifecycle", () => { + test("restore({table}) before the table exists pends, then binds on creation", 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); + await v.load(worker); + let restoreError: string | null = null; + try { + await v.restore({ + table: "lifecycle-t1", + columns: ["a"], + group_by: ["b"], + }); + } catch (e) { + restoreError = String(e); + } + + const pendingSave = await v.save(); + + // Creating the table must complete the bind reactively. + await worker.table("a,b\n1,x\n2,y", { name: "lifecycle-t1" }); + let bound = null; + for (let i = 0; i < 100 && !bound; i++) { + bound = await v.getTable().catch(() => null); + if (!bound) { + await new Promise((x) => setTimeout(x, 50)); + } + } + + await v.flush(); + const boundSave = await v.save(); + return { + restoreError, + pendingTable: pendingSave.table, + boundTable: boundSave.table, + groupBy: boundSave.group_by, + bound: !!bound, + }; + }); + + expect(result.restoreError).toBeNull(); + expect(result.pendingTable).toBe("lifecycle-t1"); + expect(result.bound).toBe(true); + expect(result.boundTable).toBe("lifecycle-t1"); + expect(result.groupBy).toEqual(["b"]); + }); + + test("a lazily-deleted table is released and rebinds on re-creation", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const worker = (window as any).__TEST_WORKER__; + const table = await worker.table("a,b\n1,x\n2,y", { + name: "lifecycle-t2", + }); + + const v = document.createElement("perspective-viewer") as any; + document.body.appendChild(v); + await v.load(worker); + await v.restore({ table: "lifecycle-t2", columns: ["a"] }); + await v.flush(); + + // Deleting under the live viewer must release its `View` so the + // lazy delete completes and the NAME becomes recreatable. NOT + // awaited — the promise only resolves once the last view closes, + // which is exactly what this test is probing. + const lazyDelete = table.delete({ lazy: true }); + lazyDelete.catch(() => {}); + let recreated = null; + let recreateError: string | null = null; + for (let i = 0; i < 100 && !recreated; i++) { + try { + recreated = await worker.table("a,b\n3,z", { + name: "lifecycle-t2", + }); + } catch (e) { + recreateError = String(e); + await new Promise((x) => setTimeout(x, 50)); + } + } + + if (!recreated) { + return { recreated: false, recreateError }; + } + + // ... and the panel rebinds to the NEW table. + let bound = null; + for (let i = 0; i < 100 && !bound; i++) { + bound = await v.getTable().catch(() => null); + if (!bound) { + await new Promise((x) => setTimeout(x, 50)); + } + } + + await v.flush(); + const save = await v.save(); + return { + recreated: true, + recreateError: null, + rebound: !!bound, + table: save.table, + }; + }); + + expect(result.recreateError).toBeNull(); + expect(result.recreated).toBe(true); + expect(result.rebound).toBe(true); + expect(result.table).toBe("lifecycle-t2"); + }); + + test("delete() of an errored viewer resolves", async ({ page }) => { + const deleteError = await page.evaluate(async () => { + const v = document.createElement("perspective-viewer") as any; + document.body.appendChild(v); + try { + await v.load(Promise.reject(new Error("boom"))); + } catch {} + + try { + await v.delete(); + return null; + } catch (e) { + return String(e); + } + }); + + expect(deleteError).toBeNull(); + }); +}); diff --git a/tools/test/src/js/models/page.ts b/tools/test/src/js/models/page.ts index 104d7f982d..93c607621b 100644 --- a/tools/test/src/js/models/page.ts +++ b/tools/test/src/js/models/page.ts @@ -29,7 +29,6 @@ export class PageView { container: Locator; settingsPanel: SettingsPanel; settingsCloseButton: Locator; - /** Opens the settings panel. */ settingsButton: Locator; columnSettingsSidebar: ColumnSettingsSidebar; @@ -42,7 +41,9 @@ export class PageView { this.settingsCloseButton = this.container.locator( "#settings_close_button", ); - this.settingsButton = this.container.locator("#settings_button"); + this.settingsButton = this.container + .locator("perspective-viewer-tab .psp-tab-settings") + .first(); this.columnSettingsSidebar = new ColumnSettingsSidebar(this); this.settingsPanel = new SettingsPanel(this);