From 06b45d2315bf3ae621c61dfbf18b8a2cda126c63 Mon Sep 17 00:00:00 2001 From: gherkin Date: Tue, 27 Oct 2026 22:51:34 +0100 Subject: [PATCH 1/5] A part clicked in the net pane becomes the selected part (#148) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/interaction.spec.js | 61 +++++++++++++++++++++++++++++++ web/app.js | 23 +++++++----- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index 2fcba23..960e460 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -379,6 +379,67 @@ test('clicking a part row again unpins and deselects it', async ({ page }) => { await expect(page.locator('#detail-pane')).toBeHidden(); }); +// A part X on another net is selected, then a part Y is clicked in the net pane. +async function selectPartThenNetPanePart(page) { + const net = frontNets[0]; + await selectNetFromList(page, net); + const x = footprints.findIndex((fp, i) => !netToFootprints[net].has(i)); + await page.locator('#tab-components').click(); + await page.locator(`#comp-tbody .comp-row[data-idx="${x}"]`).click(); + await settle(page); + await page.locator('#tab-nets').click(); + + const yRef = await page.locator('#net-results .net-comp-row .net-comp-ref').first().textContent(); + const y = footprints.findIndex((fp) => fp.ref === yRef); + await page.locator('#net-results .net-comp-row').first().click(); + await settle(page); + return { x, y }; +} + +test('a part clicked in the net pane becomes the selected part', async ({ page }) => { + await load(page); + const { x, y } = await selectPartThenNetPanePart(page); + + expect((await state(page)).selectedFootprintIdx).toBe(y); + expect(await page.evaluate(([x, y]) => [!!pinnedComponents[x], !!pinnedComponents[y]], [x, y])) + .toEqual([true, true]); + await expect(page.locator('#net-comp-detail .detail-ref')).toHaveText(footprints[y].ref); + await expect(page.locator('#detail-pane .detail-ref')).toHaveText(footprints[y].ref); + await expect(page.locator(`#comp-tbody .comp-row[data-idx="${y}"]`)).toHaveClass(/selected/); + expect(await page.evaluate(() => location.hash)).not.toContain('focus='); +}); + +test('a part clicked again in the net pane gives the selection back', async ({ page }) => { + await load(page); + const { x, y } = await selectPartThenNetPanePart(page); + await page.locator('#net-results .net-comp-row', { + has: page.locator('.net-comp-ref', { hasText: new RegExp('^' + escapeRe(footprints[y].ref) + '$') }), + }).click(); + await settle(page); + + expect((await state(page)).selectedFootprintIdx).toBe(x); + expect(await page.evaluate((y) => !!pinnedComponents[y], y)).toBe(false); + await expect(page.locator('#net-comp-detail .detail-ref')).toHaveText(footprints[x].ref); + await expect(page.locator('#detail-pane .detail-ref')).toHaveText(footprints[x].ref); + await expect(page.locator(`#comp-tbody .comp-row[data-idx="${x}"]`)).toHaveClass(/selected/); + expect(await page.evaluate(() => location.hash)) + .not.toMatch(new RegExp('comp=' + escapeRe(footprints[y].ref) + '(&|$)')); +}); + +test('a part clicked again in the net pane with nothing before it deselects', async ({ page }) => { + await load(page); + await selectNetFromList(page, frontNets[0]); + const row = page.locator('#net-results .net-comp-row').first(); + await row.click(); + await settle(page); + await page.locator('#net-results .net-comp-row').first().click(); + await settle(page); + + expect((await state(page)).selectedFootprintIdx).toBeNull(); + await expect(page.locator('#net-comp-detail')).toBeHidden(); + await expect(page.locator('#detail-pane')).toBeHidden(); +}); + // #1: the nets stayed in the breadcrumbs, but the canvas showed only the // component. Hovering a row did not stash highlightedNetPath, so leaving the // row cleared it. diff --git a/web/app.js b/web/app.js index 06c9546..712e2a9 100644 --- a/web/app.js +++ b/web/app.js @@ -22,6 +22,7 @@ var componentByRef = {}; // UPPER ref → fpIdx // Selection state var selectedFootprintIdx = null; +var selectedBeforeNetPane = null; // part selected before a net pane click took over var selectedNet = null; var highlightedFootprints = []; var highlightedNet = null; @@ -955,23 +956,26 @@ function populateNetResults(netName) { layerSpan.textContent = fp.layer; row.appendChild(layerSpan); + // The part becomes the selected part. Clicked again, it comes off and + // the part selected before it, if still pinned, is selected again. row.addEventListener("click", function(e) { hoverClear(); togglePinComponent(fpIdx); if (!pinnedComponents[fpIdx]) { + var back = selectedBeforeNetPane; + selectedBeforeNetPane = null; renderDetailPane(null, false); - deselectFootprint(fpIdx); + if (selectedFootprintIdx === fpIdx && back !== null && pinnedComponents[back]) { + selectFootprint(back, false); + renderDetailPane(back, false); + } else { + deselectFootprint(fpIdx); + } return; } + if (selectedFootprintIdx !== fpIdx) selectedBeforeNetPane = selectedFootprintIdx; + selectFootprint(fpIdx, true); renderDetailPane(fpIdx, false); - updateCompListSelection(fpIdx); - redrawAllIfDone(); - var targetLayer = pcbdata.footprints[fpIdx].layer; - var canvasdict = targetLayer === "B" ? allcanvas.back : allcanvas.front; - if (settings.canvaslayout !== "FB" && settings.canvaslayout !== targetLayer) { - setCanvasLayout(targetLayer); - } - zoomToFootprint(fpIdx, canvasdict); }); row.addEventListener("mouseenter", (function(idx) { return function() { hoverFootprint(idx); }; @@ -1316,6 +1320,7 @@ function deselectNet(netName) { function deselect() { selectedFootprintIdx = null; + selectedBeforeNetPane = null; selectedNet = null; highlightedFootprints = []; hoverNets = []; From 6390e788b3a83a83f8ff2741e7421f4cc3fe4ab6 Mon Sep 17 00:00:00 2001 From: gherkin Date: Wed, 28 Oct 2026 07:28:42 +0100 Subject: [PATCH 2/5] Net pane: unpinning a part that is not selected keeps the selection The saved part is now cleared by every other selection path, and is only used when the selected part comes off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/interaction.spec.js | 24 ++++++++++++++++++++++++ web/app.js | 13 +++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index 960e460..6746975 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -384,6 +384,7 @@ async function selectPartThenNetPanePart(page) { const net = frontNets[0]; await selectNetFromList(page, net); const x = footprints.findIndex((fp, i) => !netToFootprints[net].has(i)); + expect(x).toBeGreaterThanOrEqual(0); await page.locator('#tab-components').click(); await page.locator(`#comp-tbody .comp-row[data-idx="${x}"]`).click(); await settle(page); @@ -426,6 +427,29 @@ test('a part clicked again in the net pane gives the selection back', async ({ p .not.toMatch(new RegExp('comp=' + escapeRe(footprints[y].ref) + '(&|$)')); }); +test('a part taken off in the net pane that is not selected leaves the selection', async ({ page }) => { + await load(page); + const { y } = await selectPartThenNetPanePart(page); + const rowFor = (ref) => page.locator('#net-results .net-comp-row', { + has: page.locator('.net-comp-ref', { hasText: new RegExp('^' + escapeRe(ref) + '$') }), + }); + const zRef = await page.locator('#net-results .net-comp-row .net-comp-ref').nth(1).textContent(); + const z = footprints.findIndex((fp) => fp.ref === zRef); + expect(z).not.toBe(y); + await rowFor(zRef).click(); + await settle(page); + await rowFor(footprints[y].ref).click(); + await settle(page); + + expect((await state(page)).selectedFootprintIdx).toBe(z); + await expect(page.locator('#net-comp-detail .detail-ref')).toHaveText(zRef); + await expect(page.locator('#detail-pane .detail-ref')).toHaveText(zRef); + + await rowFor(zRef).click(); + await settle(page); + expect((await state(page)).selectedFootprintIdx).toBeNull(); +}); + test('a part clicked again in the net pane with nothing before it deselects', async ({ page }) => { await load(page); await selectNetFromList(page, frontNets[0]); diff --git a/web/app.js b/web/app.js index 712e2a9..651a0d9 100644 --- a/web/app.js +++ b/web/app.js @@ -878,6 +878,7 @@ function buildLayerFilterButtons() { function selectNet(netName) { selectedNet = netName; selectedFootprintIdx = null; + selectedBeforeNetPane = null; highlightedNet = netName; highlightedFootprints = []; hoverNets = []; @@ -962,20 +963,22 @@ function populateNetResults(netName) { hoverClear(); togglePinComponent(fpIdx); if (!pinnedComponents[fpIdx]) { + // A part that is not the selected part only loses its pin. + if (selectedFootprintIdx !== fpIdx) return; var back = selectedBeforeNetPane; - selectedBeforeNetPane = null; - renderDetailPane(null, false); - if (selectedFootprintIdx === fpIdx && back !== null && pinnedComponents[back]) { + if (back !== null && pinnedComponents[back]) { selectFootprint(back, false); renderDetailPane(back, false); } else { + renderDetailPane(null, false); deselectFootprint(fpIdx); } return; } - if (selectedFootprintIdx !== fpIdx) selectedBeforeNetPane = selectedFootprintIdx; + var before = selectedFootprintIdx; selectFootprint(fpIdx, true); renderDetailPane(fpIdx, false); + selectedBeforeNetPane = before !== fpIdx ? before : null; }); row.addEventListener("mouseenter", (function(idx) { return function() { hoverFootprint(idx); }; @@ -1042,6 +1045,7 @@ function populateNetResults(netName) { function selectFootprint(fpIdx, zoomTo) { selectedFootprintIdx = fpIdx; + selectedBeforeNetPane = null; // Canvas color comes from pinnedComponents; highlightedFootprints is hover-only. var fp = pcbdata.footprints[fpIdx]; @@ -1067,6 +1071,7 @@ function selectFootprint(fpIdx, zoomTo) { function deselectFootprint(fpIdx) { if (selectedFootprintIdx !== fpIdx) return; selectedFootprintIdx = null; + selectedBeforeNetPane = null; renderDetailPane(null); updateCompListSelection(null); updateHashFromSelection(); From dc3ea6df0fbda8bd398aead2d1f21f83f93c73a5 Mon Sep 17 00:00:00 2001 From: gherkin Date: Wed, 28 Oct 2026 07:49:42 +0100 Subject: [PATCH 3/5] The list and the canvas give the selection back too A second click on the selected part, in the component list, the net pane or on the board, takes its pin off and selects the part before it again, if that part is still pinned. The three click paths now share clickFootprint, so they behave the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/interaction.spec.js | 40 ++++++++++++++++++++ web/app.js | 61 +++++++++++++++---------------- 2 files changed, 69 insertions(+), 32 deletions(-) diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index 6746975..213f7d8 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -379,6 +379,46 @@ test('clicking a part row again unpins and deselects it', async ({ page }) => { await expect(page.locator('#detail-pane')).toBeHidden(); }); +test('clicking a part row again gives the selection back', async ({ page }) => { + await load(page); + const x = 0; + const y = 1; + await page.locator('#tab-components').click(); + const rowFor = (i) => page.locator(`#comp-tbody .comp-row[data-idx="${i}"]`); + await rowFor(x).click(); + await settle(page); + await rowFor(y).click(); + await settle(page); + expect((await state(page)).selectedFootprintIdx).toBe(y); + + await rowFor(y).click(); + await settle(page); + expect((await state(page)).selectedFootprintIdx).toBe(x); + expect(await page.evaluate((y) => !!pinnedComponents[y], y)).toBe(false); + await expect(rowFor(x)).toHaveClass(/selected/); + await expect(page.locator('#detail-pane .detail-ref')).toHaveText(footprints[x].ref); +}); + +test('clicking a part on the board again gives the selection back', async ({ page }) => { + await load(page); + const x = footprints.findIndex((fp) => fp.layer === 'F'); + await page.locator('#tab-components').click(); + await page.locator(`#comp-tbody .comp-row[data-idx="${x}"]`).click(); + await settle(page); + await page.keyboard.press('f'); + await waitIdle(page); + // match() runs in the page, so it compares with the page's selected part, x. + const spot = await canvasSpot(page, (hit) => 'footprint' in hit && hit.footprint !== selectedFootprintIdx); + const y = spot.hit.footprint; + + await clickCanvas(page, spot); + expect((await state(page)).selectedFootprintIdx).toBe(y); + await clickCanvas(page, spot); + expect((await state(page)).selectedFootprintIdx).toBe(x); + expect(await page.evaluate((y) => !!pinnedComponents[y], y)).toBe(false); + await expect(page.locator('#detail-pane .detail-ref')).toHaveText(footprints[x].ref); +}); + // A part X on another net is selected, then a part Y is clicked in the net pane. async function selectPartThenNetPanePart(page) { const net = frontNets[0]; diff --git a/web/app.js b/web/app.js index 651a0d9..4ee1704 100644 --- a/web/app.js +++ b/web/app.js @@ -22,7 +22,7 @@ var componentByRef = {}; // UPPER ref → fpIdx // Selection state var selectedFootprintIdx = null; -var selectedBeforeNetPane = null; // part selected before a net pane click took over +var selectedBefore = null; // part selected before the last click took over var selectedNet = null; var highlightedFootprints = []; var highlightedNet = null; @@ -637,9 +637,7 @@ function populateComponentList() { tr.addEventListener("click", function(e) { var idx = parseInt(this.dataset.idx); hoverClear(); // the pin shows the part from here on - togglePinComponent(idx); - if (pinnedComponents[idx]) selectFootprint(idx, true); - else deselectFootprint(idx); + clickFootprint(idx, true); }); tr.addEventListener("mouseenter", (function(idx) { return function() { hoverFootprint(idx); }; @@ -878,7 +876,7 @@ function buildLayerFilterButtons() { function selectNet(netName) { selectedNet = netName; selectedFootprintIdx = null; - selectedBeforeNetPane = null; + selectedBefore = null; highlightedNet = netName; highlightedFootprints = []; hoverNets = []; @@ -957,28 +955,9 @@ function populateNetResults(netName) { layerSpan.textContent = fp.layer; row.appendChild(layerSpan); - // The part becomes the selected part. Clicked again, it comes off and - // the part selected before it, if still pinned, is selected again. row.addEventListener("click", function(e) { hoverClear(); - togglePinComponent(fpIdx); - if (!pinnedComponents[fpIdx]) { - // A part that is not the selected part only loses its pin. - if (selectedFootprintIdx !== fpIdx) return; - var back = selectedBeforeNetPane; - if (back !== null && pinnedComponents[back]) { - selectFootprint(back, false); - renderDetailPane(back, false); - } else { - renderDetailPane(null, false); - deselectFootprint(fpIdx); - } - return; - } - var before = selectedFootprintIdx; - selectFootprint(fpIdx, true); - renderDetailPane(fpIdx, false); - selectedBeforeNetPane = before !== fpIdx ? before : null; + if (clickFootprint(fpIdx, true)) renderDetailPane(selectedFootprintIdx, false); }); row.addEventListener("mouseenter", (function(idx) { return function() { hoverFootprint(idx); }; @@ -1045,7 +1024,7 @@ function populateNetResults(netName) { function selectFootprint(fpIdx, zoomTo) { selectedFootprintIdx = fpIdx; - selectedBeforeNetPane = null; + selectedBefore = null; // Canvas color comes from pinnedComponents; highlightedFootprints is hover-only. var fp = pcbdata.footprints[fpIdx]; @@ -1067,11 +1046,31 @@ function selectFootprint(fpIdx, zoomTo) { } } +// A click on a part in the list, the net pane or the canvas. The first click +// pins the part and selects it. A second click on the selected part takes its +// pin off and selects the part before it again, if that part is still pinned. +// A click on a pinned part that is not selected only takes its pin off. +// Returns true when the click changed the selected part. +function clickFootprint(fpIdx, zoomTo) { + togglePinComponent(fpIdx); + if (pinnedComponents[fpIdx]) { + var before = selectedFootprintIdx; + selectFootprint(fpIdx, zoomTo); + selectedBefore = before !== fpIdx ? before : null; + return true; + } + if (selectedFootprintIdx !== fpIdx) return false; + var back = selectedBefore; + if (back !== null && pinnedComponents[back]) selectFootprint(back, false); + else deselectFootprint(fpIdx); + return true; +} + // A part clicked again after its pin came off is no longer the selected part. function deselectFootprint(fpIdx) { if (selectedFootprintIdx !== fpIdx) return; selectedFootprintIdx = null; - selectedBeforeNetPane = null; + selectedBefore = null; renderDetailPane(null); updateCompListSelection(null); updateHashFromSelection(); @@ -1325,7 +1324,7 @@ function deselectNet(netName) { function deselect() { selectedFootprintIdx = null; - selectedBeforeNetPane = null; + selectedBefore = null; selectedNet = null; highlightedFootprints = []; hoverNets = []; @@ -1392,12 +1391,10 @@ function onNetClickedFromCanvas(netName) { } function onFootprintClickedFromCanvas(fpIdx) { - togglePinComponent(fpIdx); - if (!pinnedComponents[fpIdx]) { deselectFootprint(fpIdx); return; } - selectFootprint(fpIdx, false); + if (!clickFootprint(fpIdx, false) || selectedFootprintIdx === null) return; // Switch to components tab to show the selection switchTab("components"); - updateCompListSelection(fpIdx); + updateCompListSelection(selectedFootprintIdx); // Scroll comp list to selected var row = document.querySelector("#comp-tbody .comp-row.selected"); if (row) row.scrollIntoView({ block: "nearest" }); From ac561401502ca4c029734be35a5774e977787f82 Mon Sep 17 00:00:00 2001 From: gherkin Date: Wed, 28 Oct 2026 08:10:13 +0100 Subject: [PATCH 4/5] A part that loses its pin is hidden in the net pane detail Wherever the pin comes off (list, net pane, board, or Esc), the net pane detail no longer shows that part. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/interaction.spec.js | 18 ++++++++++++++++++ web/app.js | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index 213f7d8..7a97007 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -490,6 +490,24 @@ test('a part taken off in the net pane that is not selected leaves the selection expect((await state(page)).selectedFootprintIdx).toBeNull(); }); +test('a part shown in the net pane is hidden there when its pin comes off elsewhere', async ({ page }) => { + await load(page); + const { x, y } = await selectPartThenNetPanePart(page); + const w = footprints.findIndex((fp, i) => i !== x && i !== y); + await page.locator('#tab-components').click(); + await page.locator(`#comp-tbody .comp-row[data-idx="${w}"]`).click(); + await settle(page); + await expect(page.locator('#net-comp-detail .detail-ref')).toHaveText(footprints[y].ref); + await page.locator(`#comp-tbody .comp-row[data-idx="${y}"]`).click(); + await settle(page); + + expect((await state(page)).selectedFootprintIdx).toBe(w); + expect(await page.evaluate((y) => !!pinnedComponents[y], y)).toBe(false); + await page.locator('#tab-nets').click(); + await expect(page.locator('#net-results')).toBeVisible(); + await expect(page.locator('#net-comp-detail')).toBeHidden(); +}); + test('a part clicked again in the net pane with nothing before it deselects', async ({ page }) => { await load(page); await selectNetFromList(page, frontNets[0]); diff --git a/web/app.js b/web/app.js index 4ee1704..851bdbb 100644 --- a/web/app.js +++ b/web/app.js @@ -23,6 +23,7 @@ var componentByRef = {}; // UPPER ref → fpIdx // Selection state var selectedFootprintIdx = null; var selectedBefore = null; // part selected before the last click took over +var netPaneDetailIdx = null; // part shown in the net pane detail var selectedNet = null; var highlightedFootprints = []; var highlightedNet = null; @@ -536,6 +537,8 @@ function togglePinComponent(fpIdx) { if (pinnedComponents[fpIdx]) { delete pinnedComponents[fpIdx]; unregisterSelection('comp', fpIdx); + // A part that loses its pin is not shown in the net pane detail. + if (netPaneDetailIdx === fpIdx) renderDetailPane(null, false); } else { registerSelection('comp', fpIdx); pinnedComponents[fpIdx] = getSelectionColor('comp', fpIdx); @@ -1091,6 +1094,7 @@ function renderDetailPane(fpIdx, showPads) { if (showPads === undefined) showPads = true; // When showPads is false we're in net context — render into the net panel's detail area var paneId = showPads ? "detail-pane" : "net-comp-detail"; + if (!showPads) netPaneDetailIdx = fpIdx; var pane = document.getElementById(paneId); var handle = document.getElementById("detail-resize-handle"); if (fpIdx === null) { @@ -1335,6 +1339,7 @@ function deselect() { linkMarkers = []; renderBreadcrumbs(); renderDetailPane(null); + renderDetailPane(null, false); document.querySelectorAll(".comp-row.selected").forEach(r => r.classList.remove("selected")); document.querySelectorAll(".net-comp-row.selected").forEach(r => r.classList.remove("selected")); // Return to search panel From e5cb5d9dad99cf8db370011e3e895b078b7ce112 Mon Sep 17 00:00:00 2001 From: gherkin Date: Wed, 28 Oct 2026 08:32:22 +0100 Subject: [PATCH 5/5] Test: check the net has two parts before taking the second Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/interaction.spec.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index 7a97007..60ee738 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -473,7 +473,9 @@ test('a part taken off in the net pane that is not selected leaves the selection const rowFor = (ref) => page.locator('#net-results .net-comp-row', { has: page.locator('.net-comp-ref', { hasText: new RegExp('^' + escapeRe(ref) + '$') }), }); - const zRef = await page.locator('#net-results .net-comp-row .net-comp-ref').nth(1).textContent(); + const refs = page.locator('#net-results .net-comp-row .net-comp-ref'); + expect(await refs.count()).toBeGreaterThan(1); + const zRef = await refs.nth(1).textContent(); const z = footprints.findIndex((fp) => fp.ref === zRef); expect(z).not.toBe(y); await rowFor(zRef).click();