From 4701cb1a877620646b718352766967456821865b Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Tue, 4 Aug 2026 10:03:07 -0700 Subject: [PATCH 1/3] Support dragging multi-target gates and groups, adds shift-expand to groups --- .../circuit-actions/groupClone.test.mjs | 235 ++++++++++++++ .../circuit-actions/groupMove.test.mjs | 168 ++++++++++ .../circuit-editor/dragController.test.mjs | 298 ++++++++++++++++++ .../test/circuit-editor/draggable.test.mjs | 114 ++++++- source/npm/qsharp/ux/circuit-vis/README.md | 30 +- .../actions/circuit-actions/move.ts | 182 ++++++++++- .../ux/circuit-vis/actions/circuitActions.ts | 81 ++++- .../editor/controllers/dragController.ts | 281 +++++++++++++++++ .../qsharp/ux/circuit-vis/editor/draggable.ts | 68 ++++ 9 files changed, 1425 insertions(+), 32 deletions(-) create mode 100644 source/npm/qsharp/test/circuit-editor/circuit-actions/groupClone.test.mjs diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/groupClone.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupClone.test.mjs new file mode 100644 index 00000000000..46ed52df9d0 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupClone.test.mjs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// addOperation: clone-copy of a group preserves shape. +// +// Ctrl-drag (clone) of a multi-wire op routes through the same +// rigid-shift path as `moveOperation`'s `_moveAsUnit`: every +// register in the cloned subtree shifts by the same +// `targetWire - sourceWire` delta, keeping `.targets` and every +// nested child wire aligned. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { addOperation } from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectOp, + gate, + group, + meas, + qubits, +} from "../_helpers.mjs"; + +test("addOperation: clone-copy of a group with delta>0 shifts every nested register", () => { + const model = build( + circuit(4, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + const sourceFoo = at(model, "0,0"); + + // clone Foo, grab on q0, drop on q2 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ 2, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { + Foo: { targets: [2, 3], children: [[{ H: 2 }, { X: 3 }]] }, + }); + // Original Foo is untouched (clone, not move). + expectOp(at(model, "0,0"), { + Foo: { targets: [0, 1], children: [[{ H: 0 }, { X: 1 }]] }, + }); +}); + +test("addOperation: clone-copy of a group with delta=0 preserves all children on their original wires", () => { + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + const sourceFoo = at(model, "0,0"); + + // clone Foo, grab on q0, drop on q0 (delta = 0, different column) + const cloned = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ 0, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { + Foo: { targets: [0, 1], children: [[{ H: 0 }, { X: 1 }]] }, + }); +}); + +test("addOperation: clone-copy of a multi-target gate preserves every leg", () => { + // The clone path must rigid-shift every leg by the same delta — + // collapsing `targets` to a single-wire stub would destroy a leg. + const model = build(circuit(4, [[gate("SWAP", [0, 1])]])); + const sourceSwap = at(model, "0,0"); + + // clone SWAP, grab on q0, drop on q2 (delta = +2) + const cloned = addOperation( + model, + sourceSwap, + "1,0", + /* targetWire */ 2, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { SWAP: [2, 3] }); +}); + +test("addOperation: clone-copy of a group containing an internal classical control shifts the classical ref in lockstep", () => { + // The cloned conditional H must read the CLONED measurement's + // classical register (c_2.0), not the original's (c_0.0). + const model = build( + circuit(4, [ + [ + group("Foo", [ + [meas(0)], + [gate("H", 1, { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ], + ]), + ); + const sourceFoo = at(model, "0,0"); + + // clone Foo, grab on q0, drop on q2 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ 2, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { + Foo: { + children: [ + [{ M: { qubits: [2], results: [{ q: 2, r: 0 }] } }], + [{ H: { targets: [3], ctrls: [{ q: 2, r: 0 }], conditional: true } }], + ], + }, + }); +}); + +test("addOperation: clone-copy of a classically-controlled op anchors its classical ref when the producer M is not cloned", () => { + // Mirror of the lockstep test, but the producing M lives OUTSIDE + // the cloned op. Foo reads c_0.0 produced by an external M. Cloning + // Foo with a wire delta must shift its quantum legs but ANCHOR the + // classical ref at q0.r0 — the original producer still owns it. + const model = build( + circuit(qubits(5, { 0: 1 }), [ + [meas(0)], + [gate("Foo", [1, 2], { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ); + const sourceFoo = at(model, "1,0"); + + // clone Foo, grab on q1, drop on q3 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "2,0", + /* targetWire */ 3, + /* insertNewColumn */ false, + /* sourceWire */ 1, + ); + + assert.ok(cloned, "clone returned an op"); + // Quantum legs shifted q1→q3, q2→q4; classical ctrl anchored at q0.r0. + expectOp(cloned, { + Foo: { targets: [3, 4], ctrls: [{ q: 0, r: 0 }], conditional: true }, + }); + // Original Foo and the external M are untouched. + expectOp(at(model, "1,0"), { + Foo: { targets: [1, 2], ctrls: [{ q: 0, r: 0 }], conditional: true }, + }); + expectOp(at(model, "0,0"), { M: { qubits: [0], results: [{ q: 0, r: 0 }] } }); +}); + +test("addOperation: clone-copy of a group anchors an internal child's classical ref when the producer M is outside the group", () => { + // The classically-dependent op is INSIDE the cloned group, but the + // producing M is OUTSIDE it (not cloned). Cloning the group with a + // wire delta shifts the child's quantum target but anchors its + // classical ref at q0.r0 — the external producer still owns it. + const model = build( + circuit(qubits(5, { 0: 1 }), [ + [meas(0)], + [ + group("Foo", [ + [gate("X", 1, { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ], + ]), + ); + const sourceFoo = at(model, "1,0"); + + // clone Foo, grab on q1, drop on q3 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "2,0", + /* targetWire */ 3, + /* insertNewColumn */ false, + /* sourceWire */ 1, + ); + + assert.ok(cloned, "clone returned an op"); + // Child X's target shifted q1→q3; classical ctrl anchored at q0.r0. + expectOp(cloned, { + Foo: { + children: [ + [{ X: { targets: [3], ctrls: [{ q: 0, r: 0 }], conditional: true } }], + ], + }, + }); + // Original Foo and the external M are untouched. + expectOp(at(model, "1,0"), { + Foo: { + children: [ + [{ X: { targets: [1], ctrls: [{ q: 0, r: 0 }], conditional: true } }], + ], + }, + }); + expectOp(at(model, "0,0"), { M: { qubits: [0], results: [{ q: 0, r: 0 }] } }); +}); + +test("addOperation: clone-copy that would push a wire below 0 returns null", () => { + // Grabbing Foo (wires 1-2) on q1 and dropping at q-1 computes + // delta = -2, underflowing wire 1 → -1. Returns null (no-op). + const model = build( + circuit(3, [[group("Foo", [[gate("H", 1), gate("X", 2)]])]]), + ); + const sourceFoo = at(model, "0,0"); + const before = JSON.stringify(model.componentGrid); + + // clone Foo, grab on q1, drop on q-1 (delta = -2, underflows) + const result = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ -1, + /* insertNewColumn */ false, + /* sourceWire */ 1, + ); + + assert.equal(result, null, "expected null when shift would underflow"); + assert.equal(JSON.stringify(model.componentGrid), before); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs index 7e9df9fbe3f..79fad039864 100644 --- a/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs @@ -22,6 +22,8 @@ import { expectOp, gate, group, + meas, + qubits, } from "../_helpers.mjs"; // --------------------------------------------------------------------------- @@ -66,6 +68,172 @@ test("moveOperation: moving a child out of a group updates the group's targets t expectOp(at(model, "0,0"), { Group: { targets: [1] } }); }); +// --------------------------------------------------------------------------- +// Dragging a group as a rigid unit. +// +// Moving a group shifts the group's own `.targets` AND recursively +// every register reference in its children grid by the same delta, +// so the box and its contents stay aligned. +// --------------------------------------------------------------------------- + +test("moveOperation: dragging a group shifts the box AND all child register refs", () => { + // Group with children H@0, CNOT(target=1, ctrl=0). Drag wire 0 + // → wire 2 (delta = +2). Box and children all shift by +2. + const model = build( + circuit(4, [ + [group("Group", [[gate("H", 0), gate("CNOT", 1, { ctrls: [0] })]])], + ]), + ); + + const moved = moveOperation(model, "0,0", "0,0", 0, 2, false, false); + assert.ok(moved); + + expectOp(at(model, "0,0"), { + Group: { + targets: [2, 3], + children: [[{ H: 2 }, { CNOT: { targets: [3], ctrls: [2] } }]], + }, + }); +}); + +// --------------------------------------------------------------------------- +// Classical-control anchoring on a moved group's children. +// --------------------------------------------------------------------------- + +test("moveOperation: moving a group with a classically-controlled child anchors the classical control", () => { + // External M produces the classical reg; the producer stays put, so + // X's target shifts but its classical control must anchor on q0. + const model = build( + circuit(qubits(4, { 0: 1 }), [ + [meas(0)], + [group("Group", [[gate("X", 1, { ctrls: [{ q: 0, r: 0 }] })]])], + ]), + ); + + // drag the group q1 → q2 (delta = +1) + moveOperation(model, "1,0", "1,0", 1, 2, false, false); + + expectOp(at(model, "1,0"), { + Group: { + children: [[{ X: { targets: [2], ctrls: [{ q: 0, r: 0 }] } }]], + }, + }); +}); + +test("moveOperation: moving a group whose internal measurement produces the classical reg shifts the consumer", () => { + // The producing M is INSIDE the moved subtree, so the classical reg + // moves too; the consumer's classical control shifts in lockstep. + const model = build( + circuit(qubits(4, { 1: 1 }), [ + [ + group("Group", [ + [meas(1)], + [gate("X", 1, { ctrls: [{ q: 1, r: 0 }] })], + ]), + ], + ]), + ); + + // drag the group q1 → q2 (delta = +1) + moveOperation(model, "0,0", "0,0", 1, 2, false, false); + + expectOp(at(model, "0,0"), { + Group: { + children: [ + [{ M: { qubits: [2], results: [{ q: 2, r: 0 }] } }], + [{ X: { targets: [2], ctrls: [{ q: 2, r: 0 }] } }], + ], + }, + }); + + // numResults bookkeeping must follow the measurement. + assert.equal( + model.qubits[1].numResults, + undefined, + "wire 1 must no longer claim a classical register", + ); + assert.equal( + model.qubits[2].numResults, + 1, + "wire 2 must now claim the classical register", + ); +}); + +test("moveOperation: unit-moving a multi-target gate with an external classical control anchors that control", () => { + // Multi-target gates take the same rigid unit-shift path as groups. + // External M produces the classical reg, so the quantum targets + // shift but the classical control must anchor on q0. + const model = build( + circuit(qubits(5, { 0: 1 }), [ + [meas(0)], + [gate("Foo", [1, 2], { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ); + + // drag the gate q1 → q3 (delta = +2) + moveOperation(model, "1,0", "1,0", 1, 3, false, false); + + // targets shift q1→q3, q2→q4; classical control anchored at q0.r0. + expectOp(at(model, "1,0"), { + Foo: { targets: [3, 4], ctrls: [{ q: 0, r: 0 }], conditional: true }, + }); +}); + +// --------------------------------------------------------------------------- +// Bounds-checking for unit-shift moves on groups. +// --------------------------------------------------------------------------- + +test("moveOperation: refuses a unit-shift that would push wires below 0", () => { + // Group spans wires 1-2. Grabbing on q2 and dropping on q0 is a + // delta = -2 shift, which would push the group's low wire (1) to -1. + const circuitLiteral = circuit(4, [ + [group("Group", [[gate("X", 1), gate("Y", 2)]])], + ]); + const before = JSON.stringify(circuitLiteral); + const model = build(circuitLiteral); + + // grab q2, drop on q0 → delta = -2, low wire 1 would underflow to -1 + const result = moveOperation(model, "0,0", "0,0", 2, 0, false, false); + + assert.equal(result, null, "move must be refused"); + assert.equal( + JSON.stringify({ + qubits: model.qubits, + componentGrid: model.componentGrid, + }), + before, + "refusal must not mutate the model", + ); +}); + +test("moveOperation: a unit-shift whose lowest wire lands exactly on 0 is allowed", () => { + // Boundary: span [1, 2] shifted by -1 lands on [0, 1] — exactly on 0 + // is still in-range, so the move succeeds. + const model = build( + circuit(4, [[group("Group", [[gate("X", 1), gate("Y", 2)]])]]), + ); + + // grab q1, drop on q0 (delta = -1) + const result = moveOperation(model, "0,0", "0,0", 1, 0, false, false); + assert.ok(result, "move must succeed when min post-shift wire is exactly 0"); + + expectOp(at(model, "0,0"), { Group: { targets: [0, 1] } }); +}); + +test("moveOperation: a unit-shift on a single-child group is bounded by the derived min wire", () => { + // The bounds check uses the derived min wire (here [1], from the lone + // X@1), not any pre-declared span: shift -1 → [0] is in-range. + const model = build(circuit(4, [[group("Group", [[gate("X", 1)]])]])); + + // grab q1, drop on q0 (delta = -1) + const result = moveOperation(model, "0,0", "0,0", 1, 0, false, false); + assert.ok(result, "move must succeed when derived min post-shift wire is 0"); + + expectOp(at(model, "0,0"), { + Group: { targets: [0], children: [[{ X: 0 }]] }, + }); +}); + // --------------------------------------------------------------------------- // Empty-group cleanup. // --------------------------------------------------------------------------- diff --git a/source/npm/qsharp/test/circuit-editor/dragController.test.mjs b/source/npm/qsharp/test/circuit-editor/dragController.test.mjs index 2ccc39d453b..842a79c321d 100644 --- a/source/npm/qsharp/test/circuit-editor/dragController.test.mjs +++ b/source/npm/qsharp/test/circuit-editor/dragController.test.mjs @@ -13,6 +13,7 @@ import assert from "node:assert/strict"; import { InteractionState } from "../../dist/ux/circuit-vis/actions/interactionState.js"; import { DragController } from "../../dist/ux/circuit-vis/editor/controllers/dragController.js"; import { QubitController } from "../../dist/ux/circuit-vis/editor/controllers/qubitController.js"; +import { Location } from "../../dist/ux/circuit-vis/data/location.js"; import { at, build, circuit, gate, group, meas } from "./_helpers.mjs"; /** @type {JSDOM | null} */ @@ -670,6 +671,303 @@ test("container mouseup teardown clears stale per-dropzone display marks", () => dragController.dispose(); }); +// --------------------------------------------------------------- +// Shift-extend lifecycle — contracts of the six private methods +// that own the shift-extend pathway: +// +// - `setupShiftExtend`: no-op for top-level sources; arms for +// internal-source drags. +// - `spawnShiftExtendDropzones`: emits dropzones only for wires +// outside the parent group's span, skips wires blocked by +// ancestor-column siblings, tags each dropzone with +// `data-shift-extend="true"`, and is re-spawn-safe. +// - `clearShiftExtendDropzones`: removes shift-extend dropzones, +// leaves regular dropzones alone. +// - `paintGhostBorder` / `clearGhostBorder`: append/replace a +// single `.shift-extend-ghost` rect in the overlay layer. +// - `tearDownShiftExtend`: clears dropzones, ghost border, +// `_shiftExtendCtx`, and the document shift-key listeners. +// +// Tests invoke the methods directly via `/** @type {any} */` casts +// and stage `layoutMap.scopes` manually. +// --------------------------------------------------------------- + +/** + * Install a `LayoutScope` for `parentLoc` into the controller's + * `ctx.layoutMap.scopes`. `columnXOffsets` defaults to a single + * column so `spawnShiftExtendDropzones`' `totalCols = real + 1` + * computes to 2 (one real + one trailing-append). + */ +function setScope( + /** @type {any} */ ctx, + /** @type {string} */ parentLoc, + columnXOffsets = [100], + columnWidths = [60], +) { + ctx.layoutMap.scopes.set(parentLoc, { columnXOffsets, columnWidths }); +} + +test("setupShiftExtend no-ops for a top-level source (depth < 2)", () => { + // Top-level ops have no ancestor group to extend. Calling + // setupShiftExtend with their `Location` must leave the controller + // disarmed — no `_shiftExtendCtx`, no installed shift listeners. + const { dragController } = setup(circuit(2, [[gate("H", 0)]])); + + /** @type {any} */ (dragController).setupShiftExtend(Location.parse("0,0")); + + assert.equal(/** @type {any} */ (dragController)._shiftExtendCtx, null); + assert.equal(/** @type {any} */ (dragController)._onShiftDown, null); + assert.equal(/** @type {any} */ (dragController)._onShiftUp, null); + + dragController.dispose(); +}); + +test("setupShiftExtend arms _shiftExtendCtx and installs shift listeners for an internal-source drag", () => { + // A child of an expanded group (depth=2). The controller must + // capture the parent group's wire span + scope and install + // document keydown/keyup listeners so the user can toggle the + // shift-extend UI mid-drag. + // Parent group Foo spans wires 0..1 because its children occupy + // q0 and q1; the dragged child H is at inner location "0,0". + const { dragController, ctx } = setup( + circuit(4, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + // setupShiftExtend looks up the IMMEDIATE parent's scope. + setScope(ctx, "0,0"); + + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + + const armed = /** @type {any} */ (dragController)._shiftExtendCtx; + assert.ok(armed, "_shiftExtendCtx must be populated"); + assert.equal(armed.parentLoc, "0,0"); + assert.equal(armed.parentMinWire, 0); + assert.equal(armed.parentMaxWire, 1); + assert.ok( + armed.parentScope.columnXOffsets, + "parentScope must carry layout geometry", + ); + + // Shift-key listeners installed (the toggle pathway). + assert.notEqual( + /** @type {any} */ (dragController)._onShiftDown, + null, + "keydown listener must be installed", + ); + assert.notEqual( + /** @type {any} */ (dragController)._onShiftUp, + null, + "keyup listener must be installed", + ); + + dragController.dispose(); +}); + +test("spawnShiftExtendDropzones emits dropzones only for wires outside the parent group's span", () => { + // Parent group spans wires 0..1. `wireData` covers wires 0..4 + // (4 qubits + trailing ghost). Spawn should emit dropzones for + // wires {2, 3, 4} only — wires {0, 1} are inside the span and + // already covered by regular inner dropzones. + // + // Per-column count: 3 wires × 2 columns (1 real + 1 trailing) = 6. + const { fixture, dragController, ctx } = setup( + circuit(4, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + setScope(ctx, "0,0"); + + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + /** @type {any} */ (dragController).spawnShiftExtendDropzones(); + + const spawned = fixture.dropzoneLayer.querySelectorAll("[data-shift-extend]"); + // Wires {2, 3, 4} × 2 columns = 6. + assert.equal(spawned.length, 6); + + const wires = new Set( + Array.from(spawned).map((d) => + Number(d.getAttribute("data-dropzone-wire")), + ), + ); + assert.deepEqual( + [...wires].sort((a, b) => a - b), + [2, 3, 4], + ); + + dragController.dispose(); +}); + +test("spawnShiftExtendDropzones skips wires blocked by ancestor-column siblings", () => { + // Top-level col 0 contains both the parent group (wires 0..1) AND + // a sibling X at wire 3. The filter marks wire 3 as blocked + // because dropping a child of the parent group onto wire 3 would + // have nowhere to go in the top-level column without colliding + // with X. + // + // Eligible outside-span wires: {2, 3, 4}. Blocked: {3}. Emitted: {2, 4}. + const { fixture, dragController, ctx } = setup( + circuit(4, [[group("Foo", [[gate("H", 0), gate("Z", 1)]]), gate("X", 3)]]), + ); + setScope(ctx, "0,0"); + + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + /** @type {any} */ (dragController).spawnShiftExtendDropzones(); + + const spawned = fixture.dropzoneLayer.querySelectorAll("[data-shift-extend]"); + // 2 unblocked wires × 2 columns = 4. + assert.equal(spawned.length, 4); + + const wires = new Set( + Array.from(spawned).map((d) => + Number(d.getAttribute("data-dropzone-wire")), + ), + ); + assert.deepEqual( + [...wires].sort((a, b) => a - b), + [2, 4], + ); + assert.ok( + !wires.has(3), + "wire 3 must be excluded — sibling X blocks it at the ancestor column", + ); + + dragController.dispose(); +}); + +test("spawnShiftExtendDropzones tags every dropzone and is re-spawn-safe", () => { + // Two contracts in one test (cheap to combine, hard to separate + // meaningfully): + // 1. Every spawned dropzone carries `data-shift-extend="true"` + // AND `data-dropzone-inter-column="false"` (so the mouseup + // handler doesn't insert a new column). + // 2. Calling spawn twice in a row leaves the layer with one + // copy, not two — the method clears its prior spawn first. + const { fixture, dragController, ctx } = setup( + circuit(3, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + setScope(ctx, "0,0"); + + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + + /** @type {any} */ (dragController).spawnShiftExtendDropzones(); + const firstSpawn = fixture.dropzoneLayer.querySelectorAll( + "[data-shift-extend]", + ); + assert.ok(firstSpawn.length > 0, "first spawn must emit some dropzones"); + // Every dropzone is tagged correctly. + for (const dz of Array.from(firstSpawn)) { + assert.equal(dz.getAttribute("data-shift-extend"), "true"); + assert.equal(dz.getAttribute("data-dropzone-inter-column"), "false"); + } + + // Re-spawn: count must NOT double. (Idempotency / re-arm safety.) + /** @type {any} */ (dragController).spawnShiftExtendDropzones(); + const secondSpawn = fixture.dropzoneLayer.querySelectorAll( + "[data-shift-extend]", + ); + assert.equal( + secondSpawn.length, + firstSpawn.length, + "second spawn must replace, not append", + ); + + dragController.dispose(); +}); + +test("paintGhostBorder appends a .shift-extend-ghost rect and replaces a prior one", () => { + // Each `paintGhostBorder` call clears the existing ghost before + // appending a new one, so the overlay never carries two ghost + // rects at once. + const { fixture, dragController, ctx } = setup( + circuit(3, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + setScope(ctx, "0,0"); + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + + // First paint — one ghost. + /** @type {any} */ (dragController).paintGhostBorder(2, 0); + let ghosts = fixture.overlay.querySelectorAll(".shift-extend-ghost"); + assert.equal(ghosts.length, 1); + const firstGhost = ghosts[0]; + + // Second paint at a different wire — old ghost replaced, not appended. + /** @type {any} */ (dragController).paintGhostBorder(0, 0); + ghosts = fixture.overlay.querySelectorAll(".shift-extend-ghost"); + assert.equal(ghosts.length, 1, "second paint must replace, not append"); + assert.notEqual( + ghosts[0], + firstGhost, + "new ghost element should be a fresh node", + ); + + // clearGhostBorder wipes it. + /** @type {any} */ (dragController).clearGhostBorder(); + ghosts = fixture.overlay.querySelectorAll(".shift-extend-ghost"); + assert.equal(ghosts.length, 0); + + dragController.dispose(); +}); + +test("tearDownShiftExtend clears dropzones, ghost border, _shiftExtendCtx, and shift listeners", () => { + // Full teardown chain. After teardown the controller is back to + // its initial unarmed state — no dropzones in the DOM, no ghost + // border, no listener refs. + const { fixture, dragController, ctx } = setup( + circuit(3, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + setScope(ctx, "0,0"); + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + /** @type {any} */ (dragController).spawnShiftExtendDropzones(); + /** @type {any} */ (dragController).paintGhostBorder(2, 0); + + // Sanity: state was actually armed. + assert.notEqual(/** @type {any} */ (dragController)._shiftExtendCtx, null); + assert.ok( + fixture.dropzoneLayer.querySelectorAll("[data-shift-extend]").length > 0, + ); + assert.equal( + fixture.overlay.querySelectorAll(".shift-extend-ghost").length, + 1, + ); + + /** @type {any} */ (dragController).tearDownShiftExtend(); + + // Everything cleared. + assert.equal(/** @type {any} */ (dragController)._shiftExtendCtx, null); + assert.equal(/** @type {any} */ (dragController)._onShiftDown, null); + assert.equal(/** @type {any} */ (dragController)._onShiftUp, null); + assert.deepEqual( + /** @type {any} */ (dragController)._shiftExtendDropzones, + [], + ); + assert.equal( + fixture.dropzoneLayer.querySelectorAll("[data-shift-extend]").length, + 0, + "shift-extend dropzones must be gone from the DOM", + ); + assert.equal( + fixture.overlay.querySelectorAll(".shift-extend-ghost").length, + 0, + "ghost border must be gone from the overlay", + ); + + // Idempotent — calling teardown a second time must not throw. + assert.doesNotThrow(() => + /** @type {any} */ (dragController).tearDownShiftExtend(), + ); + + dragController.dispose(); +}); + // --------------------------------------------------------------- // Remaining dragController paths. Each test pins a flow with a distinct model-side contract: // diff --git a/source/npm/qsharp/test/circuit-editor/draggable.test.mjs b/source/npm/qsharp/test/circuit-editor/draggable.test.mjs index 83cad29adc8..8f343a21d99 100644 --- a/source/npm/qsharp/test/circuit-editor/draggable.test.mjs +++ b/source/npm/qsharp/test/circuit-editor/draggable.test.mjs @@ -2,11 +2,13 @@ // Licensed under the MIT license. // Pure-helper unit tests for the editor's draggable module (`ux/circuit-vis/editor/draggable.ts`). -// Locks down the geometry and DOM-attribute contracts of the three exported helpers that +// Locks down the geometry and DOM-attribute contracts of the four exported helpers that // `dragController` and the rendering pipeline lean on: // // - `makeDropzoneBox`: inter-column vs on-column geometry, the trailing-append column past the // rightmost real column, and the `data-dropzone-*` attribute set used by `findParentArray`. +// - `makeShiftExtendGhost`: vertical span extension above/below the group, horizontal extension +// onto the trailing-append column, and the `shift-extend-ghost` CSS hook. // - `createWireDropzone`: full-width wire-spanning dropzone Y math, the `isBetween` cases that // target the gaps before the first / after the last wire. // - `removeAllWireDropzones`: targets `.dropzone-full-wire` only and leaves other overlay @@ -24,6 +26,7 @@ import assert from "node:assert/strict"; import { createWireDropzone, makeDropzoneBox, + makeShiftExtendGhost, removeAllWireDropzones, } from "../../dist/ux/circuit-vis/editor/draggable.js"; @@ -190,6 +193,115 @@ test("makeDropzoneBox: nested pathPrefix produces hierarchical location string", assert.equal(dz.getAttribute("data-dropzone-inter-column"), "false"); }); +// ─── makeShiftExtendGhost ─────────────────────────────────────────── + +test("makeShiftExtendGhost: hover above the group's span extends the rect upward", () => { + // Group spans wires [1, 2]; hover wire 0 (above the group). + // Vertical bounds: min(top wire Y, hover Y) - padding ... max(bottom wire Y, hover Y) + padding. + const scope = makeScope([100], [60]); + const wireData = [50, 150, 250, 350]; + + const ghost = makeShiftExtendGhost( + scope, + wireData, + /* groupMinWire */ 1, + /* groupMaxWire */ 2, + /* hoverWireIndex */ 0, + /* hoverColIndex */ 0, + ); + + assert.equal(ghost.getAttribute("class"), "shift-extend-ghost"); + // Top = min(150, 50) - 20 = 30 + assert.equal(attrNum(ghost, "y"), 50 - DROPZONE_PADDING_Y); + // Bottom = max(250, 50) + 20 = 270; height = 270 - 30 = 240 + assert.equal( + attrNum(ghost, "height"), + 250 + DROPZONE_PADDING_Y - (50 - DROPZONE_PADDING_Y), + ); +}); + +test("makeShiftExtendGhost: hover below the group's span extends the rect downward", () => { + // Group spans wires [0, 1]; hover wire 3 (below). + const scope = makeScope([100], [60]); + const wireData = [50, 150, 250, 350]; + + const ghost = makeShiftExtendGhost( + scope, + wireData, + /* groupMinWire */ 0, + /* groupMaxWire */ 1, + /* hoverWireIndex */ 3, + /* hoverColIndex */ 0, + ); + + // Top = min(50, 350) - 20 = 30 + assert.equal(attrNum(ghost, "y"), 50 - DROPZONE_PADDING_Y); + // Bottom = max(150, 350) + 20 = 370; height = 370 - 30 = 340 + assert.equal( + attrNum(ghost, "height"), + 350 + DROPZONE_PADDING_Y - (50 - DROPZONE_PADDING_Y), + ); +}); + +test("makeShiftExtendGhost: hover on the trailing-append column extends horizontally to include it", () => { + // Two real columns; hover on colIndex 2 (the trailing slot). The + // ghost rect should extend right to cover the synthesized column, + // not just the rightmost real column. + const scope = makeScope([100, 200], [60, 90]); + const wireData = [50, 150]; + + const ghostOnReal = makeShiftExtendGhost( + scope, + wireData, + 0, + 1, + 0, + /* hoverColIndex */ 1, + ); + const ghostOnTrailing = makeShiftExtendGhost( + scope, + wireData, + 0, + 1, + 0, + /* hoverColIndex */ 2, + ); + + // Hover on real rightmost: rightEdge = 200 + 90 = 290 + // Hover on trailing: rightEdge = (200 + 90 + 12) + 40 = 342 + // Left edge for both = colStartX(0) - gatePadding = 100 - 6 = 94 + // Width = rightEdge - colStartX(0) + 2*gatePadding + // = real: 290 - 100 + 12 = 202 + // = trailing: 342 - 100 + 12 = 254 + assert.equal(attrNum(ghostOnReal, "x"), 100 - GATE_PADDING); + assert.equal(attrNum(ghostOnReal, "width"), 290 - 100 + GATE_PADDING * 2); + assert.equal(attrNum(ghostOnTrailing, "x"), 100 - GATE_PADDING); + assert.equal( + attrNum(ghostOnTrailing, "width"), + 200 + 90 + GATE_PADDING * 2 + MIN_GATE_WIDTH - 100 + GATE_PADDING * 2, + ); + // Sanity: trailing footprint is strictly wider than the real one. + assert.ok( + attrNum(ghostOnTrailing, "width") > attrNum(ghostOnReal, "width"), + "trailing-column ghost should be wider than the real-column ghost", + ); +}); + +test("makeShiftExtendGhost: hover within the group span leaves vertical bounds at the group's wires", () => { + // Hover wire is inside the group's existing wire span — vertical + // bounds should land exactly on the group's wires (the min/max + // doesn't pull them anywhere new), only padded. + const scope = makeScope([100], [60]); + const wireData = [50, 150, 250, 350]; + + const ghost = makeShiftExtendGhost(scope, wireData, 1, 2, /* hover */ 2, 0); + + // Top = min(150, 250) - 20 = 130 + assert.equal(attrNum(ghost, "y"), 150 - DROPZONE_PADDING_Y); + // Bottom = max(250, 250) + 20 = 270; height = 140 + assert.equal(attrNum(ghost, "height"), 250 - 150 + DROPZONE_PADDING_Y * 2); +}); + // ─── createWireDropzone ───────────────────────────────────────────── /** Make an SVG element with a `width` attribute that mimics `svg.qviz`. */ diff --git a/source/npm/qsharp/ux/circuit-vis/README.md b/source/npm/qsharp/ux/circuit-vis/README.md index 9df96c06c7e..11d9307e257 100644 --- a/source/npm/qsharp/ux/circuit-vis/README.md +++ b/source/npm/qsharp/ux/circuit-vis/README.md @@ -316,22 +316,25 @@ interface InteractionContext { } ``` -Controllers are intentionally translation-only: they own their listeners and lifecycle, but hold no -state. State lives on `model` (persistent) or `interaction` (ephemeral). That's what lets -`dragController.test.mjs` etc. construct a controller with a hand-built context and exercise it -directly. +Controllers are intentionally translation-only: they own their listeners and lifecycle, and hold no +state _between_ gestures. Durable state lives on `model` (persistent) or `interaction` (ephemeral). +The one exception is transient, single-gesture bookkeeping a controller sets up and tears down +within one drag — `DragController`'s shift-extend fields (`_shiftExtendCtx`, +`_shiftExtendDropzones`, `_ghostBorder`, and the document keydown/keyup handlers) exist only between +`setupShiftExtend` and `tearDownShiftExtend`. That's what lets `dragController.test.mjs` etc. +construct a controller with a hand-built context and exercise it directly. --- ## Controller responsibilities -| Controller | Surface | Notes | -| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [DragController](editor/controllers/dragController.ts) | gate-drag, toolbox-drag, dropzone commit, document-level mouseup, add/remove-control wire-pick | Largest controller — these flows share dropzones/ghost/`interaction` flags so splitting wouldn't separate concerns. Holds a `QubitController` ref for the qubit-label drag-out-delete path. | -| [QubitController](editor/controllers/qubitController.ts) | qubit-label drag (swap + insert-between dropzones), `removeQubitLineWithConfirmation` | Public method called from two callers: context menu (via `CircuitEvents` shim) and `DragController`'s document-mouseup handler. | -| [SelectionController](editor/controllers/selectionController.ts) | host-element mousedown (sets `selectedWire`/`movingControl`), context-menu attach | Smallest controller; runs deeper in the DOM than `DragController`'s gate handler so its state mutation is visible by the time the drag handler runs. | -| [KeyboardController](editor/controllers/keyboardController.ts) | document `keydown`/`keyup` for Ctrl-toggle move/copy | Stateless; only consults whether `selectedOperation` has a location. | -| `enableAutoScroll` ([scrollController.ts](editor/controllers/scrollController.ts)) | document `mousemove` near container edges | Function not class — no shared state, called fresh by both gate-drag and qubit-drag. Self-removes on next mouseup. | +| Controller | Surface | Notes | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [DragController](editor/controllers/dragController.ts) | gate-drag, toolbox-drag, dropzone commit, document-level mouseup, add/remove-control wire-pick, shift-extend | Largest controller — these flows share dropzones/ghost/`interaction` flags so splitting wouldn't separate concerns. Holds a `QubitController` ref for the qubit-label drag-out-delete path, plus transient shift-extend state scoped to a single drag. | +| [QubitController](editor/controllers/qubitController.ts) | qubit-label drag (swap + insert-between dropzones), `removeQubitLineWithConfirmation` | Public method called from two callers: context menu (via `CircuitEvents` shim) and `DragController`'s document-mouseup handler. | +| [SelectionController](editor/controllers/selectionController.ts) | host-element mousedown (sets `selectedWire`/`movingControl`), context-menu attach | Smallest controller; runs deeper in the DOM than `DragController`'s gate handler so its state mutation is visible by the time the drag handler runs. | +| [KeyboardController](editor/controllers/keyboardController.ts) | document `keydown`/`keyup` for Ctrl-toggle move/copy | Stateless; only consults whether `selectedOperation` has a location. | +| `enableAutoScroll` ([scrollController.ts](editor/controllers/scrollController.ts)) | document `mousemove` near container edges | Function not class — no shared state, called fresh by both gate-drag and qubit-drag. Self-removes on next mouseup. | `CircuitEvents` itself ([events.ts](editor/events.ts)) is just wiring: build the context, instantiate each controller, expose `dispose()` and the two @@ -388,6 +391,7 @@ test/ │ │ ├── groupAddRemove.test.mjs add/remove inside expanded groups │ │ ├── groupAncestorRefresh.test.mjs ancestor derived-target refresh │ │ ├── groupCollisionSplit.test.mjs collision split within groups +│ │ ├── groupClone.test.mjs clone-drop grouped operations as a unit │ │ ├── groupMove.test.mjs moving grouped operations │ │ ├── measurementCascade.test.mjs measurement-dependency cascade │ │ ├── moveStamp.test.mjs move placement / stamping @@ -436,7 +440,9 @@ node --test "test/circuit-editor/**/*.test.mjs" ## Conventions -- **Controllers translate, they do not own.** No mutable state on the controller class itself. +- **Controllers translate, they do not own.** No durable state on the controller class itself — only + transient, single-gesture bookkeeping (e.g. `DragController`'s shift-extend fields, set up and torn + down within one drag). - **Actions mutate, they do not render.** No DOM, no `renderFn` calls inside `actions/`. Controllers re-render after dispatching. - **Locations go through `Location`.** Never hand-format `${col},${op}`; always diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/move.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/move.ts index 5f795cdd653..727ab54f888 100644 --- a/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/move.ts +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/move.ts @@ -5,15 +5,21 @@ import { Operation } from "../../data/circuit.js"; import { CircuitModel } from "../../data/circuitModel.js"; import { Location } from "../../data/location.js"; import { Register } from "../../data/register.js"; -import { findParentArray } from "../../utils.js"; +import { findParentArray, getOperationRegisters } from "../../utils.js"; import { addOp } from "./gridPrimitives.js"; +import { collectInternalClassicalRegs } from "./classicalRefs.js"; +import { refreshDerivedTargets } from "./derivedTargets.js"; /* * `move.ts` — the geometry of moving an operation. * - * Splits a move into horizontal (`moveX`: which column/grid) and vertical (`moveY`: which wires) - * components. The `moveOperation` orchestrator in `circuitActions.ts` drives these and handles the - * surrounding ancestor/measurement bookkeeping. Depends on `gridPrimitives`; no DOM. + * Splits a move into horizontal (`moveX`: which column/grid) and + * vertical (`moveY`: which wires) components, plus the register- + * shifting helpers that keep a multi-wire op's shape intact when it + * slides as a rigid unit. The `moveOperation` orchestrator in + * `circuitActions.ts` drives these and handles the surrounding + * ancestor/measurement bookkeeping. Depends on `gridPrimitives`, + * `classicalRefs`, `derivedTargets`; no DOM. */ /** @@ -46,6 +52,106 @@ const moveX = ( ); }; +/** + * Move `op` as one rigid unit (shift every register by the same + * delta) rather than rewiring just the grabbed register? + * + * Yes for multi-wire ops the user grabbed whole: groups (`children`), + * SWAPs, and multi-qubit measurements — single-leg would tear them + * apart. No for ordinary controlled gates (1 target + N controls), + * so each leg drags independently ("rewire one leg of a CNOT"). + * + * `movingControl` forces single-leg even on a group: dragging a + * control rewires just that control, it doesn't slide the group. + */ +const moveAsUnit = (op: Operation, movingControl: boolean): boolean => { + if (movingControl) return false; + if (op.children != null) return true; + switch (op.kind) { + case "unitary": + case "ket": + return op.targets.length > 1; + case "measurement": + return op.qubits.length > 1; + } +}; + +/** + * Shift every register of `op`, and recursively of its children, by + * `delta` — the rigid-unit move that keeps the gate's shape. + * + * Classical controls are the tricky part: a reference shifts only if + * the measurement it depends on is also moving. Producers inside the + * subtree shift (so their consumers do too); producers outside stay + * put (so consumers stay anchored). We collect the inside producers + * up front, then shift present refs and anchor absent ones. + */ +const shiftAllRegisters = (op: Operation, delta: number): void => { + if (delta === 0) return; + const internalProducers = collectInternalClassicalRegs(op); + _doShift(op, delta, internalProducers); +}; + +/** + * The recursive shift itself. See `shiftAllRegisters` for the + * classical-control rationale. + * + * Shifts all register fields, not just `controls`: a + * classically-conditional unitary also records the dependency in + * `targets` (the line drawn down to the classical register box). + * Shifting an external classical `targets` entry would point it at a + * wire with no registers, which the renderer rejects. + */ +const _doShift = ( + op: Operation, + delta: number, + internalProducers: Set, +): void => { + for (const reg of getOperationRegisters(op)) { + if (reg.result === undefined) { + reg.qubit += delta; + } else if (internalProducers.has(`${reg.qubit}:${reg.result}`)) { + reg.qubit += delta; + } + // else: external classical-register reference → anchor in place. + } + if (op.children) { + for (const col of op.children) { + for (const child of col.components) { + _doShift(child, delta, internalProducers); + } + } + } +}; + +/** + * Swap all references between `wireA` and `wireB` across `op`'s + * subtree — the "drop a control onto a body wire to swap them" + * gesture in `moveY`. Callers pass `op.children` so the group's own + * controls/targets are left for the caller to update. + * + * Classical entries swap by `qubit` like quantum ones; the + * external-producer anchoring from `_doShift` doesn't apply when + * swapping specific wires. + */ +const _swapWiresInSubtree = ( + op: Operation, + wireA: number, + wireB: number, +): void => { + for (const reg of getOperationRegisters(op)) { + if (reg.qubit === wireA) reg.qubit = wireB; + else if (reg.qubit === wireB) reg.qubit = wireA; + } + if (op.children) { + for (const col of op.children) { + for (const child of col.components) { + _swapWiresInSubtree(child, wireA, wireB); + } + } + } +}; + /** * Collect the wires that carry at least one measurement anywhere in `op`'s subtree, so their * per-wire `numResults` counters can be refreshed after a move. @@ -70,8 +176,24 @@ const collectMeasurementWires = (op: Operation, set: Set): void => { * `targets`/`results` refresh runs at the end of `moveOperation` instead, against the post-removal * children grid (otherwise the parent would keep claiming the departed child's wires). * - * Rewires the grabbed leg (one target or one control) to `targetWire`, leaving the other legs in - * place ("rewire one leg of a CNOT"). + * Two semantics, picked per-op by `moveAsUnit`: + * + * 1. **Unit-shift** for multi-wire ops (groups, SWAP, multi-qubit + * measurement). The grabbed wire acts as a handle: every + * register on the op (and recursively every register inside + * `children`, with external classical refs anchored — see + * `shiftAllRegisters`) shifts by `targetWire - sourceWire`. + * The whole op slides as a rigid unit, preserving the relative + * arrangement of its wires. + * + * 2. **Single-leg rewire** for ordinary controlled-gate cases (one + * target + N controls). Only the grabbed register is rewritten; + * the other legs stay put ("rewire one leg of a CNOT"). + * + * The "grabbed wire is the handle" model suits direct manipulation: + * grabbing wire 4 of a group and dragging to wire 6 pins wire 4 to + * wire 6. Richer multi-target authoring (resize, add/remove leg) + * belongs in the Inspector, not the drag-and-drop surface. */ const moveY = ( sourceOperation: Operation, @@ -79,6 +201,18 @@ const moveY = ( targetWire: number, movingControl: boolean, ): void => { + // Group / multi-target / multi-qubit ops: move the whole gate as + // a unit (shift every register by the same delta). See + // `moveAsUnit` for the criteria and rationale. + if (moveAsUnit(sourceOperation, movingControl)) { + const delta = targetWire - sourceWire; + if (delta !== 0) shiftAllRegisters(sourceOperation, delta); + return; + } + + // Single-leg path (CNOT-style: rewire just one target or one + // control leg). + // Check if the source operation already has a target or control on the target wire let targets: Register[]; switch (sourceOperation.kind) { @@ -117,6 +251,16 @@ const moveY = ( return; } + // For groups + control move, capture body occupancy BEFORE the + // `unlikeRegisters` mutation below: that mutation rewrites the + // group's derived `.targets` entry matching `targetWire`, so a + // post-mutation read would miss it and skip the subtree swap. + const groupBodyIncludesTargetWire = + movingControl && + sourceOperation.kind === "unitary" && + sourceOperation.children != null && + sourceOperation.targets.some((t) => t.qubit === targetWire); + // If a different kind of register already exists, swap the control and target if (unlikeRegisters.find((reg) => reg.qubit === targetWire)) { const index = unlikeRegisters.findIndex((reg) => reg.qubit === targetWire); @@ -126,6 +270,17 @@ const moveY = ( switch (sourceOperation.kind) { case "unitary": if (movingControl) { + // Group + control move: dragging a control on a group + // changes only the control's wire (body stays put). If the + // drop wire is occupied by a body wire, swap source ↔ target + // inside the children subtree so they trade places. + if (sourceOperation.children != null && groupBodyIncludesTargetWire) { + for (const col of sourceOperation.children) { + for (const child of col.components) { + _swapWiresInSubtree(child, sourceWire, targetWire); + } + } + } sourceOperation.controls?.forEach((control) => { if (control.qubit === sourceWire) { control.qubit = targetWire; @@ -134,6 +289,12 @@ const moveY = ( sourceOperation.controls = sourceOperation.controls?.sort( (a, b) => a.qubit - b.qubit, ); + // Re-derive the moved group's own `.targets` from its + // (possibly-swapped) children. `refreshAncestorTargets` + // walks ANCESTORS only, so the moved op itself needs this. + if (sourceOperation.children != null) { + refreshDerivedTargets(sourceOperation); + } } else { sourceOperation.targets = [{ qubit: targetWire }]; } @@ -148,4 +309,11 @@ const moveY = ( } }; -export { collectMeasurementWires, moveX, moveY }; +export { + collectMeasurementWires, + moveAsUnit, + moveX, + moveY, + shiftAllRegisters, + _swapWiresInSubtree, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuitActions.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuitActions.ts index c0c905da28d..d3a1524b66e 100644 --- a/source/npm/qsharp/ux/circuit-vis/actions/circuitActions.ts +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuitActions.ts @@ -29,6 +29,7 @@ import { } from "./circuit-actions/derivedTargets.js"; import { addOp, + getSubtreeMinMaxWire, moveArrayElement, removeOp, updateMeasurementLines, @@ -37,8 +38,10 @@ import { } from "./circuit-actions/gridPrimitives.js"; import { collectMeasurementWires, + moveAsUnit, moveX, moveY, + shiftAllRegisters, } from "./circuit-actions/move.js"; /* @@ -147,8 +150,22 @@ const moveOperation = ( const affectedMeasurementWires = new Set(); collectMeasurementWires(originalOperation, affectedMeasurementWires); - // Grow the model to fit the wire the moved leg will land on. - model.ensureQubitCount(targetWire); + // Grow the model to fit the highest wire the moved op will land + // on. For a single-leg move that's `targetWire`; for a unit-shift + // every register shifts by `targetWire - sourceWire`, so the high + // wire moves to `maxOrigWire + delta`, which can exceed it. + // Refuse the move if a unit-shift would push any wire below 0 + // (the model has no negative wires); the drop silently no-ops. + if (moveAsUnit(newSourceOperation, movingControl)) { + const delta = targetWire - sourceWire; + const [minOrigWire, maxOrigWire] = getSubtreeMinMaxWire(newSourceOperation); + if (minOrigWire >= 0 && minOrigWire + delta < 0) { + return null; + } + model.ensureQubitCount(Math.max(targetWire, maxOrigWire + delta)); + } else { + model.ensureQubitCount(targetWire); + } // Update operation's targets and controls moveY(newSourceOperation, sourceWire, targetWire, movingControl); @@ -419,6 +436,11 @@ const removeMeasurementWithDependents = ( /** * Add an operation into the circuit. * + * @param sourceWire The wire the source op was "grabbed" on. Only + * meaningful when clone-dropping a group or multi-target op: the + * subtree shifts by `targetWire - sourceWire` to keep its shape + * (mirrors `moveOperation`'s `moveAsUnit` path). Omit for fresh + * toolbox drops, which take the single-leg rewrite below. * @returns The added operation or null if the addition was unsuccessful. */ const addOperation = ( @@ -427,6 +449,7 @@ const addOperation = ( targetLocation: string, targetWire: number, insertNewColumn: boolean = false, + sourceWire?: number, ): Operation | null => { const targetOperationParent = findParentArray( model.componentGrid, @@ -453,17 +476,37 @@ const addOperation = ( JSON.stringify(sourceOperation), ); - // Single-leg rewrite (toolbox drop, single-target clone): re-pin the op to `targetWire`. - if (newSourceOperation.kind === "measurement") { - newSourceOperation.qubits = [{ qubit: targetWire }]; - // The measurement result is updated later in the updateMeasurementLines function - } else if ( - newSourceOperation.kind === "unitary" || - newSourceOperation.kind === "ket" - ) { - newSourceOperation.targets = [{ qubit: targetWire }]; + // Decide whether this clone needs the rigid unit-shift treatment + // (same predicate as `moveOperation`'s move path). `movingControl` + // is always false here — clone-of-a-control routes through + // addControl + moveOperation, not addOperation. + const cloneAsUnit = + sourceWire !== undefined && moveAsUnit(newSourceOperation, false); + + if (cloneAsUnit) { + // Mirror `moveOperation`'s unit-shift block: refuse if it would + // push any wire below 0, then grow the model to fit. + const delta = targetWire - sourceWire; + const [minOrigWire, maxOrigWire] = getSubtreeMinMaxWire(newSourceOperation); + if (minOrigWire >= 0 && minOrigWire + delta < 0) { + return null; + } + model.ensureQubitCount(Math.max(targetWire, maxOrigWire + delta)); + if (delta !== 0) shiftAllRegisters(newSourceOperation, delta); + } else { + // Single-leg rewrite (toolbox drop, single-target clone): re-pin + // the op to `targetWire`. + if (newSourceOperation.kind === "measurement") { + newSourceOperation.qubits = [{ qubit: targetWire }]; + // The measurement result is updated later in the updateMeasurementLines function + } else if ( + newSourceOperation.kind === "unitary" || + newSourceOperation.kind === "ket" + ) { + newSourceOperation.targets = [{ qubit: targetWire }]; + } + model.ensureQubitCount(targetWire); } - model.ensureQubitCount(targetWire); // Capture the dest ancestor chain BEFORE addOp so the rung references survive any column splices. // Empty when top-level. @@ -480,6 +523,20 @@ const addOperation = ( insertNewColumn, ); + // Unit-shift clones can drop nested measurements onto wires the + // model has never seen. `addOp` only refreshes TOP-LEVEL + // measurements, so refresh each touched wire explicitly. Single-leg + // drops skip this — `addOp` already handled their only measurement. + if (cloneAsUnit) { + const affectedMeasurementWires = new Set(); + collectMeasurementWires(newSourceOperation, affectedMeasurementWires); + for (const wire of affectedMeasurementWires) { + if (wire >= 0 && wire < model.qubits.length) { + updateMeasurementLines(model, wire); + } + } + } + // After mutating the parent group's children, the centralized post-widening cleanup re-derives // every ancestor's `.targets` and resolves any sibling-column collisions the widening introduced. resolveSpanChange( diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts index 20aa7dc0ebc..b47bd97096c 100644 --- a/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts @@ -16,13 +16,17 @@ import { import { createGateGhost, createWireDropzone, + makeDropzoneBox, + makeShiftExtendGhost, removeAllWireDropzones, } from "../draggable.js"; import { beginToolboxDrag, resetTransient, + trackTemporaryDropzone, } from "../../actions/interactionActions.js"; import { InteractionContext } from "./interactionContext.js"; +import { LayoutScope } from "../../renderer/layoutMap.js"; import { Location } from "../../data/location.js"; import { promptForArguments } from "../prompts.js"; import { QubitController } from "./qubitController.js"; @@ -32,7 +36,9 @@ import { getGateElems, getToolboxElems } from "../domUtils.js"; import { deepEqual, findOperation, + getAncestorColumnSiblingWires, getGateLocationString, + getQuantumWireRange, } from "../../utils.js"; /** @@ -48,6 +54,37 @@ import { * drag-off and calls `removeQubitLineWithConfirmation`. */ export class DragController { + /** + * Shift-extend context, populated by `onGateMouseDown` when the + * dragged source is internal to an expanded group, cleared by + * `tearDownShiftExtend` on container mouseup. Drives the extra + * "extend vertically" dropzones and the ghost-border overlay. + * `null` whenever the current drag can't extend a group. + */ + private _shiftExtendCtx: { + /** Hierarchical location of the immediate parent group G. */ + parentLoc: string; + /** `[minWire, maxWire]` of G's current target span. */ + parentMinWire: number; + parentMaxWire: number; + /** Geometry of G's children scope, from `LayoutMap.scopes`. */ + parentScope: LayoutScope; + } | null = null; + + /** + * Dropzones spawned by `spawnShiftExtendDropzones`, tracked + * separately so shift-release can clear them ahead of the + * container-mouseup cleanup. + */ + private _shiftExtendDropzones: SVGElement[] = []; + + /** Ghost-border rect currently painted in the overlay, if any. */ + private _ghostBorder: SVGElement | null = null; + + /** Currently-installed shift keydown/keyup listeners, if any. */ + private _onShiftDown: ((ev: KeyboardEvent) => void) | null = null; + private _onShiftUp: ((ev: KeyboardEvent) => void) | null = null; + constructor( private readonly ctx: InteractionContext, private readonly qubitController: QubitController, @@ -156,6 +193,10 @@ export class DragController { // re-render (canceled, or a no-op drop) doesn't leave the next drag with stale `display: // none` marks. this.showAllDropzones(); + // Clear shift-extend context, drop any leftover shift-extend + // dropzones and the ghost border, and uninstall the shift + // listeners. Pairs with `setupShiftExtend` in `onGateMouseDown`. + this.tearDownShiftExtend(); }); // Track whether the most recent mouseup landed on the circuit surface itself; consumed by the @@ -266,6 +307,41 @@ export class DragController { ) return; + // Add temporary per-op dropzones for the multi-target drag flow. + // The scope that contains the selected op is the parent of its + // location (an op at "0,0-1,2" lives in the "0,0" scope). + // + // Quantum-only span: a classically-controlled op's `.controls` + // back-reference to the producing M isn't a draggable leg. + const [minTarget, maxTarget] = getQuantumWireRange( + this.ctx.interaction.selectedOperation, + ); + const selectedAddr = Location.parse(selectedLocation); + const last = selectedAddr.last(); + if (last == null) return; + const [colIndex, opIndex] = last; + const parentPrefix = selectedAddr.parent().toString(); + const parentScope = this.ctx.layoutMap.scopes.get(parentPrefix); + if (parentScope == null) return; + + const dropzoneCtx = { + scope: parentScope, + wireData: this.ctx.wireData, + pathPrefix: parentPrefix, + }; + for (let wire = minTarget; wire <= maxTarget; wire++) { + if (wire === this.ctx.interaction.selectedWire) continue; + const dropzone = makeDropzoneBox(dropzoneCtx, { + colIndex, + opIndex, + wireIndex: wire, + interColumn: false, + }); + dropzone.addEventListener("mouseup", this.onDropzoneMouseUp); + trackTemporaryDropzone(this.ctx.interaction, dropzone); + this.ctx.dropzoneLayer.appendChild(dropzone); + } + this.spawnGhost(ev); // Make sure the selectedOperation has location data — downstream drop logic reads it via @@ -283,6 +359,11 @@ export class DragController { // register the selected op consumes from outside its own subtree. See `hideInvalidDropzones`. this.hideInvalidDropzones(selectedLocation); + // Arm shift-extend if the source is internal to an expanded + // ancestor group; no-op for top-level sources or untracked + // scopes. See `setupShiftExtend`. + this.setupShiftExtend(selectedAddr); + this.ctx.container.classList.add("moving"); this.ctx.ghostQubitLayer.style.display = "block"; this.ctx.dropzoneLayer.style.display = "block"; @@ -395,12 +476,17 @@ export class DragController { insertNewColumn, ); } else { + // Pass `selectedWire` as the source wire so a group / + // multi-target clone shifts every register by the same + // delta. Without it, `addOperation` collapses `targets` to a + // single-wire stub and strands the children. addOperation( this.ctx.model, this.ctx.interaction.selectedOperation, targetLoc, targetWire, insertNewColumn, + this.ctx.interaction.selectedWire, ); } } else { @@ -588,4 +674,199 @@ export class DragController { this.ctx.renderFn(); } + + /****************************** + * shift-extend * + ******************************/ + + /** + * Arm the shift-extend pathway for a new internal-source drag. + * No-op if `selectedAddr` is top-level (no parent group to extend) + * or if the immediate parent's children scope isn't tracked by the + * LayoutMap (defensive). + * + * On the happy path: captures the parent group's wire span + + * scope, installs document shift keydown/keyup listeners, and + * spawns initial dropzones if shift is already held at drag start. + */ + private setupShiftExtend(selectedAddr: Location): void { + if (selectedAddr.depth < 2) return; // top-level source + const parentAddr = selectedAddr.parent(); + const parentLoc = parentAddr.toString(); + const parentScope = this.ctx.layoutMap.scopes.get(parentLoc); + if (parentScope == null) return; + + const parentOp = findOperation(this.ctx.model.componentGrid, parentLoc); + if (parentOp == null) return; + // Quantum-only span: shift-extend reach mirrors the group's + // editable wire scope, not its visual span including any + // classical-control back-references. + const [parentMinWire, parentMaxWire] = getQuantumWireRange(parentOp); + + this._shiftExtendCtx = { + parentLoc, + parentMinWire, + parentMaxWire, + parentScope, + }; + + // Install live shift tracking. Document-level because the user + // may shift+drag with the cursor outside the SVG (e.g. hovering + // the editor chrome on the way to the target wire). + this._onShiftDown = (ev) => { + if (ev.key !== "Shift") return; + this.spawnShiftExtendDropzones(); + }; + this._onShiftUp = (ev) => { + if (ev.key !== "Shift") return; + this.clearShiftExtendDropzones(); + this.clearGhostBorder(); + }; + document.addEventListener("keydown", this._onShiftDown); + document.addEventListener("keyup", this._onShiftUp); + } + + /** + * Tear down shift-extend state for the current (or just-ended) + * drag. Idempotent — safe to call when nothing was armed. + */ + private tearDownShiftExtend(): void { + this.clearShiftExtendDropzones(); + this.clearGhostBorder(); + if (this._onShiftDown != null) { + document.removeEventListener("keydown", this._onShiftDown); + this._onShiftDown = null; + } + if (this._onShiftUp != null) { + document.removeEventListener("keyup", this._onShiftUp); + this._onShiftUp = null; + } + this._shiftExtendCtx = null; + } + + /** + * Spawn the temporary "extend group vertically" dropzones for the + * currently-armed shift-extend context. Re-spawn-safe (clears + * existing first), idempotent for the same context. + * + * Emitted at every `(column, wire)` pair where: + * - `column` is one of the parent group's existing inner columns + * OR the trailing-append column past its rightmost child; + * - `wire` is in `[0, wireData.length)` but NOT in the parent + * group's `[minTarget, maxTarget]` span. + * + * Each dropzone is tagged `data-shift-extend="true"` so the + * mouseup handler can recognize a shift-extend release for + * visual cleanup (the ghost border). The action layer + * (`moveOperation`) always re-derives ancestor `.targets` from + * post-move children, so no special routing on the action call + * is needed \u2014 the location string of the dropzone is enough. + * Hover-enter paints the ghost border for that wire; hover-leave + * clears it. + */ + private spawnShiftExtendDropzones(): void { + if (this._shiftExtendCtx == null) return; + this.clearShiftExtendDropzones(); + + const { parentScope, parentMinWire, parentMaxWire, parentLoc } = + this._shiftExtendCtx; + const realColCount = parentScope.columnXOffsets.length; + // +1 for the trailing-append column past the rightmost. + const totalCols = realColCount + 1; + + // Wires the group can't directly extend onto because a sibling + // at some level of the ancestor chain already occupies them in + // that level's outer column — dropping there would land the new + // op directly on an existing one. We walk the full ancestor + // chain since shift-extend widens every ancestor whose span + // doesn't already enclose the drop wire. + // + // The cross-over case (extending past an in-between sibling to a + // clear wire) is intentionally not filtered: `moveOperation`'s + // dest-side cascade splits the outer column so the in-between + // sibling slides one column right of the widened ancestor. + const blockedWires = getAncestorColumnSiblingWires( + this.ctx.model.componentGrid, + parentLoc, + ); + + const dropzoneCtx = { + scope: parentScope, + wireData: this.ctx.wireData, + pathPrefix: parentLoc, + }; + for (let colIndex = 0; colIndex < totalCols; colIndex++) { + for (let wire = 0; wire < this.ctx.wireData.length; wire++) { + // Only emit for wires outside the group's current span; wires + // inside already have regular inner dropzones. + if (wire >= parentMinWire && wire <= parentMaxWire) continue; + + // Skip wires a sibling already occupies (see `blockedWires`). + if (blockedWires.has(wire)) continue; + + // opIndex = 0: the wire is outside the group's span so no op + // in this column shares it; the op slots in without splicing + // a new column. + const dropzone = makeDropzoneBox(dropzoneCtx, { + colIndex, + opIndex: 0, + wireIndex: wire, + interColumn: false, + }); + dropzone.setAttribute("data-shift-extend", "true"); + // Force a normal drop (no new outer column), not an + // insert-between gesture. + dropzone.setAttribute("data-dropzone-inter-column", "false"); + dropzone.addEventListener("mouseup", this.onDropzoneMouseUp); + dropzone.addEventListener("mouseenter", () => { + this.paintGhostBorder(wire, colIndex); + }); + dropzone.addEventListener("mouseleave", () => { + this.clearGhostBorder(); + }); + this.ctx.dropzoneLayer.appendChild(dropzone); + this._shiftExtendDropzones.push(dropzone); + } + } + } + + /** + * Remove every shift-extend dropzone from the layer. Fired on + * shift-up (so the dropzones disappear immediately) and on + * container mouseup (belt-and-suspenders). Idempotent. + */ + private clearShiftExtendDropzones(): void { + for (const dz of this._shiftExtendDropzones) { + dz.parentNode?.removeChild(dz); + } + this._shiftExtendDropzones = []; + } + + /** + * Paint the ghost-border overlay for the given hover wire and + * column. Replaces any existing ghost border (so moving between + * shift-extend dropzones updates the preview). + */ + private paintGhostBorder(hoverWire: number, hoverCol: number): void { + if (this._shiftExtendCtx == null) return; + this.clearGhostBorder(); + const { parentScope, parentMinWire, parentMaxWire } = this._shiftExtendCtx; + this._ghostBorder = makeShiftExtendGhost( + parentScope, + this.ctx.wireData, + parentMinWire, + parentMaxWire, + hoverWire, + hoverCol, + ); + this.ctx.overlayLayer.appendChild(this._ghostBorder); + } + + /** Remove the ghost-border overlay if one is painted. Idempotent. */ + private clearGhostBorder(): void { + if (this._ghostBorder != null) { + this._ghostBorder.parentNode?.removeChild(this._ghostBorder); + this._ghostBorder = null; + } + } } diff --git a/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts b/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts index d3d81af21e8..b41f9fe932a 100644 --- a/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts +++ b/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts @@ -736,11 +736,79 @@ const makeDropzoneBox = ( return dropzone; }; +/** + * Build the ghost-border `` that previews a group's extended + * bounding box during a D4 Stage B shift+drag. + * + * The rect covers: + * + * - Horizontally: from the group's leftmost column's start x to its + * rightmost column's right edge. If `hoverColIndex` lies past the + * last column (the trailing-append column), the rect extends right + * to include that synthesized column too — so the user sees the + * group's new horizontal footprint along with the new vertical + * one when the drop is on the trailing column. + * - Vertically: from `min(top wire Y, hover wire Y)` to + * `max(bottom wire Y, hover wire Y)`, padded by `DROPZONE_PADDING_Y` + * on each side so the ghost reads as a generous halo around the + * group's body rather than a tight stripe over the wires. + * + * Coordinates come entirely from `LayoutScope` + `wireData`, the + * same sources Stage A's dropzones use. No DOM lookup of the + * group's rendered `` — that would couple the overlay to + * `gateFormatter`'s internal structure. + * + * Caller appends the returned element to `overlayLayer` and removes + * it on hover-off / shift-release / mouseup. + */ +const makeShiftExtendGhost = ( + scope: LayoutScope, + wireData: number[], + groupMinWire: number, + groupMaxWire: number, + hoverWireIndex: number, + hoverColIndex: number, +): SVGElement => { + // Horizontal: leftmost column start → rightmost column right edge. + // The trailing-append case (hoverColIndex past the last real + // column) extends right via `columnGeometry`'s synthesized position + // so the hover column gets covered too. + const leftGeom = columnGeometry(scope, 0); + const lastRealColIndex = Math.max(scope.columnXOffsets.length - 1, 0); + const rightRealGeom = columnGeometry(scope, lastRealColIndex); + const rightRealEdge = rightRealGeom.colStartX + rightRealGeom.colWidth; + const rightTrailGeom = columnGeometry(scope, scope.columnXOffsets.length); + const rightEdge = + hoverColIndex >= scope.columnXOffsets.length + ? rightTrailGeom.colStartX + rightTrailGeom.colWidth + : rightRealEdge; + + // Vertical: pull in the existing wire span plus the hovered wire, + // and pad. We index `wireData` defensively in case `hoverWireIndex` + // is the trailing ghost-qubit row (length == wireData.length); fall + // back to the last real wire if so, since extending onto the ghost + // row isn't a supported action. + const topWireY = wireData[groupMinWire] ?? wireData[0]; + const bottomWireY = wireData[groupMaxWire] ?? wireData[wireData.length - 1]; + const hoverWireY = wireData[hoverWireIndex] ?? wireData[wireData.length - 1]; + const topY = Math.min(topWireY, hoverWireY) - DROPZONE_PADDING_Y; + const bottomY = Math.max(bottomWireY, hoverWireY) + DROPZONE_PADDING_Y; + + return box( + leftGeom.colStartX - gatePadding, + topY, + rightEdge - leftGeom.colStartX + gatePadding * 2, + bottomY - topY, + "shift-extend-ghost", + ); +}; + export { createDropzones, createGateGhost, createQubitLabelGhost, createWireDropzone, makeDropzoneBox, + makeShiftExtendGhost, removeAllWireDropzones, }; From 46e35a41621b7cd813fc566d3906427cc2ca7a31 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Wed, 5 Aug 2026 10:49:46 -0700 Subject: [PATCH 2/3] Fix some inconsistencies in the ghost-box and dropboxes for the shift-extend scenario. --- .../circuit-editor/dragController.test.mjs | 142 +++++++++-- .../test/circuit-editor/draggable.test.mjs | 199 +++++++++------- .../test/circuit-editor/dropzones.test.mjs | 56 ++++- source/npm/qsharp/ux/circuit-vis/README.md | 7 +- .../editor/controllers/dragController.ts | 186 +++++++-------- .../qsharp/ux/circuit-vis/editor/domUtils.ts | 42 +++- .../qsharp/ux/circuit-vis/editor/draggable.ts | 224 ++++++++---------- 7 files changed, 509 insertions(+), 347 deletions(-) diff --git a/source/npm/qsharp/test/circuit-editor/dragController.test.mjs b/source/npm/qsharp/test/circuit-editor/dragController.test.mjs index 842a79c321d..65381c6ba63 100644 --- a/source/npm/qsharp/test/circuit-editor/dragController.test.mjs +++ b/source/npm/qsharp/test/circuit-editor/dragController.test.mjs @@ -765,12 +765,10 @@ test("setupShiftExtend arms _shiftExtendCtx and installs shift listeners for an }); test("spawnShiftExtendDropzones emits dropzones only for wires outside the parent group's span", () => { - // Parent group spans wires 0..1. `wireData` covers wires 0..4 - // (4 qubits + trailing ghost). Spawn should emit dropzones for - // wires {2, 3, 4} only — wires {0, 1} are inside the span and - // already covered by regular inner dropzones. - // - // Per-column count: 3 wires × 2 columns (1 real + 1 trailing) = 6. + // Parent group spans wires 0..1, so only wires 2, 3, 4 get + // shift-extend dropzones (wires 0..1 already have regular inner + // dropzones). Each wire gets 3: a band + full box for the one real + // column, plus a band for the trailing-append column. 3 wires × 3 = 9. const { fixture, dragController, ctx } = setup( circuit(4, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), ); @@ -782,8 +780,8 @@ test("spawnShiftExtendDropzones emits dropzones only for wires outside the paren /** @type {any} */ (dragController).spawnShiftExtendDropzones(); const spawned = fixture.dropzoneLayer.querySelectorAll("[data-shift-extend]"); - // Wires {2, 3, 4} × 2 columns = 6. - assert.equal(spawned.length, 6); + // Wires {2, 3, 4} × 3 shapes = 9. + assert.equal(spawned.length, 9); const wires = new Set( Array.from(spawned).map((d) => @@ -817,8 +815,8 @@ test("spawnShiftExtendDropzones skips wires blocked by ancestor-column siblings" /** @type {any} */ (dragController).spawnShiftExtendDropzones(); const spawned = fixture.dropzoneLayer.querySelectorAll("[data-shift-extend]"); - // 2 unblocked wires × 2 columns = 4. - assert.equal(spawned.length, 4); + // 2 unblocked wires × 3 shapes = 6. + assert.equal(spawned.length, 6); const wires = new Set( Array.from(spawned).map((d) => @@ -837,12 +835,13 @@ test("spawnShiftExtendDropzones skips wires blocked by ancestor-column siblings" dragController.dispose(); }); -test("spawnShiftExtendDropzones tags every dropzone and is re-spawn-safe", () => { +test("spawnShiftExtendDropzones emits both dropzone shapes and is re-spawn-safe", () => { // Two contracts in one test (cheap to combine, hard to separate // meaningfully): - // 1. Every spawned dropzone carries `data-shift-extend="true"` - // AND `data-dropzone-inter-column="false"` (so the mouseup - // handler doesn't insert a new column). + // 1. Every spawned dropzone is tagged `data-shift-extend="true"`. + // Each real column has both a band (insert a new inner column) + // and a full box (drop into the column); the trailing-append + // column has only a band. // 2. Calling spawn twice in a row leaves the layer with one // copy, not two — the method clears its prior spawn first. const { fixture, dragController, ctx } = setup( @@ -855,16 +854,38 @@ test("spawnShiftExtendDropzones tags every dropzone and is re-spawn-safe", () => ); /** @type {any} */ (dragController).spawnShiftExtendDropzones(); - const firstSpawn = fixture.dropzoneLayer.querySelectorAll( - "[data-shift-extend]", + const firstSpawn = Array.from( + fixture.dropzoneLayer.querySelectorAll("[data-shift-extend]"), ); assert.ok(firstSpawn.length > 0, "first spawn must emit some dropzones"); - // Every dropzone is tagged correctly. - for (const dz of Array.from(firstSpawn)) { + + // Every dropzone is tagged shift-extend. + for (const dz of firstSpawn) { assert.equal(dz.getAttribute("data-shift-extend"), "true"); - assert.equal(dz.getAttribute("data-dropzone-inter-column"), "false"); } + // Both shapes appear — a band (true) and a full box (false). + const interColVals = new Set( + firstSpawn.map((dz) => dz.getAttribute("data-dropzone-inter-column")), + ); + assert.ok( + interColVals.has("true") && interColVals.has("false"), + "spawn must emit both the inter-column band and the full-column box", + ); + + // The trailing-append column (location "0,0-1,0" — colIndex 1 past + // the single real column) only ever gets the band, never a full box. + const trailingFullBoxes = firstSpawn.filter( + (dz) => + dz.getAttribute("data-dropzone-location") === "0,0-1,0" && + dz.getAttribute("data-dropzone-inter-column") === "false", + ); + assert.equal( + trailingFullBoxes.length, + 0, + "trailing-append column must not emit a full-column box", + ); + // Re-spawn: count must NOT double. (Idempotency / re-arm safety.) /** @type {any} */ (dragController).spawnShiftExtendDropzones(); const secondSpawn = fixture.dropzoneLayer.querySelectorAll( @@ -879,26 +900,54 @@ test("spawnShiftExtendDropzones tags every dropzone and is re-spawn-safe", () => dragController.dispose(); }); +/** + * Append a rendered group box to the fixture SVG so `getGroupBoxElem` + * (and thus `paintGhostBorder`) can find it. Mirrors the DOM shape the + * renderer emits: a `` whose first + * direct-child `` is the group's own dashed + * box. Only x/y/width/height are read by the ghost cloner. + */ +function appendGroupBox( + /** @type {SVGElement} */ svg, + /** @type {string} */ location, + { x = 80, y = 0, width = 100, height = 160 } = {}, +) { + const g = document.createElementNS(SVG_NS, "g"); + g.setAttribute("class", "gate"); + g.setAttribute("data-location", location); + const rect = document.createElementNS(SVG_NS, "rect"); + rect.setAttribute("class", "gate-unitary"); + rect.setAttribute("x", String(x)); + rect.setAttribute("y", String(y)); + rect.setAttribute("width", String(width)); + rect.setAttribute("height", String(height)); + g.appendChild(rect); + svg.appendChild(g); + return rect; +} + test("paintGhostBorder appends a .shift-extend-ghost rect and replaces a prior one", () => { // Each `paintGhostBorder` call clears the existing ghost before // appending a new one, so the overlay never carries two ghost - // rects at once. + // rects at once. The ghost is cloned from the group's rendered box, + // so the fixture must carry a real box for the parent location. const { fixture, dragController, ctx } = setup( circuit(3, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), ); setScope(ctx, "0,0"); + appendGroupBox(fixture.svg, "0,0"); /** @type {any} */ (dragController).setupShiftExtend( Location.parse("0,0-0,0"), ); - // First paint — one ghost. - /** @type {any} */ (dragController).paintGhostBorder(2, 0); + // First paint — one ghost. (isBand=false: drop-into-column, no widen.) + /** @type {any} */ (dragController).paintGhostBorder(2, 0, false); let ghosts = fixture.overlay.querySelectorAll(".shift-extend-ghost"); assert.equal(ghosts.length, 1); const firstGhost = ghosts[0]; // Second paint at a different wire — old ghost replaced, not appended. - /** @type {any} */ (dragController).paintGhostBorder(0, 0); + /** @type {any} */ (dragController).paintGhostBorder(0, 0, false); ghosts = fixture.overlay.querySelectorAll(".shift-extend-ghost"); assert.equal(ghosts.length, 1, "second paint must replace, not append"); assert.notEqual( @@ -915,6 +964,50 @@ test("paintGhostBorder appends a .shift-extend-ghost rect and replaces a prior o dragController.dispose(); }); +test("paintGhostBorder widens the ghost for an outer inserting band, not for a full-box drop", () => { + // Hovering the trailing-append column's narrow band (isBand=true, + // colIndex past the last real column) inserts a new column, so the + // ghost grows wider than the rendered box. A full-column box drop + // (isBand=false) on the same column leaves the width at the box's. + const { fixture, dragController, ctx } = setup( + circuit(3, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + // Single real column at x=80 width=60; trailing column sits past it. + setScope(ctx, "0,0", [80], [60]); + const box = appendGroupBox(fixture.svg, "0,0", { + x: 80, + y: 0, + width: 80, + height: 160, + }); + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + + const boxWidth = Number(box.getAttribute("width")); + + // Full-box drop into column 0 → no horizontal growth. + /** @type {any} */ (dragController).paintGhostBorder(2, 0, false); + const fullBoxGhost = fixture.overlay.querySelector(".shift-extend-ghost"); + assert.equal(Number(fullBoxGhost.getAttribute("width")), boxWidth); + + // Inserting band at the trailing column (colIndex 1, past the single + // real column) → ghost grows wider than the box. + /** @type {any} */ (dragController).paintGhostBorder(2, 1, true); + const bandGhost = fixture.overlay.querySelector(".shift-extend-ghost"); + assert.ok( + Number(bandGhost.getAttribute("width")) > boxWidth, + "trailing inserting band should widen the ghost past the box width", + ); + // Left edge stays pinned to the box's — growth lands on the right. + assert.equal( + Number(bandGhost.getAttribute("x")), + Number(box.getAttribute("x")), + ); + + dragController.dispose(); +}); + test("tearDownShiftExtend clears dropzones, ghost border, _shiftExtendCtx, and shift listeners", () => { // Full teardown chain. After teardown the controller is back to // its initial unarmed state — no dropzones in the DOM, no ghost @@ -923,11 +1016,12 @@ test("tearDownShiftExtend clears dropzones, ghost border, _shiftExtendCtx, and s circuit(3, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), ); setScope(ctx, "0,0"); + appendGroupBox(fixture.svg, "0,0"); /** @type {any} */ (dragController).setupShiftExtend( Location.parse("0,0-0,0"), ); /** @type {any} */ (dragController).spawnShiftExtendDropzones(); - /** @type {any} */ (dragController).paintGhostBorder(2, 0); + /** @type {any} */ (dragController).paintGhostBorder(2, 0, false); // Sanity: state was actually armed. assert.notEqual(/** @type {any} */ (dragController)._shiftExtendCtx, null); diff --git a/source/npm/qsharp/test/circuit-editor/draggable.test.mjs b/source/npm/qsharp/test/circuit-editor/draggable.test.mjs index 8f343a21d99..f6d232080f2 100644 --- a/source/npm/qsharp/test/circuit-editor/draggable.test.mjs +++ b/source/npm/qsharp/test/circuit-editor/draggable.test.mjs @@ -7,8 +7,9 @@ // // - `makeDropzoneBox`: inter-column vs on-column geometry, the trailing-append column past the // rightmost real column, and the `data-dropzone-*` attribute set used by `findParentArray`. -// - `makeShiftExtendGhost`: vertical span extension above/below the group, horizontal extension -// onto the trailing-append column, and the `shift-extend-ghost` CSS hook. +// - `makeShiftExtendGhost`: clones the group's rendered box and slides one edge — vertical span +// extension above/below the group, right-edge growth when inserting a new column (with +// label-slack absorption), and the `shift-extend-ghost` CSS hook. // - `createWireDropzone`: full-width wire-spanning dropzone Y math, the `isBetween` cases that // target the gaps before the first / after the last wire. // - `removeAllWireDropzones`: targets `.dropzone-full-wire` only and leaves other overlay @@ -80,6 +81,21 @@ const INTER_COLUMN_FULL_WIDTH = INTER_COLUMN_HALF_WIDTH * 2; // 24 const DROPZONE_PADDING_Y = 20; const REGISTER_HEIGHT = GATE_HEIGHT + GATE_PADDING * 2; // 52 +// Group-box padding constants — mirror `groupPaddingX` / `groupBottomPadding` / `groupTopPadding` +// in `ux/circuit-vis/renderer/constants.ts`. `groupTopPadding` is DERIVED there as +// `groupBottomPadding + labelFontSize + groupLabelPaddingY`, so it's derived here too. The ghost's +// moved edge lands `TOP_PAD` above / `BOTTOM_PAD` below the hovered wire — the same +// half-gate-plus-group-padding offsets the renderer leaves between a group's edge and its nearest +// wire. +const GROUP_PADDING_X = 10; +const GROUP_BOTTOM_PADDING = 10; +const LABEL_FONT_SIZE = 14; +const GROUP_LABEL_PADDING_Y = 2; +const GROUP_TOP_PADDING = + GROUP_BOTTOM_PADDING + LABEL_FONT_SIZE + GROUP_LABEL_PADDING_Y; // 26 +const TOP_PAD = GATE_HEIGHT / 2 + GROUP_TOP_PADDING; // 46 +const BOTTOM_PAD = GATE_HEIGHT / 2 + GROUP_BOTTOM_PADDING; // 30 + /** * Build a `LayoutScope` with the given column starts/widths. Mirrors the shape * `LayoutMap.scopes.get(prefix)` returns. @@ -195,111 +211,114 @@ test("makeDropzoneBox: nested pathPrefix produces hierarchical location string", // ─── makeShiftExtendGhost ─────────────────────────────────────────── -test("makeShiftExtendGhost: hover above the group's span extends the rect upward", () => { - // Group spans wires [1, 2]; hover wire 0 (above the group). - // Vertical bounds: min(top wire Y, hover Y) - padding ... max(bottom wire Y, hover Y) + padding. +/** + * Build a group-box `` with the given bounds — the "real" rendered dashed box that + * `makeShiftExtendGhost` clones. Only x/y/width/height are read; the class mirrors a plain + * (non-classical) group's box. + * + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + */ +function makeGroupBox(x, y, width, height) { + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", String(x)); + rect.setAttribute("y", String(y)); + rect.setAttribute("width", String(width)); + rect.setAttribute("height", String(height)); + rect.setAttribute("class", "gate-unitary"); + return /** @type {SVGGraphicsElement} */ (rect); +} + +test("makeShiftExtendGhost: hover above the box top slides the top edge up, bottom fixed", () => { + // Box spans y ∈ [200, 320]; hover a wire at y=150 (above the box). + // The top edge drops to hoverY - TOP_PAD; the bottom edge (which may + // carry a classical-control reach) stays put. + const box = makeGroupBox(100, 200, 80, 120); const scope = makeScope([100], [60]); - const wireData = [50, 150, 250, 350]; - - const ghost = makeShiftExtendGhost( - scope, - wireData, - /* groupMinWire */ 1, - /* groupMaxWire */ 2, - /* hoverWireIndex */ 0, - /* hoverColIndex */ 0, - ); + + const ghost = makeShiftExtendGhost(box, /* hoverWireY */ 150, false, scope); assert.equal(ghost.getAttribute("class"), "shift-extend-ghost"); - // Top = min(150, 50) - 20 = 30 - assert.equal(attrNum(ghost, "y"), 50 - DROPZONE_PADDING_Y); - // Bottom = max(250, 50) + 20 = 270; height = 270 - 30 = 240 - assert.equal( - attrNum(ghost, "height"), - 250 + DROPZONE_PADDING_Y - (50 - DROPZONE_PADDING_Y), - ); + // Top = 150 - 46 = 104; bottom fixed at 320; height = 320 - 104 = 216. + assert.equal(attrNum(ghost, "y"), 150 - TOP_PAD); + assert.equal(attrNum(ghost, "height"), 320 - (150 - TOP_PAD)); + // Horizontal untouched (not a column insert). + assert.equal(attrNum(ghost, "x"), 100); + assert.equal(attrNum(ghost, "width"), 80); }); -test("makeShiftExtendGhost: hover below the group's span extends the rect downward", () => { - // Group spans wires [0, 1]; hover wire 3 (below). +test("makeShiftExtendGhost: hover below the box bottom slides the bottom edge down, top fixed", () => { + // Box spans y ∈ [200, 320]; hover a wire at y=400 (below the box). + const box = makeGroupBox(100, 200, 80, 120); const scope = makeScope([100], [60]); - const wireData = [50, 150, 250, 350]; - - const ghost = makeShiftExtendGhost( - scope, - wireData, - /* groupMinWire */ 0, - /* groupMaxWire */ 1, - /* hoverWireIndex */ 3, - /* hoverColIndex */ 0, - ); - // Top = min(50, 350) - 20 = 30 - assert.equal(attrNum(ghost, "y"), 50 - DROPZONE_PADDING_Y); - // Bottom = max(150, 350) + 20 = 370; height = 370 - 30 = 340 - assert.equal( - attrNum(ghost, "height"), - 350 + DROPZONE_PADDING_Y - (50 - DROPZONE_PADDING_Y), - ); + const ghost = makeShiftExtendGhost(box, /* hoverWireY */ 400, false, scope); + + // Top fixed at 200; bottom = 400 + 30 = 430; height = 430 - 200 = 230. + assert.equal(attrNum(ghost, "y"), 200); + assert.equal(attrNum(ghost, "height"), 400 + BOTTOM_PAD - 200); }); -test("makeShiftExtendGhost: hover on the trailing-append column extends horizontally to include it", () => { - // Two real columns; hover on colIndex 2 (the trailing slot). The - // ghost rect should extend right to cover the synthesized column, - // not just the rightmost real column. - const scope = makeScope([100, 200], [60, 90]); - const wireData = [50, 150]; - - const ghostOnReal = makeShiftExtendGhost( - scope, - wireData, - 0, - 1, - 0, - /* hoverColIndex */ 1, - ); - const ghostOnTrailing = makeShiftExtendGhost( - scope, - wireData, - 0, - 1, - 0, - /* hoverColIndex */ 2, - ); +test("makeShiftExtendGhost: hover inside the box span leaves the vertical bounds untouched", () => { + // Hover wire at y=260, inside the box's [200, 320] span. Defensive + // case (callers only paint for outside wires) — no edge should move. + const box = makeGroupBox(100, 200, 80, 120); + const scope = makeScope([100], [60]); - // Hover on real rightmost: rightEdge = 200 + 90 = 290 - // Hover on trailing: rightEdge = (200 + 90 + 12) + 40 = 342 - // Left edge for both = colStartX(0) - gatePadding = 100 - 6 = 94 - // Width = rightEdge - colStartX(0) + 2*gatePadding - // = real: 290 - 100 + 12 = 202 - // = trailing: 342 - 100 + 12 = 254 - assert.equal(attrNum(ghostOnReal, "x"), 100 - GATE_PADDING); - assert.equal(attrNum(ghostOnReal, "width"), 290 - 100 + GATE_PADDING * 2); - assert.equal(attrNum(ghostOnTrailing, "x"), 100 - GATE_PADDING); - assert.equal( - attrNum(ghostOnTrailing, "width"), - 200 + 90 + GATE_PADDING * 2 + MIN_GATE_WIDTH - 100 + GATE_PADDING * 2, - ); - // Sanity: trailing footprint is strictly wider than the real one. + const ghost = makeShiftExtendGhost(box, /* hoverWireY */ 260, false, scope); + + assert.equal(attrNum(ghost, "y"), 200); + assert.equal(attrNum(ghost, "height"), 120); + assert.equal(attrNum(ghost, "x"), 100); + assert.equal(attrNum(ghost, "width"), 80); +}); + +test("makeShiftExtendGhost: inserting a column grows the right edge only, left edge fixed", () => { + // A narrow box whose right edge sits left of where the new trailing + // column needs the box to reach. `extendColumn=true` grows the RIGHT + // edge to enclose the synthesized column + group side padding; the + // left edge never moves. (A leading-column insert shifts existing + // columns right, so its new space also lands on the right — same + // logic as a trailing insert.) + const box = makeGroupBox(100, 200, 80, 120); // right edge = 180 + const scope = makeScope([100], [60]); + + // Trailing column geometry: colStartX = 100 + 60 + 2*GATE_PADDING = 172, + // colWidth = MIN_GATE_WIDTH = 40. neededRight = 172 + 40 + GROUP_PADDING_X = 222. + const neededRight = + 100 + 60 + GATE_PADDING * 2 + MIN_GATE_WIDTH + GROUP_PADDING_X; + assert.equal(neededRight, 222); + + const ghostNoGrow = makeShiftExtendGhost(box, 260, false, scope); + const ghostGrow = makeShiftExtendGhost(box, 260, true, scope); + + // No-insert ghost keeps the box width. + assert.equal(attrNum(ghostNoGrow, "width"), 80); + + // Insert ghost: left edge fixed at 100, right edge pushed to 222. + assert.equal(attrNum(ghostGrow, "x"), 100); + assert.equal(attrNum(ghostGrow, "width"), neededRight - 100); assert.ok( - attrNum(ghostOnTrailing, "width") > attrNum(ghostOnReal, "width"), - "trailing-column ghost should be wider than the real-column ghost", + attrNum(ghostGrow, "width") > attrNum(ghostNoGrow, "width"), + "column-insert ghost should be wider than the no-insert ghost", ); }); -test("makeShiftExtendGhost: hover within the group span leaves vertical bounds at the group's wires", () => { - // Hover wire is inside the group's existing wire span — vertical - // bounds should land exactly on the group's wires (the min/max - // doesn't pull them anywhere new), only padded. +test("makeShiftExtendGhost: a label-widened box absorbs the new column instead of overshooting", () => { + // Box already wider than the trailing column needs (e.g. stretched by + // a long group label): its right edge (300) already exceeds + // neededRight (222), so a column insert adds NO width — the ghost + // never overshoots the committed box width. + const box = makeGroupBox(100, 200, 200, 120); // right edge = 300 const scope = makeScope([100], [60]); - const wireData = [50, 150, 250, 350]; - const ghost = makeShiftExtendGhost(scope, wireData, 1, 2, /* hover */ 2, 0); + const ghost = makeShiftExtendGhost(box, 260, true, scope); - // Top = min(150, 250) - 20 = 130 - assert.equal(attrNum(ghost, "y"), 150 - DROPZONE_PADDING_Y); - // Bottom = max(250, 250) + 20 = 270; height = 140 - assert.equal(attrNum(ghost, "height"), 250 - 150 + DROPZONE_PADDING_Y * 2); + // Width unchanged — the new column is absorbed by the existing slack. + assert.equal(attrNum(ghost, "x"), 100); + assert.equal(attrNum(ghost, "width"), 200); }); // ─── createWireDropzone ───────────────────────────────────────────── diff --git a/source/npm/qsharp/test/circuit-editor/dropzones.test.mjs b/source/npm/qsharp/test/circuit-editor/dropzones.test.mjs index 619156e9469..68145a4928c 100644 --- a/source/npm/qsharp/test/circuit-editor/dropzones.test.mjs +++ b/source/npm/qsharp/test/circuit-editor/dropzones.test.mjs @@ -15,7 +15,7 @@ import { afterEach, beforeEach, test } from "node:test"; import assert from "node:assert/strict"; import { draw } from "../../dist/ux/circuit-vis/index.js"; import { Location } from "../../dist/ux/circuit-vis/data/location.js"; -import { circuit, gate, group } from "./_helpers.mjs"; +import { circuit, gate, group, meas, qubits } from "./_helpers.mjs"; const documentTemplate = ` @@ -268,6 +268,60 @@ test("nested dropzones are clipped to the group's wire extent", () => { ); }); +// --------------------------------------------------------------------------- +// Classical-control reach: a group's box can extend up to a classical +// register it's conditioned on, enclosing intermediate wires no child +// touches. Those enclosed wires must still get inner dropzones. +// +// Here Foo targets only q2 but is conditioned on q0's result, pulling its box +// up to q0's classical row and enclosing q1. The recursion span uses +// `getMinMaxRegIdx` (includes the classical row) rounded inward, giving [1, 2] +// — so q1 is covered while q0's quantum wire (above the row) stays outside. +// --------------------------------------------------------------------------- + +test("nested dropzones cover intermediate wires under a classical-control reach", () => { + const cg = singleCircuit( + circuit(qubits(3, { 0: 1 }), [ + [meas(0)], + [ + group("Foo", [[gate("X", 2)]], { + expanded: true, + ctrls: [{ q: 0, r: 0 }], + conditional: true, + }), + ], + ]), + ); + + const dropzones = renderAndCollectDropzones(cg); + // Foo is the only op in top-level column 1 → its inner scope is "1,0-". + const nested = nestedUnder(dropzones, "1,0-"); + + assert.ok( + nested.length > 0, + "expected nested dropzones inside the classically-controlled group", + ); + + // The intermediate wire 1 — enclosed by the box's classical reach but + // touched by no child — must carry at least one inner dropzone. + const wire1 = nested.filter((d) => d.wire === 1); + assert.ok( + wire1.length > 0, + `intermediate wire 1 must get inner dropzones under the classical reach; got wires: ${JSON.stringify( + nested.map((d) => d.wire), + )}`, + ); + + // q0's own quantum wire sits above the classical row, outside the box — + // so no inner dropzone should land on wire 0. + const wire0 = nested.filter((d) => d.wire === 0); + assert.deepEqual( + wire0, + [], + "q0's quantum wire is above the classical row, outside the box — no inner dropzone", + ); +}); + // --------------------------------------------------------------------------- // Nested dropzones must appear when a group is rendered expanded by the renderer (via `renderDepth` // or expand-button click) even when the source op has no pre-baked `dataAttributes.expanded` flag. diff --git a/source/npm/qsharp/ux/circuit-vis/README.md b/source/npm/qsharp/ux/circuit-vis/README.md index 11d9307e257..e0c373936a7 100644 --- a/source/npm/qsharp/ux/circuit-vis/README.md +++ b/source/npm/qsharp/ux/circuit-vis/README.md @@ -373,6 +373,10 @@ Out of scope for this PR. - **`LayoutMap` is the single source of geometry.** Dropzones and the editor's ghost positioning read from `LayoutMap`, not from rendered SVG attributes. Keeps the editor accurate for nested scopes too. + - **One documented exception:** the shift-extend ghost. `makeShiftExtendGhost` + ([draggable.ts](editor/draggable.ts)) clones the group's rendered box `` and slides one + edge, because `LayoutMap` records per-column geometry but not the box's outer rectangle (its + label-inclusive top, classical-control reach, or label-stretched width). Used only to draw the hover preview. The actual circuit-edit still comes from the location string. --- @@ -450,4 +454,5 @@ node --test "test/circuit-editor/**/*.test.mjs" - **`null` means "not found".** `findOperation` & friends return `null` for both "no input" and "out of bounds" — callers stay defensive, no throws to catch. - **One overlay group, one source of geometry.** Don't append to `svg.qviz` directly; use - `ctx.overlayLayer`. Don't measure rendered SVG; ask the `LayoutMap`. + `ctx.overlayLayer`. Don't measure rendered SVG; ask the `LayoutMap`. (Exception: the shift-extend + ghost — see Notable invariants.) diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts index b47bd97096c..959960f3d37 100644 --- a/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts @@ -32,7 +32,7 @@ import { promptForArguments } from "../prompts.js"; import { QubitController } from "./qubitController.js"; import { enableAutoScroll } from "./scrollController.js"; import { toolboxGateDictionary } from "../toolboxGates.js"; -import { getGateElems, getToolboxElems } from "../domUtils.js"; +import { getGateElems, getGroupBoxElem, getToolboxElems } from "../domUtils.js"; import { deepEqual, findOperation, @@ -55,27 +55,21 @@ import { */ export class DragController { /** - * Shift-extend context, populated by `onGateMouseDown` when the - * dragged source is internal to an expanded group, cleared by - * `tearDownShiftExtend` on container mouseup. Drives the extra - * "extend vertically" dropzones and the ghost-border overlay. - * `null` whenever the current drag can't extend a group. + * Shift-extend context: populated by `onGateMouseDown` when the drag source is inside an expanded + * group, cleared by `tearDownShiftExtend` on mouseup. Drives the extend dropzones and ghost + * border. `null` when the drag can't extend a group. */ private _shiftExtendCtx: { /** Hierarchical location of the immediate parent group G. */ parentLoc: string; - /** `[minWire, maxWire]` of G's current target span. */ + /** `[minWire, maxWire]` of G's current quantum target span. */ parentMinWire: number; parentMaxWire: number; /** Geometry of G's children scope, from `LayoutMap.scopes`. */ parentScope: LayoutScope; } | null = null; - /** - * Dropzones spawned by `spawnShiftExtendDropzones`, tracked - * separately so shift-release can clear them ahead of the - * container-mouseup cleanup. - */ + /** Dropzones from `spawnShiftExtendDropzones`, tracked so shift-release can clear them early. */ private _shiftExtendDropzones: SVGElement[] = []; /** Ghost-border rect currently painted in the overlay, if any. */ @@ -680,14 +674,9 @@ export class DragController { ******************************/ /** - * Arm the shift-extend pathway for a new internal-source drag. - * No-op if `selectedAddr` is top-level (no parent group to extend) - * or if the immediate parent's children scope isn't tracked by the - * LayoutMap (defensive). - * - * On the happy path: captures the parent group's wire span + - * scope, installs document shift keydown/keyup listeners, and - * spawns initial dropzones if shift is already held at drag start. + * Arm the shift-extend pathway for a new internal-source drag. No-op if `selectedAddr` is + * top-level or the parent's children scope isn't in the LayoutMap. On the happy path: captures + * the parent group's wire span + scope and installs document shift keydown/keyup listeners. */ private setupShiftExtend(selectedAddr: Location): void { if (selectedAddr.depth < 2) return; // top-level source @@ -698,9 +687,8 @@ export class DragController { const parentOp = findOperation(this.ctx.model.componentGrid, parentLoc); if (parentOp == null) return; - // Quantum-only span: shift-extend reach mirrors the group's - // editable wire scope, not its visual span including any - // classical-control back-references. + // Quantum-only span: shift-extend reach mirrors the group's editable wire scope, not its + // visual span. const [parentMinWire, parentMaxWire] = getQuantumWireRange(parentOp); this._shiftExtendCtx = { @@ -710,9 +698,8 @@ export class DragController { parentScope, }; - // Install live shift tracking. Document-level because the user - // may shift+drag with the cursor outside the SVG (e.g. hovering - // the editor chrome on the way to the target wire). + // Live shift tracking. Document-level because the user may shift+drag with the cursor outside + // the SVG. this._onShiftDown = (ev) => { if (ev.key !== "Shift") return; this.spawnShiftExtendDropzones(); @@ -726,10 +713,7 @@ export class DragController { document.addEventListener("keyup", this._onShiftUp); } - /** - * Tear down shift-extend state for the current (or just-ended) - * drag. Idempotent — safe to call when nothing was armed. - */ + /** Tear down shift-extend state for the current (or just-ended) drag. Idempotent. */ private tearDownShiftExtend(): void { this.clearShiftExtendDropzones(); this.clearGhostBorder(); @@ -745,24 +729,13 @@ export class DragController { } /** - * Spawn the temporary "extend group vertically" dropzones for the - * currently-armed shift-extend context. Re-spawn-safe (clears - * existing first), idempotent for the same context. - * - * Emitted at every `(column, wire)` pair where: - * - `column` is one of the parent group's existing inner columns - * OR the trailing-append column past its rightmost child; - * - `wire` is in `[0, wireData.length)` but NOT in the parent - * group's `[minTarget, maxTarget]` span. + * Spawn the temporary "extend group" dropzones for the armed shift-extend context. Re-spawn-safe. * - * Each dropzone is tagged `data-shift-extend="true"` so the - * mouseup handler can recognize a shift-extend release for - * visual cleanup (the ghost border). The action layer - * (`moveOperation`) always re-derives ancestor `.targets` from - * post-move children, so no special routing on the action call - * is needed \u2014 the location string of the dropzone is enough. - * Hover-enter paints the ghost border for that wire; hover-leave - * clears it. + * Emitted at every `(column, wire)` where `column` is one of the group's inner columns or the + * trailing-append column, and `wire` is outside the group's span. Each is tagged + * `data-shift-extend="true"` so mouseup can recognize the release; the dropzone's location string + * is enough for `moveOperation` to re-derive ancestor `.targets`. Hover paints/clears the ghost + * border. */ private spawnShiftExtendDropzones(): void { if (this._shiftExtendCtx == null) return; @@ -774,58 +747,71 @@ export class DragController { // +1 for the trailing-append column past the rightmost. const totalCols = realColCount + 1; - // Wires the group can't directly extend onto because a sibling - // at some level of the ancestor chain already occupies them in - // that level's outer column — dropping there would land the new - // op directly on an existing one. We walk the full ancestor - // chain since shift-extend widens every ancestor whose span - // doesn't already enclose the drop wire. - // - // The cross-over case (extending past an in-between sibling to a - // clear wire) is intentionally not filtered: `moveOperation`'s - // dest-side cascade splits the outer column so the in-between - // sibling slides one column right of the widened ancestor. + // Wires an ancestor-chain sibling already occupies in its outer column — dropping there would + // land the new op on an existing one. The cross-over case (past an in-between sibling to a clear + // wire) is intentionally not filtered: `moveOperation`'s cascade splits the outer column. const blockedWires = getAncestorColumnSiblingWires( this.ctx.model.componentGrid, parentLoc, ); + // "Inside" is decided by the box's pixel span, not the quantum-target range: a classically- + // controlled group's box reaches to the producing M's classical wire, so intermediate wires + // under that reach are visually inside. Falls back to the quantum span if the box is missing. + const groupBox = getGroupBoxElem(this.ctx.container, parentLoc); + const boxTop = groupBox != null ? Number(groupBox.getAttribute("y")) : null; + const boxBottom = + groupBox != null && boxTop != null + ? boxTop + Number(groupBox.getAttribute("height")) + : null; + const isInsideGroup = (wire: number): boolean => { + if (boxTop != null && boxBottom != null) { + const wireY = this.ctx.wireData[wire]; + return wireY >= boxTop && wireY <= boxBottom; + } + return wire >= parentMinWire && wire <= parentMaxWire; + }; + const dropzoneCtx = { scope: parentScope, wireData: this.ctx.wireData, pathPrefix: parentLoc, }; for (let colIndex = 0; colIndex < totalCols; colIndex++) { + const isTrailingCol = colIndex >= realColCount; for (let wire = 0; wire < this.ctx.wireData.length; wire++) { - // Only emit for wires outside the group's current span; wires - // inside already have regular inner dropzones. - if (wire >= parentMinWire && wire <= parentMaxWire) continue; + // Only wires outside the group's span; inside wires already have regular dropzones. + if (isInsideGroup(wire)) continue; // Skip wires a sibling already occupies (see `blockedWires`). if (blockedWires.has(wire)) continue; - // opIndex = 0: the wire is outside the group's span so no op - // in this column shares it; the op slots in without splicing - // a new column. - const dropzone = makeDropzoneBox(dropzoneCtx, { - colIndex, - opIndex: 0, - wireIndex: wire, - interColumn: false, - }); - dropzone.setAttribute("data-shift-extend", "true"); - // Force a normal drop (no new outer column), not an - // insert-between gesture. - dropzone.setAttribute("data-dropzone-inter-column", "false"); - dropzone.addEventListener("mouseup", this.onDropzoneMouseUp); - dropzone.addEventListener("mouseenter", () => { - this.paintGhostBorder(wire, colIndex); - }); - dropzone.addEventListener("mouseleave", () => { - this.clearGhostBorder(); - }); - this.ctx.dropzoneLayer.appendChild(dropzone); - this._shiftExtendDropzones.push(dropzone); + // Mirror the regular inner-dropzone shapes: the narrow band (`true`) inserts a new column, + // the full box (`false`) drops into the existing column. The trailing-append column gets + // only the band (no column body to drop onto). `opIndex = 0`: the wire is outside the + // group's span, so nothing else in this column shares it. + const shapes: boolean[] = isTrailingCol ? [true] : [true, false]; + for (const interColumn of shapes) { + const dropzone = makeDropzoneBox(dropzoneCtx, { + colIndex, + opIndex: 0, + wireIndex: wire, + interColumn, + }); + dropzone.setAttribute("data-shift-extend", "true"); + // Keep the shape-derived `data-dropzone-inter-column`: the band inserts a new inner column + // at `colIndex`, the full box drops into the existing one. Both target this group's + // children scope, so neither inserts an outer column. + dropzone.addEventListener("mouseup", this.onDropzoneMouseUp); + dropzone.addEventListener("mouseenter", () => { + this.paintGhostBorder(wire, colIndex, interColumn); + }); + dropzone.addEventListener("mouseleave", () => { + this.clearGhostBorder(); + }); + this.ctx.dropzoneLayer.appendChild(dropzone); + this._shiftExtendDropzones.push(dropzone); + } } } } @@ -843,21 +829,35 @@ export class DragController { } /** - * Paint the ghost-border overlay for the given hover wire and - * column. Replaces any existing ghost border (so moving between - * shift-extend dropzones updates the preview). + * Paint the ghost-border overlay for the hover wire/column, replacing any existing one. `isBand` + * marks the narrow inter-column band (inserts a column, so the ghost grows) vs the full-column + * box (no horizontal change); only the outer bands grow, always on the right edge. */ - private paintGhostBorder(hoverWire: number, hoverCol: number): void { + private paintGhostBorder( + hoverWire: number, + hoverCol: number, + isBand: boolean, + ): void { if (this._shiftExtendCtx == null) return; this.clearGhostBorder(); - const { parentScope, parentMinWire, parentMaxWire } = this._shiftExtendCtx; + const { parentLoc, parentScope } = this._shiftExtendCtx; + + // Clone the group's rendered box so the ghost matches it exactly, then slide the one edge the + // hovered wire extends. Direction/offset come from the box's pixel bounds. + const groupBox = getGroupBoxElem(this.ctx.container, parentLoc); + const hoverWireY = this.ctx.wireData[hoverWire]; + if (groupBox == null || hoverWireY == null) return; + + // Only the outer inserting bands widen the box — the trailing band past the last child and the + // leading band before column 0. Inner bands and full boxes leave the width unchanged. + const extendColumn = + isBand && + (hoverCol >= parentScope.columnXOffsets.length || hoverCol === 0); this._ghostBorder = makeShiftExtendGhost( + groupBox, + hoverWireY, + extendColumn, parentScope, - this.ctx.wireData, - parentMinWire, - parentMaxWire, - hoverWire, - hoverCol, ); this.ctx.overlayLayer.appendChild(this._ghostBorder); } diff --git a/source/npm/qsharp/ux/circuit-vis/editor/domUtils.ts b/source/npm/qsharp/ux/circuit-vis/editor/domUtils.ts index 0787c28be40..e4c59ad8e0f 100644 --- a/source/npm/qsharp/ux/circuit-vis/editor/domUtils.ts +++ b/source/npm/qsharp/ux/circuit-vis/editor/domUtils.ts @@ -86,13 +86,8 @@ const getQubitLabelElems = (container: HTMLElement): SVGTextElement[] => { }; /** - * Parse a host element's `data-wire-ys` attribute into a number array. The renderer writes the - * wire-Y coordinates the element visually spans onto this attribute as a JSON array of numbers (see - * [`gateFormatter.ts`](../renderer/formatters/gateFormatter.ts)). - * - * Returns `[]` when the attribute is missing or malformed — same convention `_wireYs` in - * [`draggable.ts`](draggable.ts) follows. Lives here so the selection / drag controllers can read - * host-element wire spans without duplicating the parse. + * Parse a host element's `data-wire-ys` attribute (a JSON number array of the wire-Y coordinates it + * spans, written by the renderer) into a number array. Returns `[]` when missing or malformed. */ const parseWireYs = (elem: Element): number[] => { const wireYsAttr = elem.getAttribute("data-wire-ys"); @@ -108,12 +103,45 @@ const parseWireYs = (elem: Element): number[] => { return []; }; +/** + * Find the own dashed box `` of an expanded group by its `data-location`. The box is the + * group ``'s first direct-child `` (`gate-unitary`, or `classical-container` when + * classically controlled); restricting to direct children skips nested children's boxes. Returns + * `null` when the group or its box isn't found (e.g. collapsed). Cloned by the shift-extend ghost. + * + * @param container The HTML container element containing the circuit visualization. + * @param location The `data-location` string of the group. + * @returns The group's own box ``, or `null` if not found. + */ +const getGroupBoxElem = ( + container: HTMLElement, + location: string, +): SVGGraphicsElement | null => { + const circuitSvg = container.querySelector("svg.qviz"); + if (circuitSvg == null) return null; + const groupElem = circuitSvg.querySelector( + `.gate[data-location="${location}"]`, + ); + if (groupElem == null) return null; + for (const child of Array.from(groupElem.children)) { + if ( + child.tagName.toLowerCase() === "rect" && + (child.classList.contains("gate-unitary") || + child.classList.contains("classical-container")) + ) { + return child as SVGGraphicsElement; + } + } + return null; +}; + export { findGateElem, getWireData, getToolboxElems, getHostElems, getGateElems, + getGroupBoxElem, getQubitLabelElems, parseWireYs, }; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts b/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts index b41f9fe932a..63ce89cfbe5 100644 --- a/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts +++ b/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts @@ -5,6 +5,9 @@ import { ComponentGrid, Operation } from "../data/circuit.js"; import { gateHeight, gatePadding, + groupBottomPadding, + groupPaddingX, + groupTopPadding, minGateWidth, regLineStart, startX, @@ -17,7 +20,7 @@ import { Location } from "../data/location.js"; import { toRenderData } from "./standaloneRenderData.js"; import { Sqore } from "../sqore.js"; import { getHostElems, getToolboxElems, getWireData } from "./domUtils.js"; -import { getQuantumWireRange } from "../utils.js"; +import { getMinMaxRegIdx, getQuantumWireRange } from "../utils.js"; /** Register height is the height of a single gate including the padding on the top and bottom. */ const registerHeight: number = gateHeight + gatePadding * 2; @@ -396,23 +399,12 @@ const _dropzoneLayer = (context: Context) => { }; /** - * Append a trailing-column band of dropzones (one per wire in `[minWire, maxWire)`) just past the - * rightmost column of a single scope — either the top-level grid or an expanded group's children - * grid. - * - * Each emitted dropzone is shaped like the existing left-edge inter-column band (so it visually - * reads as "I'm extending this scope to the right"), but tagged - * `data-dropzone-inter-column="false"` so the drop handler treats it as a normal drop. The `_addOp` - * action takes care of synthesizing the new column when the target column index is one past the - * rightmost. - * - * Together with the leading-column band that already falls out of the `_populateDropzonesForGrid` - * loop at `colIndex=0`, this gives every expanded group a one-column-of-reach extend-sideways - * gesture on both edges, no modifier required. - * - * Idempotent w.r.t. wire extent: at the top level, `[minWire, maxWire)` is `[0, wireData.length)`. - * For nested scopes it's the parent group's own wire span, so the trailing column can't escape the - * group's vertical bounds. + * Append a trailing-column band of dropzones (one per wire in `[minWire, maxWire)`) just past a + * scope's rightmost column. Shaped like the left-edge inter-column band but tagged + * `data-dropzone-inter-column="false"` so the drop handler treats it as a normal drop; `_addOp` + * synthesizes the new column. With the leading band emitted at `colIndex=0`, every expanded group + * gets an extend-sideways gesture on both edges. `[minWire, maxWire)` bounds the band to the scope's + * wire span so a nested column can't escape its group. */ const _appendTrailingColumnForScope = ( dropzoneLayer: SVGElement, @@ -453,9 +445,8 @@ const _appendTrailingColumnForScope = ( * Doubles as the `LayoutMap.scopes` key. * @param wireData Full circuit wire-Y array (wires don't get reindexed inside groups; child * operations still reference circuit-wide qubit IDs). - * @param minWire Inclusive lower bound on wire indices this scope is allowed to produce - * dropzones for. At top level this is `0`; for nested scopes it's the parent group's top wire so - * a drop inside `Foo` (which spans wires 0-1) can never land on wire 2. + * @param minWire Inclusive lower bound on wire indices this scope may emit dropzones for + * (`0` at top level; the parent group's top wire when nested). * @param maxWire Exclusive upper bound, mirror of `minWire`. */ const _populateDropzonesForGrid = ( @@ -483,28 +474,14 @@ const _populateDropzonesForGrid = ( const columnOps = grid[colIndex]; if (columnOps == null) continue; - // Precompute which wires this column's ops actually occupy. A central dropzone at an occupied - // wire would visually sit on top of a gate (or its connecting lines), even if the gate belongs - // to a different op than the one being iterated — so the "is this wire safe for a central - // drop?" question can't be answered from a single op in isolation. - // - // We also need a per-wire `opIndex` for the dropzone's location string. The action layer treats - // `opIndex` as the array position to insert at (`Array.splice(opIndex, 0, op)`); the renderer - // doesn't depend on array order for layout. So: + // Precompute which wires this column's ops occupy, plus a per-wire `opIndex` for the location + // string. A central dropzone on an occupied wire would sit on top of a gate, so occupancy must + // be answered across the whole column, not one op in isolation. `opIndex` is the array position + // to insert at: an owned wire uses the owning op's index (drop "onto" the gate); an unowned wire + // uses `components.length` (drop in a gap appends). First claimant of a wire wins. // - // - Owned wire → use the owning op's opIndex. Drops "onto" the gate insert at the gate's - // array position. - // - Unowned wire → use `components.length`. Drops in a gap append to the column's array. - // - // Walk ops in declared order; first claimant of a wire wins (overlapping ops in one column - // shouldn't occur — the action layer's `_addOp` splits them into separate columns — but - // defensive anyway). - // - // Quantum-only span: a classically-controlled op back-references the producing measurement's - // qubit via `.controls`, but doesn't render any body on that wire (only a small - // classical-control circle sits on the row). Treating that wire as occupied would suppress the - // central dropzone there, leaving the visually-empty area at the group's column un-droppable - // for top-level inserts. + // Uses the quantum-only span: a classically-controlled op back-references the producing M's + // qubit but renders no body there, so that wire must stay droppable. const occupiedWires = new Set(); const wireOwnerOpIndex = new Map(); columnOps.components.forEach((op, opIndex) => { @@ -517,17 +494,14 @@ const _populateDropzonesForGrid = ( } }); - // Wire-by-wire pass. The previous algorithm accumulated a monotonically-increasing `wireIndex` - // across ops; that assumed ops were sorted by `minTarget`, which the compiler often violates - // (it tends to emit ops in execution order rather than wire order). Iterating wires directly - // removes that assumption and emits the same boxes for the sorted-by-wire common case. + // Wire-by-wire pass — iterating wires directly (rather than accumulating across ops) avoids + // assuming ops are sorted by `minTarget`, which the compiler often violates. for (let wireIndex = minWire; wireIndex < maxWire; wireIndex++) { const opIndex = wireOwnerOpIndex.get(wireIndex) ?? columnOps.components.length; - // Inter-column band: always emit. It's a narrow vertical strip on the left edge of the column - // ("insert a new column before this one"); even when it slightly overlaps a gate's body it - // doesn't visually conflict with the gate icon. + // Inter-column band: always emit — a narrow strip on the column's left edge ("insert a new + // column before this one"), harmless even when it overlaps a gate body. dropzoneLayer.appendChild( makeDropzoneBox(ctx, { colIndex, @@ -537,10 +511,8 @@ const _populateDropzonesForGrid = ( }), ); - // Central full-width box: emit only at wires NOT occupied by any op in this column. This is - // the fix for the phantom dropzone bug — without the column-wide occupancy check, an op's own - // "above-me" wires (`wireIndex < minTarget`) could be occupied by a different op later in the - // column, and the central box would sit on top of that op's gate. + // Central full-width box: emit only at wires no op in this column occupies, so it never sits + // on top of another op's gate. if (!occupiedWires.has(wireIndex)) { dropzoneLayer.appendChild( makeDropzoneBox(ctx, { @@ -553,24 +525,20 @@ const _populateDropzonesForGrid = ( } } - // Recurse into expanded children. Decoupled from the wire loop above because recursion depends - // only on the op's identity and wire extent, not on `wireIndex`. - // - // The recursion's wire extent matches the group's own [minTarget, maxTarget] (inclusive), - // ensuring nested dropzones can never escape the parent group. Drive the is-this-expanded - // decision off the LayoutMap rather than `isExpandedGroup(op)`: `op` here belongs to - // `sqore.circuit.componentGrid` (the original), while expand flags from - // `expandOperationsToDepth`, `expandIfSingleOperation`, and the user's expand-chevron clicks - // are applied to the per-render deep copy only — never to the original. The LayoutMap, built - // from that deep copy, is the authoritative record of which groups were rendered expanded. + // Recurse into expanded children. Drive the is-expanded decision off the LayoutMap, not + // `isExpandedGroup(op)`: `op` is from the original grid, but expand flags live only on the + // per-render deep copy the LayoutMap was built from. columnOps.components.forEach((op, opIndex) => { const childKey = composeLocation(pathPrefix, colIndex, opIndex); if (op.children != null && layoutMap.scopes.has(childKey)) { - // Quantum-only span: a classically-controlled group's `.controls` carries the producing - // measurement's qubit as a back-reference, but that qubit isn't a member wire of the group. - // Including it here would make drops onto that qubit (and adds from the toolbox) silently - // land inside the group; the user has to shift-drag to extend the group to a new wire. - const [minTarget, maxTarget] = getQuantumWireRange(op); + // Inner dropzones must cover every qubit wire the box visually encloses, including + // intermediate wires between a classical-control row and the quantum targets. + // `getMinMaxRegIdx` gives fractional bounds (classical rows at `q + 0.5`); rounding inward + // (`ceil`/`floor`) yields the enclosed integer wire range and excludes the control's own + // qubit wire (row `0.5` → wire 1, so q0's quantum wire above the row stays outside). + const [minRow, maxRow] = getMinMaxRegIdx(op); + const minTarget = Math.ceil(minRow); + const maxTarget = Math.floor(maxRow); _populateDropzonesForGrid( dropzoneLayer, layoutMap, @@ -584,11 +552,8 @@ const _populateDropzonesForGrid = ( }); } - // Trailing-append column for this scope. At the top level this is the "add a brand-new column - // past the rightmost" affordance; for an expanded group it's the right-edge extend-sideways band - // that mirrors the leading-column band emitted at `colIndex=0` of the column loop above. Runs - // once per scope, after the column loop, so it sits at the same recursion depth as the children - // walk. + // Trailing-append column for this scope: the "add a new column past the rightmost" band, + // mirroring the leading band at `colIndex=0`. Once per scope, after the column loop. _appendTrailingColumnForScope( dropzoneLayer, scope, @@ -626,8 +591,7 @@ const columnGeometry = ( colWidth: scope.columnWidths[colIndex] ?? minGateWidth, }; } - // Synthesize a column past the rightmost. Spacing matches the historical accumulator - // (`gatePadding * 2` between columns). + // Synthesize a column past the rightmost, `gatePadding * 2` beyond the last column. const lastIndex = scope.columnXOffsets.length - 1; if (lastIndex >= 0) { const lastStart = scope.columnXOffsets[lastIndex]; @@ -737,70 +701,68 @@ const makeDropzoneBox = ( }; /** - * Build the ghost-border `` that previews a group's extended - * bounding box during a D4 Stage B shift+drag. + * Build the ghost-border `` previewing a group's extended box during a shift+drag. * - * The rect covers: + * Clones the group's actual rendered dashed box and moves only the one edge the hover extends, so + * the resting edges (label room, classical-control reach, side padding) match the real box exactly. + * Edge movement uses the clone's pixel bounds, not the model's wire span, so a classically- + * controlled group whose box is driven by a classical wire still extends correctly: * - * - Horizontally: from the group's leftmost column's start x to its - * rightmost column's right edge. If `hoverColIndex` lies past the - * last column (the trailing-append column), the rect extends right - * to include that synthesized column too — so the user sees the - * group's new horizontal footprint along with the new vertical - * one when the drop is on the trailing column. - * - Vertically: from `min(top wire Y, hover wire Y)` to - * `max(bottom wire Y, hover wire Y)`, padded by `DROPZONE_PADDING_Y` - * on each side so the ghost reads as a generous halo around the - * group's body rather than a tight stripe over the wires. + * - `hoverWireY` above the box top → top edge drops to `hoverWireY - (gateHeight/2 + + * groupTopPadding)`; bottom edge stays put. + * - `hoverWireY` below the box bottom → symmetric with `groupBottomPadding`; top edge stays put. + * - `hoverWireY` inside the box → no vertical change (defensive; caller only paints outside wires). * - * Coordinates come entirely from `LayoutScope` + `wireData`, the - * same sources Stage A's dropzones use. No DOM lookup of the - * group's rendered `` — that would couple the overlay to - * `gateFormatter`'s internal structure. + * When `extendColumn` is set (a band inserting a new column), the box grows rightward just enough to + * enclose the added min-width column plus side padding — clamped at 0, so a box already widened by a + * long label absorbs the new column instead of overshooting. Growth is always on the right edge: + * the circuit only grows rightward, so even a leading-column insert shows the space on the right. * - * Caller appends the returned element to `overlayLayer` and removes - * it on hover-off / shift-release / mouseup. + * The clone is re-tagged `shift-extend-ghost` for the translucent-preview CSS. */ const makeShiftExtendGhost = ( + groupBox: SVGGraphicsElement, + hoverWireY: number, + extendColumn: boolean, scope: LayoutScope, - wireData: number[], - groupMinWire: number, - groupMaxWire: number, - hoverWireIndex: number, - hoverColIndex: number, ): SVGElement => { - // Horizontal: leftmost column start → rightmost column right edge. - // The trailing-append case (hoverColIndex past the last real - // column) extends right via `columnGeometry`'s synthesized position - // so the hover column gets covered too. - const leftGeom = columnGeometry(scope, 0); - const lastRealColIndex = Math.max(scope.columnXOffsets.length - 1, 0); - const rightRealGeom = columnGeometry(scope, lastRealColIndex); - const rightRealEdge = rightRealGeom.colStartX + rightRealGeom.colWidth; - const rightTrailGeom = columnGeometry(scope, scope.columnXOffsets.length); - const rightEdge = - hoverColIndex >= scope.columnXOffsets.length - ? rightTrailGeom.colStartX + rightTrailGeom.colWidth - : rightRealEdge; - - // Vertical: pull in the existing wire span plus the hovered wire, - // and pad. We index `wireData` defensively in case `hoverWireIndex` - // is the trailing ghost-qubit row (length == wireData.length); fall - // back to the last real wire if so, since extending onto the ghost - // row isn't a supported action. - const topWireY = wireData[groupMinWire] ?? wireData[0]; - const bottomWireY = wireData[groupMaxWire] ?? wireData[wireData.length - 1]; - const hoverWireY = wireData[hoverWireIndex] ?? wireData[wireData.length - 1]; - const topY = Math.min(topWireY, hoverWireY) - DROPZONE_PADDING_Y; - const bottomY = Math.max(bottomWireY, hoverWireY) + DROPZONE_PADDING_Y; - - return box( - leftGeom.colStartX - gatePadding, - topY, - rightEdge - leftGeom.colStartX + gatePadding * 2, - bottomY - topY, - "shift-extend-ghost", - ); + const boxLeft = Number(groupBox.getAttribute("x")); + const boxTop = Number(groupBox.getAttribute("y")); + const width = Number(groupBox.getAttribute("width")); + const height = Number(groupBox.getAttribute("height")); + const boxBottom = boxTop + height; + + // Label-inclusive pads the renderer leaves between a group's edge and its nearest wire. The moved + // edge lands this far from the hovered wire; the opposite edge keeps the clone's value. + const topPad = gateHeight / 2 + groupTopPadding; + const bottomPad = gateHeight / 2 + groupBottomPadding; + + let newTop = boxTop; + let newBottom = boxBottom; + if (hoverWireY < boxTop) { + newTop = hoverWireY - topPad; + } else if (hoverWireY > boxBottom) { + newBottom = hoverWireY + bottomPad; + } + + // Grow rightward to enclose the synthesized trailing column plus side padding. Clamp at 0 so a + // box already widened by a long label absorbs the new column instead of overshooting. + let newRight = boxLeft + width; + if (extendColumn) { + const trailingIndex = scope.columnXOffsets.length; + const { colStartX, colWidth } = columnGeometry(scope, trailingIndex); + const neededRight = colStartX + colWidth + groupPaddingX; + const growth = Math.max(0, neededRight - newRight); + newRight += growth; + } + + const ghost = groupBox.cloneNode(false) as SVGGraphicsElement; + ghost.setAttribute("x", `${boxLeft}`); + ghost.setAttribute("y", `${newTop}`); + ghost.setAttribute("height", `${newBottom - newTop}`); + ghost.setAttribute("width", `${newRight - boxLeft}`); + ghost.setAttribute("class", "shift-extend-ghost"); + return ghost; }; export { From 38012b639caa84df3c5c15cf16d31afcd1089d22 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Wed, 5 Aug 2026 15:06:21 -0700 Subject: [PATCH 3/3] update snapshot test --- ...l-control-producer-below.qsc.snapshot.html | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc.snapshot.html index 3e405476e23..d73e73c5490 100644 --- a/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc.snapshot.html @@ -636,6 +636,26 @@

Toolbox

data-dropzone-wire="1" data-dropzone-inter-column="false" /> + + Toolbox data-dropzone-wire="1" data-dropzone-inter-column="true" /> + + Toolbox data-dropzone-wire="1" data-dropzone-inter-column="false" /> +