From 62dfa222214ce318679220e01e54dc1d66169e12 Mon Sep 17 00:00:00 2001 From: gherkin Date: Tue, 27 Oct 2026 22:12:29 +0100 Subject: [PATCH 1/2] A second click on a highlighted net or a pinned part takes it off (#133) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/interaction.spec.js | 111 ++++++++++++++++++++++++++++-- web/app.js | 42 ++++++++++- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index 19756a5..5afa0ac 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -255,18 +255,115 @@ test('selecting the current net again keeps the walked nets highlighted (#38)', const aPx = await countColor(page, 'F', PALETTE[0]); expect(aPx).toBeGreaterThan(0); - // From the net list. - await selectNetFromList(page, b); - let s = await state(page); + // From the last breadcrumb. The net list takes the net off instead. + await page.locator('#breadcrumb-bar .breadcrumb-item').last().click(); + await settle(page); + const s = await state(page); expect(s.highlightedNetPath).toEqual([a, b]); expect(await countColor(page, 'F', PALETTE[0])).toBeGreaterThanOrEqual(aPx * 0.9); +}); - // From the last breadcrumb. - await page.locator('#breadcrumb-bar .breadcrumb-item').last().click(); +test('clicking a highlighted net in the list again takes only that net off', async ({ page }) => { + await load(page); + const [a, b] = frontNets; + await selectNetFromList(page, a); + await selectNetFromList(page, b); + + // An earlier net: the current net and its pane stay. + await selectNetFromList(page, a); + let s = await state(page); + expect(s.highlightedNetPath).toEqual([b]); + expect(s.selectedNet).toBe(b); + expect(await countColor(page, 'F', PALETTE[0], 200)).toBe(0); + expect(await countColor(page, 'F', PALETTE[1])).toBeGreaterThan(0); + + // The current net: back to the search list with nothing highlighted. + await selectNetFromList(page, b); + s = await state(page); + expect(s.highlightedNetPath).toEqual([]); + expect(s.selectedNet).toBeNull(); + expect(s.highlightedNet).toBeNull(); + await expect(page.locator('#net-search-panel')).toBeVisible(); + expect(await countColor(page, 'F', PALETTE[1], 200)).toBe(0); +}); + +// A spot on the front canvas, relative to its top left, where a click hits +// what match() accepts. +async function canvasSpot(page, match) { + const spot = await page.evaluate((src) => { + const match = eval(src); + const ld = allcanvas.front; + const div = document.getElementById('frontcanvas'); + for (let gy = 1; gy < 40; gy++) { + for (let gx = 1; gx < 40; gx++) { + const e = { offsetX: div.clientWidth * gx / 40, offsetY: div.clientHeight * gy / 40 }; + const hit = canvasHitScan('F', ...canvasToBoard(e, ld)); + if (hit && match(hit)) return { x: e.offsetX, y: e.offsetY, hit }; + } + } + return null; + }, match.toString()); + expect(spot).not.toBeNull(); + return spot; +} + +async function clickCanvas(page, spot) { + const box = await page.locator('#frontcanvas').boundingBox(); + await page.mouse.click(box.x + spot.x, box.y + spot.y); await settle(page); +} + +test('clicking a highlighted net on the board again takes it off', async ({ page }) => { + await load(page); + await page.keyboard.press('f'); + await waitIdle(page); + const spot = await canvasSpot(page, (hit) => 'net' in hit && hit.net); + + await clickCanvas(page, spot); + let s = await state(page); + expect(s.selectedNet).toBe(spot.hit.net); + expect(s.highlightedNetPath).toEqual([spot.hit.net]); + + await clickCanvas(page, spot); s = await state(page); - expect(s.highlightedNetPath).toEqual([a, b]); - expect(await countColor(page, 'F', PALETTE[0])).toBeGreaterThanOrEqual(aPx * 0.9); + expect(s.selectedNet).toBeNull(); + expect(s.highlightedNetPath).toEqual([]); +}); + +test('clicking a part on the board again unpins and deselects it', async ({ page }) => { + await load(page); + await page.keyboard.press('f'); + await waitIdle(page); + const spot = await canvasSpot(page, (hit) => 'footprint' in hit); + const idx = spot.hit.footprint; + + await clickCanvas(page, spot); + let s = await state(page); + expect(s.selectedFootprintIdx).toBe(idx); + await expect(page.locator('#detail-pane')).toBeVisible(); + + await clickCanvas(page, spot); + s = await state(page); + expect(s.selectedFootprintIdx).toBeNull(); + expect(await page.evaluate((i) => !!pinnedComponents[i], idx)).toBe(false); + await expect(page.locator('#detail-pane')).toBeHidden(); +}); + +test('clicking a part row again unpins and deselects it', async ({ page }) => { + await load(page); + const idx = footprints.findIndex((fp) => fp.layer === 'F'); + await page.locator('#tab-components').click(); + const row = page.locator(`#comp-tbody .comp-row[data-idx="${idx}"]`); + + await row.click(); + await settle(page); + expect((await state(page)).selectedFootprintIdx).toBe(idx); + + await row.click(); + await settle(page); + expect((await state(page)).selectedFootprintIdx).toBeNull(); + await expect(row).not.toHaveClass(/selected/); + await expect(page.locator('#detail-pane')).toBeHidden(); }); // #1: the nets stayed in the breadcrumbs, but the canvas showed only the diff --git a/web/app.js b/web/app.js index 1e09476..4926110 100644 --- a/web/app.js +++ b/web/app.js @@ -637,7 +637,8 @@ function populateComponentList() { var idx = parseInt(this.dataset.idx); hoverClear(); // the pin shows the part from here on togglePinComponent(idx); - selectFootprint(idx, true); + if (pinnedComponents[idx]) selectFootprint(idx, true); + else deselectFootprint(idx); }); tr.addEventListener("mouseenter", (function(idx) { return function() { hoverFootprint(idx); }; @@ -755,7 +756,8 @@ function populateNetSearchList() { row.addEventListener("click", function() { hoverClear(); - selectNet(netName); + if (netIsHighlighted(netName)) deselectNet(netName); + else selectNet(netName); }); row.addEventListener("mouseenter", (function(n) { return function() { hoverNet(n); }; @@ -956,6 +958,11 @@ function populateNetResults(netName) { row.addEventListener("click", function(e) { hoverClear(); togglePinComponent(fpIdx); + if (!pinnedComponents[fpIdx]) { + renderDetailPane(null, false); + deselectFootprint(fpIdx); + return; + } renderDetailPane(fpIdx, false); updateCompListSelection(fpIdx); redrawAllIfDone(); @@ -1052,6 +1059,15 @@ function selectFootprint(fpIdx, zoomTo) { } } +// A part clicked again after its pin came off is no longer the selected part. +function deselectFootprint(fpIdx) { + if (selectedFootprintIdx !== fpIdx) return; + selectedFootprintIdx = null; + renderDetailPane(null); + updateCompListSelection(null); + updateHashFromSelection(); +} + function updateCompListSelection(fpIdx) { document.querySelectorAll(".comp-row").forEach(function(row) { row.classList.toggle("selected", parseInt(row.dataset.idx) === fpIdx); @@ -1274,6 +1290,26 @@ function clearWalkHistory() { redrawAllIfDone(); } +function netIsHighlighted(netName) { + return netName === highlightedNet || highlightedNetPath.indexOf(netName) >= 0; +} + +// Take one net off: its walk steps and colour, and the net pane if it is the +// current net. The other nets and parts stay as they are. +function deselectNet(netName) { + netWalkHistory = netWalkHistory.filter(function(s) { + return !(s.type === "net" && s.value === netName); + }); + unregisterSelection('net', netName); + if (selectedNet === netName) { + selectedNet = null; + highlightedNet = null; + netGoBack(); + } + rebuildBreadcrumbs(); + updateHashFromSelection(); +} + function deselect() { selectedFootprintIdx = null; selectedNet = null; @@ -1335,6 +1371,7 @@ function initDetailResize() { // ---- Canvas click callbacks (called from render.js) ---- function onNetClickedFromCanvas(netName) { + if (netIsHighlighted(netName)) { deselectNet(netName); return; } document.getElementById("net-search-input").value = netName; selectNet(netName); switchTab("nets"); @@ -1342,6 +1379,7 @@ function onNetClickedFromCanvas(netName) { function onFootprintClickedFromCanvas(fpIdx) { togglePinComponent(fpIdx); + if (!pinnedComponents[fpIdx]) { deselectFootprint(fpIdx); return; } selectFootprint(fpIdx, false); // Switch to components tab to show the selection switchTab("components"); From 8d97049b17590cd1ed6d40750641058571dd690b Mon Sep 17 00:00:00 2001 From: gherkin Date: Tue, 27 Oct 2026 22:27:12 +0100 Subject: [PATCH 2/2] Taking a net off does not leave a repeated walk step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A › B › A without B gave A › A in the breadcrumbs and the highlighted path. pushWalkStep never adds a step that repeats the one before it, so deselectNet now drops such a step too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/interaction.spec.js | 13 +++++++++++++ web/app.js | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index 5afa0ac..2fcba23 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -287,6 +287,19 @@ test('clicking a highlighted net in the list again takes only that net off', asy expect(await countColor(page, 'F', PALETTE[1], 200)).toBe(0); }); +test('taking a net off the middle of A › B › A leaves one A', async ({ page }) => { + await load(page); + const [a, b] = frontNets; + await selectNetFromList(page, a); + await selectNetFromList(page, b); + await page.locator('#breadcrumb-bar .breadcrumb-item').first().click(); + await settle(page); + expect((await state(page)).highlightedNetPath).toEqual([a, b, a]); + + await selectNetFromList(page, b); + expect((await state(page)).highlightedNetPath).toEqual([a]); +}); + // A spot on the front canvas, relative to its top left, where a click hits // what match() accepts. async function canvasSpot(page, match) { diff --git a/web/app.js b/web/app.js index 4926110..06c9546 100644 --- a/web/app.js +++ b/web/app.js @@ -1297,8 +1297,12 @@ function netIsHighlighted(netName) { // Take one net off: its walk steps and colour, and the net pane if it is the // current net. The other nets and parts stay as they are. function deselectNet(netName) { + // Drop its steps, and a step that now repeats the one before it, as + // pushWalkStep never adds one: A › B › A without B is A. netWalkHistory = netWalkHistory.filter(function(s) { return !(s.type === "net" && s.value === netName); + }).filter(function(s, i, h) { + return i === 0 || h[i - 1].type !== s.type || h[i - 1].value !== s.value; }); unregisterSelection('net', netName); if (selectedNet === netName) {