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 93edf11f708..10e989e759b 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 @@ -29,6 +29,8 @@ import { expectOp, gate, group, + meas, + qubits, } from "../_helpers.mjs"; // --------------------------------------------------------------------------- @@ -75,6 +77,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 4ccdff3f15f..306cb54ef5a 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} */ @@ -696,6 +697,322 @@ 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("setupShiftExtend no-ops when the parent scope isn't in the LayoutMap (defensive)", () => { + // Defensive — every expanded group's scope should be in the + // LayoutMap, but the method must skip silently rather than + // throw if it isn't. + const { dragController } = setup( + circuit(2, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + // Intentionally do NOT register a scope for "0,0". + + /** @type {any} */ (dragController).setupShiftExtend( + Location.parse("0,0-0,0"), + ); + + assert.equal(/** @type {any} */ (dragController)._shiftExtendCtx, null); + assert.equal(/** @type {any} */ (dragController)._onShiftDown, null); + + 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 (would be visible as a doubled halo). + 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 09682e067af..1458234ff87 100644 --- a/source/npm/qsharp/test/circuit-editor/draggable.test.mjs +++ b/source/npm/qsharp/test/circuit-editor/draggable.test.mjs @@ -3,12 +3,15 @@ // 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 +// 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. @@ -28,6 +31,7 @@ import assert from "node:assert/strict"; import { createWireDropzone, makeDropzoneBox, + makeShiftExtendGhost, removeAllWireDropzones, } from "../../dist/ux/circuit-vis/editor/draggable.js"; @@ -180,6 +184,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/ARCHITECTURE.md b/source/npm/qsharp/ux/circuit-vis/ARCHITECTURE.md index bb613b08e54..6dc61f6372c 100644 --- a/source/npm/qsharp/ux/circuit-vis/ARCHITECTURE.md +++ b/source/npm/qsharp/ux/circuit-vis/ARCHITECTURE.md @@ -348,22 +348,28 @@ 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. +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 @@ -474,8 +480,10 @@ 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 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 f5fc2d03fd8..e96449915a3 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,17 +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. + * 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. */ /** @@ -48,6 +52,119 @@ const moveX = ( ); }; +/** + * Should we move `op` as a single rigid unit (shift every register + * by the same delta), or as a single leg (rewire just the grabbed + * register)? + * + * Move as a unit when: + * - `op` is a group (has `children`): the user grabbed the box and + * expects the children to come along. + * - `op` has more than one target/qubit in the relevant axis (SWAP, + * multi-qubit measurement): single-leg would collapse it. + * + * Single-leg behavior covers the ordinary controlled-gate cases (one + * target + N controls) so the user can drag the target or any one + * control independently ("rewire one leg of a CNOT"). + * + * `movingControl` takes precedence over the group check: a control + * on a group is still a single leg (rewire just the control), so + * dragging it doesn't slide the whole 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 wire-axis register of `op` — and recursively every + * child's — by `delta`. Used when moving a multi-wire op as a rigid + * unit so the gate keeps its shape on the new wires. + * + * Classical controls (registers with `result` set) need care: the + * question isn't "is this classical?" but "is what it references also + * moving?". A producing measurement INSIDE the moved subtree shifts + * by the same delta, so its consumer shifts too; a producer OUTSIDE + * stays put, so the consumer stays anchored. We first collect the + * `(qubit, result)` tuples produced inside the subtree, then for each + * classical control: present → shift, absent → anchor. + */ +const shiftAllRegisters = (op: Operation, delta: number): void => { + if (delta === 0) return; + const internalProducers = collectInternalClassicalRegs(op); + _doShift(op, delta, internalProducers); +}; + +/** + * The actual recursive shift. See `shiftAllRegisters` for the + * classical-control rationale. + * + * Applies to ALL register-bearing fields, not just `controls`: + * classically-conditional unitaries record dependencies in both + * `controls` AND `targets` (the `targets` entries are visual extent + * claims drawing the line down to the classical register box). A + * naively-shifted external classical entry in `targets` would point + * at a wire with no classical 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 every register reference on `wireA` with every reference on + * `wireB` throughout `op`'s subtree. Used by the group + + * `movingControl` branch in `moveY` for the "drop the control onto a + * body wire to swap them" gesture; callers pass `op.children` + * directly so the group's own controls/targets are left for the + * caller to update. + * + * Classical-register entries get the same `qubit` swap as quantum + * ones — here we're swapping specific wires, so the external-producer + * "anchor" rule from `_doShift` doesn't apply. + */ +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 @@ -75,9 +192,24 @@ const collectMeasurementWires = (op: Operation, set: Set): void => { * 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, @@ -85,6 +217,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) { @@ -123,6 +267,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); @@ -132,6 +286,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; @@ -140,6 +305,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 }]; } @@ -154,4 +325,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 27e2586f1f8..81af7eb4a34 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"; /* @@ -159,8 +162,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); @@ -451,6 +468,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 = ( @@ -459,6 +481,7 @@ const addOperation = ( targetLocation: string, targetWire: number, insertNewColumn: boolean = false, + sourceWire?: number, ): Operation | null => { const targetOperationParent = findParentArray( model.componentGrid, @@ -472,18 +495,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. @@ -500,6 +542,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. 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 126abf8d7a0..fd90cbdb66b 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"; @@ -31,8 +35,10 @@ import { toolboxGateDictionary } from "../toolboxGates.js"; import { deepEqual, findOperation, + getAncestorColumnSiblingWires, getGateElems, getGateLocationString, + getQuantumWireRange, getToolboxElems, } from "../../utils.js"; @@ -52,6 +58,37 @@ import { * `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, @@ -164,6 +201,10 @@ export class DragController { // (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 @@ -278,6 +319,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 @@ -296,6 +372,11 @@ export class DragController { // 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"; @@ -411,12 +492,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 { @@ -615,4 +701,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 044862caabc..360ceae185b 100644 --- a/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts +++ b/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts @@ -793,11 +793,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, };