diff --git a/tests/browser/backdrop.spec.js b/tests/browser/backdrop.spec.js index ca37dc1..fe3b854 100644 --- a/tests/browser/backdrop.spec.js +++ b/tests/browser/backdrop.spec.js @@ -39,6 +39,16 @@ async function backdrops(page) { return page.evaluate(() => window.__pcbaTest.counters().backdrops); } +// A backdrop for the current view is drawn. It is displayed only where the +// buffer does not reach, so this is not the same as being on screen. +async function ready(page) { + return page.evaluate(() => allcanvas.front._backdropReady === true); +} + +function shown(page) { + return page.evaluate(() => allcanvas.front.backdrop.style.display); +} + async function center(page) { const box = await page.locator('#frontcanvas').boundingBox(); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; @@ -147,6 +157,58 @@ test('a fast zoom out shows the board at the edges', async ({ page }) => { expect(agreement(without, fit)).toBeLessThan(0.9); }); +// The buffer is see-through outside the board outline. A backdrop under it +// showed there, scaled up and blurred, when the buffer covered the whole view. +test('the backdrop is not shown while the buffer covers the view', async ({ page }) => { + await load(page); + expect(await ready(page)).toBe(true); + expect(await shown(page)).toBe('none'); + + await zoomIn(page, 8); + expect(await shown(page)).toBe('none'); + + // A fast zoom out leaves the edges without buffer, and the backdrop shows. + expect(await page.evaluate(() => { + const t = allcanvas.front.transform; + t.zoom = 1; t.panx = 0; t.pany = 0; + updateCSSTransform(allcanvas.front); + return allcanvas.front.backdrop.style.display; + })).toBe('block'); + + // The renders catch up and it is hidden again. + await page.evaluate(() => redrawCanvas(allcanvas.front)); + await waitIdle(page); + expect(await shown(page)).toBe('none'); +}); + +// After a layer toggle the old backdrop showed outside the board outline +// until the new one came. The first render of the new state covers the view, +// so the old backdrop must be hidden from then on. +test('the old backdrop is not shown after a layer toggle', async ({ page }) => { + await load(page); + const first = await backdrops(page); + // Hold the worker's next backdrop, so only the renders come in. + await page.evaluate(() => { + window.__held = null; + _worker.onmessage = (e) => { + if (e.data.type === 'backdrop' && !window.__held) window.__held = e; + else handleWorkerMessage(e); + }; + }); + await page.keyboard.press('s'); + await page.waitForFunction(() => window.__held !== null, null, { timeout: 30000 }); + expect(await backdrops(page)).toBe(first); + expect(await shown(page)).toBe('none'); + + await page.evaluate(() => { + _worker.onmessage = handleWorkerMessage; + handleWorkerMessage(window.__held); + }); + await waitIdle(page); + expect(await backdrops(page)).toBe(first + 1); + expect(await shown(page)).toBe('none'); +}); + // The worker keeps the backdrop's layer canvases between draws. Cleared under // the last draw's transform, they kept part of the board at the old rotation. // Only a board off the positive quadrant shows it. After a few turns the @@ -157,6 +219,8 @@ test('the backdrop shows no old rotation after rotating', async ({ page }) => { for (const c of document.querySelectorAll('#frontcanvas canvas')) { if (c !== allcanvas.front.backdrop) c.style.visibility = 'hidden'; } + // The buffer covers the view, so the backdrop is not displayed. + allcanvas.front.backdrop.style.display = 'block'; }); return boardMask(page); }; @@ -184,22 +248,21 @@ test('the backdrop shows no old rotation after rotating', async ({ page }) => { // one, as a second board, until the new backdrop arrived. test('the old backdrop is hidden while the new rotation is drawn', async ({ page }) => { await load(page); - const shown = () => page.evaluate(() => allcanvas.front.backdrop.style.display); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); const first = await backdrops(page); expect(await page.evaluate(() => { rotateBoard(1); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return [allcanvas.front.backdrop.style.display, allcanvas.front._backdropReady]; + })).toEqual(['none', false]); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); expect(await backdrops(page)).toBe(first + 1); // A full turn brings back the old key, and it must still be drawn again. for (let i = 0; i < 3; i++) await page.evaluate(() => rotateBoard(1)); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); }); // A new size of the view gives a new fit. The backdrop at the old fit showed @@ -208,45 +271,45 @@ test('the old backdrop is hidden when the view changes size', async ({ page }) = // Both sides are drawn from FB on, which takes several seconds a step. test.setTimeout(60000); await load(page); - const shown = () => page.evaluate(() => allcanvas.front.backdrop.style.display); // The window. The resize event comes later, so record whether it was hidden. await page.evaluate(() => { window.__hidden = false; - const c = allcanvas.front.backdrop; - new MutationObserver(() => { - if (c.style.display === 'none') window.__hidden = true; - }).observe(c, { attributes: true, attributeFilter: ['style'] }); + const hide = window.hideBackdrop; + window.hideBackdrop = (ld) => { + if (ld === allcanvas.front) window.__hidden = true; + hide(ld); + }; }); await page.setViewportSize({ width: 1000, height: 650 }); await page.waitForFunction(() => window.__hidden, null, { timeout: 10000 }); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); // F to FB, as the G key does. expect(await page.evaluate(() => { showSide('FB'); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return allcanvas.front._backdropReady; + })).toBe(false); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); // Row to column. expect(await page.evaluate(() => { setCanvasDirection('column'); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return allcanvas.front._backdropReady; + })).toBe(false); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); }); // The same fit keeps the backdrop. A resize to the same size must not hide it. -test('a resize with the same fit keeps the backdrop shown', async ({ page }) => { +test('a resize with the same fit keeps the backdrop', async ({ page }) => { await load(page); expect(await page.evaluate(() => { resizeAll(); - return allcanvas.front.backdrop.style.display; - })).toBe('block'); + return allcanvas.front._backdropReady; + })).toBe(true); }); // A backdrop the worker is still drawing when the board turns is for the old @@ -269,12 +332,12 @@ test('a backdrop drawn for the old rotation is dropped', async ({ page }) => { rotateBoard(1); _worker.onmessage = handleWorkerMessage; handleWorkerMessage(window.__held); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return allcanvas.front._backdropReady; + })).toBe(false); expect(await backdrops(page)).toBe(first); await waitIdle(page); - expect(await page.evaluate(() => allcanvas.front.backdrop.style.display)).toBe('block'); + expect(await ready(page)).toBe(true); expect(await backdrops(page)).toBe(first + 1); }); diff --git a/web/render.js b/web/render.js index f238a4e..8124a68 100644 --- a/web/render.js +++ b/web/render.js @@ -200,6 +200,10 @@ function handleWorkerMessage(e) { // faster than the renders arrive, or past what the buffer holds, leaves the // edges empty. The backdrop shows there instead, blurred. It is moved by // CSS for pan and zoom, and drawn again only when what is drawn changes. +// +// It is shown only where the buffer does not reach. The buffer is see-through +// outside the board outline, and a backdrop under it shows there: blurred at +// a high zoom, and in the old state for a moment after a layer toggle. var _backdropTimers = {}; // side -> timeout var _backdropPending = {}; // side -> true while the worker draws it @@ -211,6 +215,7 @@ var _backdropStale = {}; // side -> drop the one the worker is drawing function hideBackdrop(layerdict) { if (!layerdict.backdrop) return; layerdict.backdrop.style.display = "none"; + layerdict._backdropReady = false; layerdict._backdropKey = null; if (_backdropPending[layerdict.layer]) _backdropStale[layerdict.layer] = true; } @@ -277,7 +282,7 @@ function blitBackdrop(msg) { ctx.clearRect(0, 0, c.width, c.height); ctx.drawImage(bitmap, 0, 0); bitmap.close(); - c.style.display = "block"; + layerdict._backdropReady = true; updateBackdropTransform(layerdict); if (!_stats[msg.side]) _stats[msg.side] = makeStatsTracker(); @@ -294,6 +299,28 @@ function updateBackdropTransform(layerdict) { var tx = t.zoom * t.panx / devicePixelRatio; var ty = t.zoom * t.pany / devicePixelRatio; c.style.transform = "translate(" + tx + "px," + ty + "px) scale(" + t.zoom + ")"; + c.style.display = layerdict._backdropReady && !bufferCoversView(layerdict) ? "block" : "none"; +} + +// Whether the drawn part of the buffer covers the whole view. Placed on +// screen as updateCSSTransform() places it. Until the overscan pieces are +// all in, only the viewport of the render is drawn. Compared in device +// pixels, as the worker sizes it: back in CSS pixels it can come out a hair +// short of the view. +function bufferCoversView(layerdict) { + var buf = layerdict._bufferState, r = layerdict._drawnRect; + if (!buf || !r) return false; + if (!_piecesPending[layerdict.layer]) r = { x: 0, y: 0, w: layerdict._bufW, h: layerdict._bufH }; + var div = document.getElementById(layerdict.layer === "B" ? "backcanvas" : "frontcanvas"); + if (!div) return false; + var t = layerdict.transform; + var os = layerdict._overscan || { x: 0, y: 0 }; + var k = t.zoom / buf.zoom; + var tx = t.zoom * (t.panx - buf.panx) - k * os.x; + var ty = t.zoom * (t.pany - buf.pany) - k * os.y; + return tx + k * r.x <= 0 && ty + k * r.y <= 0 && + tx + k * (r.x + r.w) >= div.clientWidth * devicePixelRatio && + ty + k * (r.y + r.h) >= div.clientHeight * devicePixelRatio; } // ---- Render performance stats ---- @@ -485,6 +512,7 @@ function blitBitmaps(msg) { // The canvases no longer match _bufferState, so the next render must not // carry them. layerdict._content = null; + layerdict._drawnRect = null; return; } @@ -505,6 +533,7 @@ function blitBitmaps(msg) { layerdict._bufW = msg.bufW; layerdict._bufH = msg.bufH; layerdict._content = content; + layerdict._drawnRect = msg.rect; // A layer with no bitmap has no content and is cleared. var canvases = pieceCanvases(layerdict, msg); @@ -632,7 +661,10 @@ function blitPiece(msg) { return; } drawPieceBitmaps(pieceCanvases(layerdict, msg), msg.rect); - if (msg.done) _piecesPending[msg.side] = false; + if (msg.done) { + _piecesPending[msg.side] = false; + updateBackdropTransform(layerdict); + } if (!_stats[msg.side]) _stats[msg.side] = makeStatsTracker(); _stats[msg.side].drawCalls += msg.drawCalls || 0;