From 2ad8017e09f35e941bcc17c9929a162922b99f01 Mon Sep 17 00:00:00 2001 From: Nick Oelmann Date: Wed, 1 Jul 2026 12:36:37 +0200 Subject: [PATCH 01/11] #160: centralize quantum rotation angle recognition --- frontend/src/lib/quantumAngle.test.ts | 104 +++++++++++++++ frontend/src/lib/quantumAngle.ts | 124 ++++++++++++++++++ .../views/circuit-view/util/quantikzMapper.ts | 59 +-------- 3 files changed, 230 insertions(+), 57 deletions(-) create mode 100644 frontend/src/lib/quantumAngle.test.ts create mode 100644 frontend/src/lib/quantumAngle.ts diff --git a/frontend/src/lib/quantumAngle.test.ts b/frontend/src/lib/quantumAngle.test.ts new file mode 100644 index 00000000..31a1757f --- /dev/null +++ b/frontend/src/lib/quantumAngle.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { angleToLatex, angleToUnicode, resolveAngle } from './quantumAngle'; + +describe('resolveAngle', () => { + it('recognizes the common rotation constants as π multiples', () => { + expect(resolveAngle(Math.PI)).toEqual({ kind: 'pi', numerator: 1, denominator: 1 }); + expect(resolveAngle(Math.PI / 2)).toEqual({ kind: 'pi', numerator: 1, denominator: 2 }); + expect(resolveAngle(Math.PI / 4)).toEqual({ kind: 'pi', numerator: 1, denominator: 4 }); + expect(resolveAngle(Math.PI / 8)).toEqual({ kind: 'pi', numerator: 1, denominator: 8 }); + // τ == 2π is modeled as the π multiple 2/1. + expect(resolveAngle(2 * Math.PI)).toEqual({ kind: 'pi', numerator: 2, denominator: 1 }); + }); + + it('recognizes deep power-of-two (QFT/phase) denominators', () => { + expect(resolveAngle(Math.PI / 16)).toEqual({ kind: 'pi', numerator: 1, denominator: 16 }); + expect(resolveAngle(Math.PI / 32)).toEqual({ kind: 'pi', numerator: 1, denominator: 32 }); + expect(resolveAngle(Math.PI / 128)).toEqual({ kind: 'pi', numerator: 1, denominator: 128 }); + }); + + it('recognizes negative constants', () => { + expect(resolveAngle(-Math.PI / 4)).toEqual({ kind: 'pi', numerator: -1, denominator: 4 }); + expect(resolveAngle(-2 * Math.PI)).toEqual({ kind: 'pi', numerator: -2, denominator: 1 }); + }); + + it('models an exact zero rotation as its own kind', () => { + expect(resolveAngle(0)).toEqual({ kind: 'zero' }); + expect(resolveAngle(1e-12)).toEqual({ kind: 'zero' }); + }); + + it('returns a plain number for values without a common π match', () => { + expect(resolveAngle(1.23)).toEqual({ kind: 'number', radians: 1.23 }); + // π/5 is a valid fraction but not in the curated common denominators. + expect(resolveAngle(Math.PI / 5)).toEqual({ kind: 'number', radians: Math.PI / 5 }); + }); + + it('applies the tolerance in radians, independent of denominator', () => { + const almostHalfPi = Math.PI / 2 + 1e-4; + + // Loose radian tolerance snaps it to π/2 ... + expect(resolveAngle(almostHalfPi, { tolerance: 1e-3 })).toEqual({ kind: 'pi', numerator: 1, denominator: 2 }); + // ... the default (strict) tolerance keeps it numeric. + expect(resolveAngle(almostHalfPi)).toEqual({ kind: 'number', radians: almostHalfPi }); + }); + + it('honors a configurable denominator set', () => { + // π/5 only matches when 5 is an allowed denominator. + expect(resolveAngle(Math.PI / 5, { denominators: [1, 5] })).toEqual({ + kind: 'pi', + numerator: 1, + denominator: 5, + }); + }); + + describe('normalize', () => { + it('does not fold by default', () => { + expect(resolveAngle(3 * Math.PI)).toEqual({ kind: 'pi', numerator: 3, denominator: 1 }); + }); + + it("folds onto (-π, π] with '2pi'", () => { + expect(resolveAngle(2 * Math.PI, { normalize: '2pi' })).toEqual({ kind: 'zero' }); + expect(resolveAngle(3 * Math.PI, { normalize: '2pi' })).toEqual({ + kind: 'pi', + numerator: 1, + denominator: 1, + }); + }); + + it("folds onto (-2π, 2π] with '4pi', preserving the 4π periodicity", () => { + // 3π ≡ -π under 4π periodicity (and stays distinct from π). + expect(resolveAngle(3 * Math.PI, { normalize: '4pi' })).toEqual({ + kind: 'pi', + numerator: -1, + denominator: 1, + }); + expect(resolveAngle(4 * Math.PI, { normalize: '4pi' })).toEqual({ kind: 'zero' }); + }); + }); +}); + +describe('angle renderers', () => { + it('renders π multiples as LaTeX', () => { + expect(angleToLatex(resolveAngle(Math.PI))).toBe(String.raw`\pi`); + expect(angleToLatex(resolveAngle(Math.PI / 2))).toBe(String.raw`\frac{\pi}{2}`); + expect(angleToLatex(resolveAngle(-Math.PI / 4))).toBe(String.raw`-\frac{\pi}{4}`); + expect(angleToLatex(resolveAngle(2 * Math.PI))).toBe(String.raw`2\pi`); + }); + + it('renders π multiples as Unicode', () => { + expect(angleToUnicode(resolveAngle(Math.PI))).toBe('π'); + expect(angleToUnicode(resolveAngle(Math.PI / 2))).toBe('π/2'); + expect(angleToUnicode(resolveAngle(-Math.PI / 4))).toBe('-π/4'); + expect(angleToUnicode(resolveAngle(2 * Math.PI))).toBe('2π'); + }); + + it('renders zero as 0 in every format', () => { + expect(angleToLatex({ kind: 'zero' })).toBe('0'); + expect(angleToUnicode({ kind: 'zero' })).toBe('0'); + }); + + it('rounds non-matching values to two decimals', () => { + expect(angleToLatex(resolveAngle(1.23456))).toBe('1.23'); + expect(angleToUnicode(resolveAngle(1.23456))).toBe('1.23'); + }); +}); diff --git a/frontend/src/lib/quantumAngle.ts b/frontend/src/lib/quantumAngle.ts new file mode 100644 index 00000000..4becb9a5 --- /dev/null +++ b/frontend/src/lib/quantumAngle.ts @@ -0,0 +1,124 @@ +// Denominators of π tried during recognition, in ascending order. Curated for quantum gates: +// powers of two cover phase/QFT angles (π/2, π/4, …, π/128), while 3/6/12 cover the common +// thirds and sixths. Uncommon fractions like π/5 or π/7 are intentionally excluded here — pass +// a custom `denominators` list to `resolveAngle` for a fully general formatter. +const DEFAULT_PI_DENOMINATORS = [1, 2, 3, 4, 6, 8, 12, 16, 32, 64, 128]; +const DEFAULT_TOLERANCE = 1e-9; + +const TWO_PI = 2 * Math.PI; +const FOUR_PI = 4 * Math.PI; + +/** + * Format-independent, symbolic representation of a rotation angle — the shared "model" that + * every output format (LaTeX, Unicode, OpenQASM, …) is rendered from. + * + * - zero: an exact (within tolerance) zero rotation. + * - pi: a rational multiple of π, i.e. `(numerator / denominator) * π`, always stored in the lowest terms with `denominator ≥ 1` and `numerator ≠ 0`. Named constants are just special + * cases: `2π` (τ) is `{ numerator: 2, denominator: 1 }` - weather that is `2π`, `τ`, or `\tau` is a renderer decision. + * - number: any angle that does not match a known π multiple keeps the full-precision value. + */ +export type QuantumAngle = + | { kind: 'zero' } + | { kind: 'pi'; numerator: number; denominator: number } + | { kind: 'number'; radians: number }; + +export interface ResolveAngleOptions { + /** True radian tolerance when matching against a π multiple. Defaults to 1e-9. */ + tolerance?: number; + /** Denominators of π to try, in ascending order. Defaults to a curated common set. */ + denominators?: number[]; + /** + * Canonicalize the angle before recognition. + * - 'none': use θ as-is. + * - '2pi': θ mod 2π. + * - '4pi': θ mod 4π. + */ + normalize?: 'none' | '2pi' | '4pi'; +} + +/** + * Recognizes a rotation angle (in radians) as a symbolic {@link QuantumAngle}. + * + * This is the single, output-format-agnostic resolver shared by all mappers/displays: it only decides *what* the angle is, never *how* it is written. + * Values that are not a common multiple of π are returned as `{ kind: 'number' }`. + */ +export function resolveAngle(radians: number, options: ResolveAngleOptions = {}): QuantumAngle { + const tolerance = options.tolerance ?? DEFAULT_TOLERANCE; + const denominators = options.denominators ?? DEFAULT_PI_DENOMINATORS; + + if (!Number.isFinite(radians)) return { kind: 'number', radians }; + + const normalized = normalizeRadians(radians, options.normalize ?? 'none'); + + if (Math.abs(normalized) < tolerance) return { kind: 'zero' }; + + for (const denominator of denominators) { + const numerator = Math.round((normalized * denominator) / Math.PI); + + if (numerator === 0) continue; + + // Compare in radians so `tolerance` means the same thing regardless of denominator. + const candidate = (numerator * Math.PI) / denominator; + if (Math.abs(normalized - candidate) < tolerance) { + const divisor = gcd(Math.abs(numerator), denominator); + return { kind: 'pi', numerator: numerator / divisor, denominator: denominator / divisor }; + } + } + + return { kind: 'number', radians: normalized }; +} + +/** Folds an angle onto a canonical window (see {@link ResolveAngleOptions.normalize}). */ +function normalizeRadians(radians: number, mode: NonNullable): number { + if (mode === 'none') return radians; + + const period = mode === '4pi' ? FOUR_PI : TWO_PI; + const half = period / 2; + + let wrapped = radians % period; // (-period, period) + if (wrapped > half) wrapped -= period; + else if (wrapped <= -half) wrapped += period; + + return wrapped; +} + +/** Renders a {@link QuantumAngle} as a LaTeX math string (e.g. `\frac{\pi}{2}`, `2\pi`). */ +export function angleToLatex(angle: QuantumAngle): string { + if (angle.kind === 'zero') return '0'; + if (angle.kind === 'number') return formatDecimal(angle.radians); + + const sign = angle.numerator < 0 ? '-' : ''; + const absNumerator = Math.abs(angle.numerator); + const piTerm = absNumerator === 1 ? String.raw`\pi` : String.raw`${absNumerator}\pi`; + + return angle.denominator === 1 ? `${sign}${piTerm}` : String.raw`${sign}\frac{${piTerm}}{${angle.denominator}}`; +} + +/** Renders a {@link QuantumAngle} as a Unicode string for plain UI display (e.g. `π/2`, `2π`). */ +export function angleToUnicode(angle: QuantumAngle): string { + if (angle.kind === 'zero') return '0'; + if (angle.kind === 'number') return formatDecimal(angle.radians); + + const sign = angle.numerator < 0 ? '-' : ''; + const absNumerator = Math.abs(angle.numerator); + const piTerm = absNumerator === 1 ? 'π' : `${absNumerator}π`; + + return angle.denominator === 1 ? `${sign}${piTerm}` : `${sign}${piTerm}/${angle.denominator}`; +} + +/** Rounds a plain angle to two decimals for display; `?` for non-finite input. */ +function formatDecimal(radians: number): string { + if (!Number.isFinite(radians)) return '?'; + return Number(radians.toFixed(2)).toString(); +} + +function gcd(a: number, b: number): number { + let x = a; + let y = b; + + while (y !== 0) { + [x, y] = [y, x % y]; + } + + return x || 1; +} diff --git a/frontend/src/views/circuit-view/util/quantikzMapper.ts b/frontend/src/views/circuit-view/util/quantikzMapper.ts index f744c909..509dbfb4 100644 --- a/frontend/src/views/circuit-view/util/quantikzMapper.ts +++ b/frontend/src/views/circuit-view/util/quantikzMapper.ts @@ -8,10 +8,9 @@ import { RegisterResponse, } from '@/api/dto/circuit.ts'; import { buildWireIndex, WireIndex } from '@/lib/circuitIndex.ts'; +import { angleToLatex, resolveAngle } from '@/lib/quantumAngle.ts'; const ROTATION_GATES = new Set(['RX', 'RY', 'RZ']); -const PI_FRACTION_DENOMINATORS = [1, 2, 3, 4, 6, 8, 12, 16]; -const ANGLE_TOLERANCE = 1e-8; const TRAILING_COLUMNS = 1; // Keep trailing wire column so the rendered circuit does not end directly at the last gate. /** @@ -224,7 +223,7 @@ function gateLabel(gate: ElementaryQuantumGateDto): string { if (ROTATION_GATES.has(identifier)) { const axis = identifier[1]; // \ensuremath works with both quantikz versions where gate labels may be handled in text or math mode. - return String.raw`\ensuremath{R_${axis}(${formatAngle(gate.rotationAngle)})}`; + return String.raw`\ensuremath{R_${axis}(${angleToLatex(resolveAngle(gate.rotationAngle))})}`; } if (identifier === 'CZ') { @@ -238,60 +237,6 @@ function isControlledXGate(identifier: string, gate: ElementaryQuantumGateDto): return ['X', 'CX', 'CCX'].includes(identifier) && Boolean(gate.controlQubits?.length); } -// Prefer symbolic π fractions for common rotation angles; fall back to a compact decimal. -function formatAngle(angle: number | null | undefined): string { - if (angle === null || angle === undefined) { - return '?'; - } - - if (Math.abs(angle) < ANGLE_TOLERANCE) { - return '0'; - } - - const piRatio = angle / Math.PI; - - for (const denominator of PI_FRACTION_DENOMINATORS) { - const numerator = Math.round(piRatio * denominator); - - if (numerator === 0) continue; - - if (Math.abs(piRatio - numerator / denominator) < ANGLE_TOLERANCE) { - return formatPiFraction(numerator, denominator); - } - } - - return Number(angle.toFixed(2)).toString(); -} - -function formatPiFraction(numerator: number, denominator: number): string { - const divisor = gcd(Math.abs(numerator), denominator); - const reducedNumerator = numerator / divisor; - const reducedDenominator = denominator / divisor; - const sign = reducedNumerator < 0 ? '-' : ''; - const absNumerator = Math.abs(reducedNumerator); - - if (reducedDenominator === 1) { - return absNumerator === 1 ? String.raw`${sign}\pi` : String.raw`${sign}${absNumerator}\pi`; - } - - if (absNumerator === 1) { - return String.raw`${sign}\frac{\pi}{${reducedDenominator}}`; - } - - return String.raw`${sign}\frac{${absNumerator}\pi}{${reducedDenominator}}`; -} - -function gcd(a: number, b: number): number { - let x = a; - let y = b; - - while (y !== 0) { - [x, y] = [y, x % y]; - } - - return x || 1; -} - function escapeLatexText(value: string): string { return value .replaceAll('\\', String.raw`\textbackslash{}`) From 80bfb4267762f2f3795466029d7ea903253f9433 Mon Sep 17 00:00:00 2001 From: Nick Oelmann Date: Wed, 1 Jul 2026 13:31:24 +0200 Subject: [PATCH 02/11] #161: initial mapper to labeled dirac notation --- .../circuit-view/util/diracMapper.test.ts | 145 ++++++++++++++++++ .../views/circuit-view/util/diracMapper.ts | 127 +++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 frontend/src/views/circuit-view/util/diracMapper.test.ts create mode 100644 frontend/src/views/circuit-view/util/diracMapper.ts diff --git a/frontend/src/views/circuit-view/util/diracMapper.test.ts b/frontend/src/views/circuit-view/util/diracMapper.test.ts new file mode 100644 index 00000000..febd88bc --- /dev/null +++ b/frontend/src/views/circuit-view/util/diracMapper.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { toLabeledDirac } from './diracMapper'; +import { + CircuitResponse, + ElementaryQuantumGateDto, + ElementSelectorDto, + LayerResponse, + QuantumOperationDto, + QuantumRegisterResponse, +} from '@/api/dto/circuit'; + +const quantumRegister = (id: string, numberOfQubits: number, name = id): QuantumRegisterResponse => ({ + id, + name, + type: 'Quantum_Register', + numberOfQubits, +}); + +const sel = (registerId: string, index: number): ElementSelectorDto => ({ registerId, index }); + +const gate = ( + identifier: string, + targetQubits: ElementSelectorDto[], + controlQubits: ElementSelectorDto[] = [], + overrides: Partial = {}, +): ElementaryQuantumGateDto => ({ + type: 'ELEMENTARY_QUANTUM_GATE', + identifier: identifier as ElementaryQuantumGateDto['identifier'], + inverseForm: false, + targetQubits, + controlQubits, + rotationAngle: 0, + ...overrides, +}); + +const layer = (...ops: QuantumOperationDto[]): LayerResponse => ({ quantumOperations: ops }); + +const circuit = (registers: QuantumRegisterResponse[], layers: LayerResponse[]): CircuitResponse => ({ + id: 'test-circuit', + registers, + layers, +}); + +describe('toLabeledDirac', () => { + it('renders a single-qubit gate composed with a labelled ket', () => { + // H(q0) + const result = toLabeledDirac(circuit([quantumRegister('q', 1)], [layer(gate('H', [sel('q', 0)]))])); + + expect(result).toBe(String.raw`\mathrm{H}_{q_{0}} \cdot \lvert 0\rangle_{q_{0}}`); + }); + + it('renders the H; CNOT example (last-applied gate leftmost, \\cdot composition)', () => { + // H(q0); CNOT(q0, q1) + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 2)], + [layer(gate('H', [sel('q', 0)])), layer(gate('CX', [sel('q', 1)], [sel('q', 0)]))], + ), + ); + + expect(result).toBe( + String.raw`\mathrm{CNOT}_{q_{0} q_{1}} \cdot \mathrm{H}_{q_{0}} \cdot \lvert 00\rangle_{q_{0} q_{1}}`, + ); + }); + + it('preserves gate-local operand order (control=q1, target=q0 stays q_1 q_0)', () => { + // CNOT(control=q1, target=q0) — must NOT be reordered to q_0 q_1. + const cnot = gate('CX', [sel('q', 0)], [sel('q', 1)]); + + const result = toLabeledDirac(circuit([quantumRegister('q', 2)], [layer(cnot)])); + + expect(result).toBe(String.raw`\mathrm{CNOT}_{q_{1} q_{0}} \cdot \lvert 00\rangle_{q_{0} q_{1}}`); + }); + + it('renders the CCNOT example with space-separated operand labels', () => { + // CCNOT with controls q0, q2 and target q1 — the Toffoli from the motivating example. + const ccnot = gate('CCX', [sel('q', 1)], [sel('q', 0), sel('q', 2)]); + + const result = toLabeledDirac(circuit([quantumRegister('q', 3)], [layer(ccnot)])); + + expect(result).toBe(String.raw`\mathrm{CCNOT}_{q_{0} q_{2} q_{1}} \cdot \lvert 000\rangle_{q_{0} q_{1} q_{2}}`); + }); + + it('renders a rotation gate with an upright braced axis and \\!\\left parentheses', () => { + // RZ(pi/2) on q1 of a two-qubit register. + const result = toLabeledDirac( + circuit([quantumRegister('q', 2)], [layer(gate('RZ', [sel('q', 1)], [], { rotationAngle: Math.PI / 2 }))]), + ); + + expect(result).toBe( + String.raw`\mathrm{R}_{z}\!\left(\frac{\pi}{2}\right)_{q_{1}} \cdot \lvert 00\rangle_{q_{0} q_{1}}`, + ); + }); + + it('marks an inverse gate with a dagger before the qubit subscript', () => { + // T†(q0) + const result = toLabeledDirac( + circuit([quantumRegister('q', 1)], [layer(gate('T', [sel('q', 0)], [], { inverseForm: true }))]), + ); + + expect(result).toBe(String.raw`\mathrm{T}^{\dagger}_{q_{0}} \cdot \lvert 0\rangle_{q_{0}}`); + }); + + it('places the dagger before the angle on an inverse rotation', () => { + // RZ(pi/2)†(q0) + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 1)], + [layer(gate('RZ', [sel('q', 0)], [], { rotationAngle: Math.PI / 2, inverseForm: true }))], + ), + ); + + expect(result).toBe( + String.raw`\mathrm{R}_{z}^{\dagger}\!\left(\frac{\pi}{2}\right)_{q_{0}} \cdot \lvert 0\rangle_{q_{0}}`, + ); + }); + + it('sets multi-character register names upright, in both operator and ket labels', () => { + const result = toLabeledDirac( + circuit([quantumRegister('ancilla', 1)], [layer(gate('H', [sel('ancilla', 0)]))]), + ); + + expect(result).toBe(String.raw`\mathrm{H}_{\text{ancilla}_{0}} \cdot \lvert 0\rangle_{\text{ancilla}_{0}}`); + }); + + it('skips non-unitary operations such as measurements', () => { + const measurement: QuantumOperationDto = { + type: 'MEASUREMENT', + identifier: 'MEASURE', + inverseForm: false, + targetQubits: [sel('q', 0)], + controlQubits: [], + classicBits: [], + }; + + const result = toLabeledDirac(circuit([quantumRegister('q', 1)], [layer(measurement)])); + + // Only the labelled initial state remains; no operator was rendered. + expect(result).toBe(String.raw`\lvert 0\rangle_{q_{0}}`); + }); + + it('returns an empty string when there are no qubits', () => { + expect(toLabeledDirac(circuit([], []))).toBe(''); + }); +}); diff --git a/frontend/src/views/circuit-view/util/diracMapper.ts b/frontend/src/views/circuit-view/util/diracMapper.ts new file mode 100644 index 00000000..4974167d --- /dev/null +++ b/frontend/src/views/circuit-view/util/diracMapper.ts @@ -0,0 +1,127 @@ +import { + CircuitResponse, + ElementaryQuantumGateDto, + ElementSelectorDto, + isQuantumRegister, + RegisterResponse, +} from '@/api/dto/circuit.ts'; +import { angleToLatex, resolveAngle } from '@/lib/quantumAngle.ts'; + +const COMPOSITION = String.raw` \cdot `; + +interface GateSymbol { + base: string; + suffix: string; +} + +/** + * Export a circuit as labelled Dirac notation. + */ +export function toLabeledDirac(circuit: CircuitResponse): string { + const resolveLabel = buildLabelResolver(circuit.registers); + const tokens: string[] = []; + + // Reverse application order: the last gate is rendered first. + for (let layerIdx = circuit.layers.length - 1; layerIdx >= 0; layerIdx--) { + const operations = circuit.layers[layerIdx].quantumOperations; + + for (let opIdx = operations.length - 1; opIdx >= 0; opIdx--) { + const operation = operations[opIdx]; + + if (operation.type !== 'ELEMENTARY_QUANTUM_GATE') continue; + tokens.push(renderOperator(operation, resolveLabel)); + } + } + + const initialState = renderInitialState(circuit.registers, resolveLabel); + if (initialState) tokens.push(initialState); + + return tokens.join(COMPOSITION); +} + +/** + * Resolve a qubit selector to its labelled Dirac form. + */ +function buildLabelResolver(registers: RegisterResponse[]): (selector: ElementSelectorDto) => string { + const nameById = new Map(registers.map((register) => [register.id, register.name])); + + return (selector) => { + const name = nameById.get(selector.registerId) ?? selector.registerId; + const base = name.length === 1 ? name : String.raw`\text{${escapeLatexText(name)}}`; + + return `${base}_{${selector.index}}`; + }; +} + +/** + * Render |00...0⟩ with explicit qubit labels. + */ +function renderInitialState( + registers: RegisterResponse[], + resolveLabel: (selector: ElementSelectorDto) => string, +): string { + const labels: string[] = []; + + for (const register of registers) { + if (!isQuantumRegister(register)) continue; + + for (let index = 0; index < register.numberOfQubits; index++) { + labels.push(resolveLabel({ registerId: register.id, index })); + } + } + + if (labels.length === 0) return ''; + + return String.raw`\lvert ${'0'.repeat(labels.length)}\rangle_{${labels.join(' ')}}`; +} + +function renderOperator( + gate: ElementaryQuantumGateDto, + resolveLabel: (selector: ElementSelectorDto) => string, +): string { + const { base, suffix } = gateSymbol(gate); + const dagger = gate.inverseForm ? String.raw`^{\dagger}` : ''; + + // Keep operand order: controls first, then targets. + const labels = [...gate.controlQubits, ...gate.targetQubits].map(resolveLabel).join(' '); + + return `${base}${dagger}${suffix}_{${labels}}`; +} + +/** + * Map a gate to its Dirac symbol. + */ +function gateSymbol(gate: ElementaryQuantumGateDto): GateSymbol { + const identifier = gate.identifier.toUpperCase(); + const controlCount = gate.controlQubits.length; + + if (identifier === 'X' || identifier === 'CX' || identifier === 'CCX') { + if (controlCount === 1) return { base: String.raw`\mathrm{CNOT}`, suffix: '' }; + if (controlCount === 2) return { base: String.raw`\mathrm{CCNOT}`, suffix: '' }; + + return { base: String.raw`\mathrm{X}`, suffix: '' }; + } + + if (identifier === 'CZ') return { base: String.raw`\mathrm{CZ}`, suffix: '' }; + if (identifier === 'SWAP') return { base: String.raw`\mathrm{SWAP}`, suffix: '' }; + + if (identifier === 'RX' || identifier === 'RY' || identifier === 'RZ') { + if (gate.rotationAngle === undefined || gate.rotationAngle === null) { + return { base: String.raw`\mathrm{${identifier}}`, suffix: '' }; + } + + const axis = identifier[1].toLowerCase(); + const angle = angleToLatex(resolveAngle(gate.rotationAngle)); + + return { + base: String.raw`\mathrm{R}_{${axis}}`, + suffix: String.raw`\!\left(${angle}\right)`, + }; + } + + return { base: String.raw`\mathrm{${identifier}}`, suffix: '' }; +} + +function escapeLatexText(value: string): string { + return value.replaceAll(/[\\{}]/g, String.raw`\$&`); +} From a1d230970e1bbe178caac35a1afa7b1017d38a20 Mon Sep 17 00:00:00 2001 From: Nick Oelmann Date: Wed, 1 Jul 2026 17:39:46 +0200 Subject: [PATCH 03/11] #161: add unlabeled mapper and exrtact shared functionality --- .../dirac/labeledMapper.test.ts} | 31 +++- frontend/src/notation/dirac/labeledMapper.ts | 86 +++++++++++ frontend/src/notation/dirac/layout.test.ts | 30 ++++ frontend/src/notation/dirac/layout.ts | 60 ++++++++ frontend/src/notation/dirac/symbols.ts | 48 ++++++ .../notation/dirac/unlabeledMapper.test.ts | 143 ++++++++++++++++++ .../src/notation/dirac/unlabeledMapper.ts | 122 +++++++++++++++ .../quantikz}/quantikzMapper.ts | 0 .../views/circuit-view/util/diracMapper.ts | 127 ---------------- 9 files changed, 518 insertions(+), 129 deletions(-) rename frontend/src/{views/circuit-view/util/diracMapper.test.ts => notation/dirac/labeledMapper.test.ts} (82%) create mode 100644 frontend/src/notation/dirac/labeledMapper.ts create mode 100644 frontend/src/notation/dirac/layout.test.ts create mode 100644 frontend/src/notation/dirac/layout.ts create mode 100644 frontend/src/notation/dirac/symbols.ts create mode 100644 frontend/src/notation/dirac/unlabeledMapper.test.ts create mode 100644 frontend/src/notation/dirac/unlabeledMapper.ts rename frontend/src/{views/circuit-view/util => notation/quantikz}/quantikzMapper.ts (100%) delete mode 100644 frontend/src/views/circuit-view/util/diracMapper.ts diff --git a/frontend/src/views/circuit-view/util/diracMapper.test.ts b/frontend/src/notation/dirac/labeledMapper.test.ts similarity index 82% rename from frontend/src/views/circuit-view/util/diracMapper.test.ts rename to frontend/src/notation/dirac/labeledMapper.test.ts index febd88bc..c3b23f9f 100644 --- a/frontend/src/views/circuit-view/util/diracMapper.test.ts +++ b/frontend/src/notation/dirac/labeledMapper.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { toLabeledDirac } from './diracMapper'; +import { toLabeledDirac } from './labeledMapper.ts'; import { CircuitResponse, ElementaryQuantumGateDto, @@ -7,7 +7,7 @@ import { LayerResponse, QuantumOperationDto, QuantumRegisterResponse, -} from '@/api/dto/circuit'; +} from '@/api/dto/circuit.ts'; const quantumRegister = (id: string, numberOfQubits: number, name = id): QuantumRegisterResponse => ({ id, @@ -123,6 +123,12 @@ describe('toLabeledDirac', () => { expect(result).toBe(String.raw`\mathrm{H}_{\text{ancilla}_{0}} \cdot \lvert 0\rangle_{\text{ancilla}_{0}}`); }); + it('escapes LaTeX-special characters in register names', () => { + const result = toLabeledDirac(circuit([quantumRegister('q_a', 1)], [layer(gate('H', [sel('q_a', 0)]))])); + + expect(result).toBe(String.raw`\mathrm{H}_{\text{q\_a}_{0}} \cdot \lvert 0\rangle_{\text{q\_a}_{0}}`); + }); + it('skips non-unitary operations such as measurements', () => { const measurement: QuantumOperationDto = { type: 'MEASUREMENT', @@ -139,6 +145,27 @@ describe('toLabeledDirac', () => { expect(result).toBe(String.raw`\lvert 0\rangle_{q_{0}}`); }); + it('breaks per layer in the layered layout', () => { + // H(q0); CNOT(q0, q1) — two layers, wrapped and broken onto separate lines. + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 2)], + [layer(gate('H', [sel('q', 0)])), layer(gate('CX', [sel('q', 1)], [sel('q', 0)]))], + ), + 'layered', + ); + + expect(result).toBe( + [ + '\\begin{aligned}', + String.raw`& \left(\mathrm{CNOT}_{q_{0} q_{1}}\right) \\`, + String.raw`& \cdot \left(\mathrm{H}_{q_{0}}\right) \\`, + String.raw`& \cdot \lvert 00\rangle_{q_{0} q_{1}}`, + '\\end{aligned}', + ].join('\n'), + ); + }); + it('returns an empty string when there are no qubits', () => { expect(toLabeledDirac(circuit([], []))).toBe(''); }); diff --git a/frontend/src/notation/dirac/labeledMapper.ts b/frontend/src/notation/dirac/labeledMapper.ts new file mode 100644 index 00000000..8eae1727 --- /dev/null +++ b/frontend/src/notation/dirac/labeledMapper.ts @@ -0,0 +1,86 @@ +import { + CircuitResponse, + ElementaryQuantumGateDto, + ElementSelectorDto, + isQuantumRegister, + RegisterResponse, +} from '@/api/dto/circuit.ts'; +import { gateSymbol } from '@/notation/dirac/symbols.ts'; +import { assembleDirac, buildLayerGroups, Layout } from '@/notation/dirac/layout.ts'; + +/** + * Export a circuit as labelled Dirac notation. + */ +export function toLabeledDirac(circuit: CircuitResponse, layout: Layout = 'inline'): string { + const resolveLabel = buildLabelResolver(circuit.registers); + + const ket = renderInitialState(circuit.registers, resolveLabel); + if (!ket) return ''; + + const layerGroups = buildLayerGroups(circuit, (gate) => renderOperator(gate, resolveLabel)); + + return assembleDirac(layerGroups, ket, layout); +} + +/** + * Resolve a qubit selector to its labelled Dirac form. + */ +function buildLabelResolver(registers: RegisterResponse[]): (selector: ElementSelectorDto) => string { + const nameById = new Map(registers.map((register) => [register.id, register.name])); + + return (selector) => { + const name = nameById.get(selector.registerId) ?? selector.registerId; + // Keep a single letter/digit italic; anything else goes through \text{} and must be escaped. + const base = /^[a-zA-Z0-9]$/.test(name) ? name : String.raw`\text{${escapeLatexText(name)}}`; + + return `${base}_{${selector.index}}`; + }; +} + +/** + * Render |00...0⟩ with explicit qubit labels. + */ +function renderInitialState( + registers: RegisterResponse[], + resolveLabel: (selector: ElementSelectorDto) => string, +): string { + const labels: string[] = []; + + for (const register of registers) { + if (!isQuantumRegister(register)) continue; + + for (let index = 0; index < register.numberOfQubits; index++) { + labels.push(resolveLabel({ registerId: register.id, index })); + } + } + + if (labels.length === 0) return ''; + + return String.raw`\lvert ${'0'.repeat(labels.length)}\rangle_{${labels.join(' ')}}`; +} + +function renderOperator( + gate: ElementaryQuantumGateDto, + resolveLabel: (selector: ElementSelectorDto) => string, +): string { + const { base, suffix } = gateSymbol(gate); + const dagger = gate.inverseForm ? String.raw`^{\dagger}` : ''; + + // Keep operand order: controls first, then targets. + const labels = [...gate.controlQubits, ...gate.targetQubits].map(resolveLabel).join(' '); + + return `${base}${dagger}${suffix}_{${labels}}`; +} + +// Escape the characters that are special inside a KaTeX \text{} group. +function escapeLatexText(value: string): string { + return value + .replaceAll('\\', String.raw`\textbackslash{}`) + .replaceAll('{', String.raw`\{`) + .replaceAll('}', String.raw`\}`) + .replaceAll('_', String.raw`\_`) + .replaceAll('#', String.raw`\#`) + .replaceAll('$', String.raw`\$`) + .replaceAll('%', String.raw`\%`) + .replaceAll('&', String.raw`\&`); +} diff --git a/frontend/src/notation/dirac/layout.test.ts b/frontend/src/notation/dirac/layout.test.ts new file mode 100644 index 00000000..e14fee08 --- /dev/null +++ b/frontend/src/notation/dirac/layout.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { assembleDirac } from './layout.ts'; + +describe('assembleDirac', () => { + it('joins everything into a single product for the inline layout', () => { + const result = assembleDirac([['A', 'B'], ['C'], ['D', 'E', 'F']], 'K', 'inline'); + + expect(result).toBe(String.raw`A \cdot B \cdot C \cdot D \cdot E \cdot F \cdot K`); + }); + + it('breaks after each layer for the layered layout', () => { + const result = assembleDirac([['A', 'B'], ['C'], ['D', 'E', 'F']], 'K', 'layered'); + + expect(result).toBe( + [ + '\\begin{aligned}', + String.raw`& \left(A \cdot B\right) \\`, + String.raw`& \cdot \left(C\right) \\`, + String.raw`& \cdot \left(D \cdot E \cdot F\right) \\`, + String.raw`& \cdot K`, + '\\end{aligned}', + ].join('\n'), + ); + }); + + it('renders just the ket when there are no operators', () => { + expect(assembleDirac([], 'K', 'inline')).toBe('K'); + expect(assembleDirac([], 'K', 'layered')).toBe('\\begin{aligned}\n& K\n\\end{aligned}'); + }); +}); diff --git a/frontend/src/notation/dirac/layout.ts b/frontend/src/notation/dirac/layout.ts new file mode 100644 index 00000000..e263058d --- /dev/null +++ b/frontend/src/notation/dirac/layout.ts @@ -0,0 +1,60 @@ +import { CircuitResponse, ElementaryQuantumGateDto } from '@/api/dto/circuit.ts'; + +const COMPOSITION = String.raw` \cdot `; + +export type Layout = 'inline' | 'layered'; + +/** + * Groups the rendered operators per circuit layer, in reverse application order (last-applied + * layer first). `renderOperation` turns one gate into its token; an empty token is dropped, and + * empty layers are omitted. Shared by both Dirac mappers, which only differ in `renderOperation`. + */ +export function buildLayerGroups( + circuit: CircuitResponse, + renderOperation: (gate: ElementaryQuantumGateDto) => string, +): string[][] { + const layerGroups: string[][] = []; + + for (let layerIdx = circuit.layers.length - 1; layerIdx >= 0; layerIdx--) { + const operations = circuit.layers[layerIdx].quantumOperations; + const tokens: string[] = []; + + for (let opIdx = operations.length - 1; opIdx >= 0; opIdx--) { + const operation = operations[opIdx]; + + if (operation.type !== 'ELEMENTARY_QUANTUM_GATE') continue; + + const token = renderOperation(operation); + if (token) tokens.push(token); + } + + if (tokens.length > 0) layerGroups.push(tokens); + } + + return layerGroups; +} + +/** + * Joins the operator tokens (grouped per circuit layer) and the initial state into one Dirac + * expression. `inline` renders a single product; `layered` breaks after each layer, wrapping the + * layer in parentheses and continuing with a leading `\cdot` on the next line. + */ +export function assembleDirac(layerGroups: string[][], ket: string, layout: Layout): string { + const groups = layerGroups.filter((tokens) => tokens.length > 0); + + if (layout === 'inline') { + return [...groups.flat(), ket].join(COMPOSITION); + } + + const lines = [...groups.map(groupLayer), ket]; + const rows = lines.map((line, index) => (index === 0 ? `& ${line}` : String.raw`& \cdot ${line}`)); + + return `\\begin{aligned}\n${rows.join(' \\\\\n')}\n\\end{aligned}`; +} + +// A single, already-parenthesised factor (an unlabelled tensor token) needs no extra grouping. +function groupLayer(tokens: string[]): string { + if (tokens.length === 1 && tokens[0].startsWith(String.raw`\left(`)) return tokens[0]; + + return String.raw`\left(${tokens.join(COMPOSITION)}\right)`; +} diff --git a/frontend/src/notation/dirac/symbols.ts b/frontend/src/notation/dirac/symbols.ts new file mode 100644 index 00000000..b1089e14 --- /dev/null +++ b/frontend/src/notation/dirac/symbols.ts @@ -0,0 +1,48 @@ +import { ElementaryQuantumGateDto } from '@/api/dto/circuit.ts'; +import { angleToLatex, resolveAngle } from '@/lib/quantumAngle.ts'; + +// Split so the dagger can be inserted between the name and a rotation's `\!\left(\theta\right)`. +export interface GateSymbol { + base: string; + suffix: string; +} + +/** + * Upright Dirac operator symbol without qubit labels or dagger, shared by both Dirac mappers. + * X-type gates with controls are named by their controlled form (CNOT / CCNOT). + */ +export function gateSymbol(gate: ElementaryQuantumGateDto): GateSymbol { + const identifier = gate.identifier.toUpperCase(); + const controlCount = gate.controlQubits.length; + + if (identifier === 'X' || identifier === 'CX' || identifier === 'CCX') { + if (controlCount === 1) return { base: String.raw`\mathrm{CNOT}`, suffix: '' }; + if (controlCount === 2) return { base: String.raw`\mathrm{CCNOT}`, suffix: '' }; + + return { base: String.raw`\mathrm{X}`, suffix: '' }; + } + + if (identifier === 'CZ') return { base: String.raw`\mathrm{CZ}`, suffix: '' }; + if (identifier === 'SWAP') return { base: String.raw`\mathrm{SWAP}`, suffix: '' }; + + if (identifier === 'RX' || identifier === 'RY' || identifier === 'RZ') { + if (gate.rotationAngle === undefined || gate.rotationAngle === null) { + return { base: String.raw`\mathrm{${identifier}}`, suffix: '' }; + } + + const axis = identifier[1].toLowerCase(); + const angle = angleToLatex(resolveAngle(gate.rotationAngle)); + + return { base: String.raw`\mathrm{R}_{${axis}}`, suffix: String.raw`\!\left(${angle}\right)` }; + } + + return { base: String.raw`\mathrm{${identifier}}`, suffix: '' }; +} + +/** Full upright symbol including the dagger (if inverse), but without qubit labels. */ +export function gateSymbolLatex(gate: ElementaryQuantumGateDto): string { + const { base, suffix } = gateSymbol(gate); + const dagger = gate.inverseForm ? String.raw`^{\dagger}` : ''; + + return `${base}${dagger}${suffix}`; +} diff --git a/frontend/src/notation/dirac/unlabeledMapper.test.ts b/frontend/src/notation/dirac/unlabeledMapper.test.ts new file mode 100644 index 00000000..eb526604 --- /dev/null +++ b/frontend/src/notation/dirac/unlabeledMapper.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { toUnlabeledDirac } from './unlabeledMapper.ts'; +import { + CircuitResponse, + ElementaryQuantumGateDto, + ElementSelectorDto, + LayerResponse, + QuantumOperationDto, + QuantumRegisterResponse, +} from '@/api/dto/circuit.ts'; + +const quantumRegister = (id: string, numberOfQubits: number, name = id): QuantumRegisterResponse => ({ + id, + name, + type: 'Quantum_Register', + numberOfQubits, +}); + +const sel = (registerId: string, index: number): ElementSelectorDto => ({ registerId, index }); + +const gate = ( + identifier: string, + targetQubits: ElementSelectorDto[], + controlQubits: ElementSelectorDto[] = [], + overrides: Partial = {}, +): ElementaryQuantumGateDto => ({ + type: 'ELEMENTARY_QUANTUM_GATE', + identifier: identifier as ElementaryQuantumGateDto['identifier'], + inverseForm: false, + targetQubits, + controlQubits, + rotationAngle: 0, + ...overrides, +}); + +const layer = (...ops: QuantumOperationDto[]): LayerResponse => ({ quantumOperations: ops }); + +const circuit = (registers: QuantumRegisterResponse[], layers: LayerResponse[]): CircuitResponse => ({ + id: 'test-circuit', + registers, + layers, +}); + +describe('toUnlabeledDirac', () => { + it('renders a single-qubit gate as a tensor product with identities', () => { + const result = toUnlabeledDirac(circuit([quantumRegister('q', 2)], [layer(gate('H', [sel('q', 0)]))])); + + expect(result).toBe(String.raw`\left(\mathrm{H} \otimes I\right) \cdot \lvert 00\rangle`); + }); + + it('places a single-qubit gate at its wire', () => { + const result = toUnlabeledDirac( + circuit([quantumRegister('q', 2)], [layer(gate('RZ', [sel('q', 1)], [], { rotationAngle: Math.PI / 2 }))]), + ); + + expect(result).toBe( + String.raw`\left(I \otimes \mathrm{R}_{z}\!\left(\frac{\pi}{2}\right)\right) \cdot \lvert 00\rangle`, + ); + }); + + it('renders an adjacent, in-order multi-qubit gate as a single block (I ⊗ CNOT)', () => { + // CNOT(control=q1, target=q2) on 3 qubits — wires [1, 2] are contiguous and ascending. + const cnot = gate('CX', [sel('q', 2)], [sel('q', 1)]); + + const result = toUnlabeledDirac(circuit([quantumRegister('q', 3)], [layer(cnot)])); + + expect(result).toBe(String.raw`\left(I \otimes \mathrm{CNOT}\right) \cdot \lvert 000\rangle`); + }); + + it('conjugates a non-adjacent gate with SWAPs', () => { + // CNOT(control=q0, target=q2) on 3 qubits — wires [0, 2] are not contiguous. + const cnot = gate('CX', [sel('q', 2)], [sel('q', 0)]); + + const result = toUnlabeledDirac(circuit([quantumRegister('q', 3)], [layer(cnot)])); + + expect(result).toBe( + String.raw`\left(I \otimes \mathrm{SWAP}\right) \cdot \left(\mathrm{CNOT} \otimes I\right) \cdot \left(I \otimes \mathrm{SWAP}\right) \cdot \lvert 000\rangle`, + ); + }); + + it('conjugates a control-below-target gate with SWAPs (operand order preserved)', () => { + // CNOT(control=q1, target=q0) — operand order [1, 0] is descending, so it needs a SWAP. + const cnot = gate('CX', [sel('q', 0)], [sel('q', 1)]); + + const result = toUnlabeledDirac(circuit([quantumRegister('q', 2)], [layer(cnot)])); + + expect(result).toBe( + String.raw`\left(\mathrm{SWAP}\right) \cdot \left(\mathrm{CNOT}\right) \cdot \left(\mathrm{SWAP}\right) \cdot \lvert 00\rangle`, + ); + }); + + it('marks an inverse gate with a dagger', () => { + const result = toUnlabeledDirac( + circuit([quantumRegister('q', 1)], [layer(gate('T', [sel('q', 0)], [], { inverseForm: true }))]), + ); + + expect(result).toBe(String.raw`\left(\mathrm{T}^{\dagger}\right) \cdot \lvert 0\rangle`); + }); + + it('composes operators right-to-left across layers', () => { + const result = toUnlabeledDirac( + circuit([quantumRegister('q', 1)], [layer(gate('H', [sel('q', 0)])), layer(gate('X', [sel('q', 0)]))]), + ); + + expect(result).toBe(String.raw`\left(\mathrm{X}\right) \cdot \left(\mathrm{H}\right) \cdot \lvert 0\rangle`); + }); + + it('skips non-unitary operations such as measurements', () => { + const measurement: QuantumOperationDto = { + type: 'MEASUREMENT', + identifier: 'MEASURE', + inverseForm: false, + targetQubits: [sel('q', 0)], + controlQubits: [], + classicBits: [], + }; + + const result = toUnlabeledDirac(circuit([quantumRegister('q', 1)], [layer(measurement)])); + + expect(result).toBe(String.raw`\lvert 0\rangle`); + }); + + it('breaks per layer in the layered layout', () => { + const result = toUnlabeledDirac( + circuit([quantumRegister('q', 1)], [layer(gate('H', [sel('q', 0)])), layer(gate('X', [sel('q', 0)]))]), + 'layered', + ); + + expect(result).toBe( + [ + '\\begin{aligned}', + String.raw`& \left(\mathrm{X}\right) \\`, + String.raw`& \cdot \left(\mathrm{H}\right) \\`, + String.raw`& \cdot \lvert 0\rangle`, + '\\end{aligned}', + ].join('\n'), + ); + }); + + it('returns an empty string when there are no qubits', () => { + expect(toUnlabeledDirac(circuit([], []))).toBe(''); + }); +}); diff --git a/frontend/src/notation/dirac/unlabeledMapper.ts b/frontend/src/notation/dirac/unlabeledMapper.ts new file mode 100644 index 00000000..da9c2e2a --- /dev/null +++ b/frontend/src/notation/dirac/unlabeledMapper.ts @@ -0,0 +1,122 @@ +import { CircuitResponse, ElementaryQuantumGateDto, getCircuitWidth } from '@/api/dto/circuit.ts'; +import { buildWireIndex, WireIndex } from '@/lib/circuitIndex.ts'; +import { gateSymbolLatex } from '@/notation/dirac/symbols.ts'; +import { assembleDirac, buildLayerGroups, Layout } from '@/notation/dirac/layout.ts'; + +const COMPOSITION = String.raw` \cdot `; +const IDENTITY = 'I'; +const SWAP = String.raw`\mathrm{SWAP}`; + +interface Placement { + start: number; + span: number; + symbol: string; +} + +/** + * Export a circuit as unlabelled Dirac notation: operators as tensor products with identities + * (e.g. `I \otimes \mathrm{CNOT}`) applied to `\lvert 0\ldots0\rangle`. + */ +export function toUnlabeledDirac(circuit: CircuitResponse, layout: Layout = 'inline'): string { + const numQubits = getCircuitWidth(circuit); + if (numQubits === 0) return ''; + + const wireIndex = buildWireIndex(circuit.registers, 'quantum'); + const layerGroups = buildLayerGroups(circuit, (gate) => renderGate(gate, numQubits, wireIndex)); + + const ket = String.raw`\lvert ${'0'.repeat(numQubits)}\rangle`; + + return assembleDirac(layerGroups, ket, layout); +} + +function renderGate(gate: ElementaryQuantumGateDto, numQubits: number, wireIndex: WireIndex): string { + const symbol = gateSymbolLatex(gate); + + // Operand wires in the gate's order (controls first, then targets), never sorted. + const wires = [...gate.controlQubits, ...gate.targetQubits] + .map((selector) => wireIndex.getWireIndex(selector)) + .filter((wire): wire is number => wire !== undefined); + + if (wires.length === 0) return ''; + + if (wires.length === 1) { + return tensorFactor(numQubits, [{ start: wires[0], span: 1, symbol }]); + } + + const base = Math.min(...wires); + + // Contiguous and already in ascending operand order → one clean tensor factor. + const isContiguousBlock = wires.every((wire, index) => wire === base + index); + if (isContiguousBlock) { + return tensorFactor(numQubits, [{ start: base, span: wires.length, symbol }]); + } + + return renderWithSwapConjugation(numQubits, wires, base, symbol); +} + +/** + * A gate on non-adjacent/out-of-order wires is written as `S^\dagger (I \otimes G \otimes I) S`, + * where `S` routes the operands into the contiguous block `[base, base + k)` and cancels around it. + */ +function renderWithSwapConjugation(numQubits: number, wires: number[], base: number, symbol: string): string { + const routing = routeOperandsToBlock(wires, numQubits, base); + const swapFactor = (position: number) => tensorFactor(numQubits, [{ start: position, span: 2, symbol: SWAP }]); + + const block = tensorFactor(numQubits, [{ start: base, span: wires.length, symbol }]); + const parts = [...routing.map(swapFactor), block, ...[...routing].reverse().map(swapFactor)]; + + return parts.join(COMPOSITION); +} + +/** Adjacent SWAPs (each given by its lower position) that bring the operands into `[base, base + k)`. */ +function routeOperandsToBlock(wires: number[], numQubits: number, base: number): number[] { + const target = new Array(numQubits); + wires.forEach((wire, index) => { + target[base + index] = wire; + }); + + const operandSet = new Set(wires); + const remaining: number[] = []; + for (let wire = 0; wire < numQubits; wire++) { + if (!operandSet.has(wire)) remaining.push(wire); + } + let remainingIdx = 0; + for (let position = 0; position < numQubits; position++) { + target[position] ??= remaining[remainingIdx++]; + } + + // Insertion sort into `target`; never disturbs already-placed positions. + const arrangement = Array.from({ length: numQubits }, (_, index) => index); + const swaps: number[] = []; + for (let position = 0; position < numQubits; position++) { + let current = arrangement.indexOf(target[position]); + while (current > position) { + swaps.push(current - 1); + [arrangement[current - 1], arrangement[current]] = [arrangement[current], arrangement[current - 1]]; + current--; + } + } + + return swaps; +} + +/** Builds `\left(f_0 \otimes ... \right)`, filling wires not covered by a placement with `I`. */ +function tensorFactor(numQubits: number, placements: Placement[]): string { + const placementByStart = new Map(placements.map((placement) => [placement.start, placement])); + const entries: string[] = []; + + let wire = 0; + while (wire < numQubits) { + const placement = placementByStart.get(wire); + if (placement) { + entries.push(placement.symbol); + wire += placement.span; + } else { + entries.push(IDENTITY); + wire++; + } + } + + const tensorFactor = entries.join(String.raw` \otimes `); + return String.raw`\left(${tensorFactor}\right)`; +} diff --git a/frontend/src/views/circuit-view/util/quantikzMapper.ts b/frontend/src/notation/quantikz/quantikzMapper.ts similarity index 100% rename from frontend/src/views/circuit-view/util/quantikzMapper.ts rename to frontend/src/notation/quantikz/quantikzMapper.ts diff --git a/frontend/src/views/circuit-view/util/diracMapper.ts b/frontend/src/views/circuit-view/util/diracMapper.ts deleted file mode 100644 index 4974167d..00000000 --- a/frontend/src/views/circuit-view/util/diracMapper.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - CircuitResponse, - ElementaryQuantumGateDto, - ElementSelectorDto, - isQuantumRegister, - RegisterResponse, -} from '@/api/dto/circuit.ts'; -import { angleToLatex, resolveAngle } from '@/lib/quantumAngle.ts'; - -const COMPOSITION = String.raw` \cdot `; - -interface GateSymbol { - base: string; - suffix: string; -} - -/** - * Export a circuit as labelled Dirac notation. - */ -export function toLabeledDirac(circuit: CircuitResponse): string { - const resolveLabel = buildLabelResolver(circuit.registers); - const tokens: string[] = []; - - // Reverse application order: the last gate is rendered first. - for (let layerIdx = circuit.layers.length - 1; layerIdx >= 0; layerIdx--) { - const operations = circuit.layers[layerIdx].quantumOperations; - - for (let opIdx = operations.length - 1; opIdx >= 0; opIdx--) { - const operation = operations[opIdx]; - - if (operation.type !== 'ELEMENTARY_QUANTUM_GATE') continue; - tokens.push(renderOperator(operation, resolveLabel)); - } - } - - const initialState = renderInitialState(circuit.registers, resolveLabel); - if (initialState) tokens.push(initialState); - - return tokens.join(COMPOSITION); -} - -/** - * Resolve a qubit selector to its labelled Dirac form. - */ -function buildLabelResolver(registers: RegisterResponse[]): (selector: ElementSelectorDto) => string { - const nameById = new Map(registers.map((register) => [register.id, register.name])); - - return (selector) => { - const name = nameById.get(selector.registerId) ?? selector.registerId; - const base = name.length === 1 ? name : String.raw`\text{${escapeLatexText(name)}}`; - - return `${base}_{${selector.index}}`; - }; -} - -/** - * Render |00...0⟩ with explicit qubit labels. - */ -function renderInitialState( - registers: RegisterResponse[], - resolveLabel: (selector: ElementSelectorDto) => string, -): string { - const labels: string[] = []; - - for (const register of registers) { - if (!isQuantumRegister(register)) continue; - - for (let index = 0; index < register.numberOfQubits; index++) { - labels.push(resolveLabel({ registerId: register.id, index })); - } - } - - if (labels.length === 0) return ''; - - return String.raw`\lvert ${'0'.repeat(labels.length)}\rangle_{${labels.join(' ')}}`; -} - -function renderOperator( - gate: ElementaryQuantumGateDto, - resolveLabel: (selector: ElementSelectorDto) => string, -): string { - const { base, suffix } = gateSymbol(gate); - const dagger = gate.inverseForm ? String.raw`^{\dagger}` : ''; - - // Keep operand order: controls first, then targets. - const labels = [...gate.controlQubits, ...gate.targetQubits].map(resolveLabel).join(' '); - - return `${base}${dagger}${suffix}_{${labels}}`; -} - -/** - * Map a gate to its Dirac symbol. - */ -function gateSymbol(gate: ElementaryQuantumGateDto): GateSymbol { - const identifier = gate.identifier.toUpperCase(); - const controlCount = gate.controlQubits.length; - - if (identifier === 'X' || identifier === 'CX' || identifier === 'CCX') { - if (controlCount === 1) return { base: String.raw`\mathrm{CNOT}`, suffix: '' }; - if (controlCount === 2) return { base: String.raw`\mathrm{CCNOT}`, suffix: '' }; - - return { base: String.raw`\mathrm{X}`, suffix: '' }; - } - - if (identifier === 'CZ') return { base: String.raw`\mathrm{CZ}`, suffix: '' }; - if (identifier === 'SWAP') return { base: String.raw`\mathrm{SWAP}`, suffix: '' }; - - if (identifier === 'RX' || identifier === 'RY' || identifier === 'RZ') { - if (gate.rotationAngle === undefined || gate.rotationAngle === null) { - return { base: String.raw`\mathrm{${identifier}}`, suffix: '' }; - } - - const axis = identifier[1].toLowerCase(); - const angle = angleToLatex(resolveAngle(gate.rotationAngle)); - - return { - base: String.raw`\mathrm{R}_{${axis}}`, - suffix: String.raw`\!\left(${angle}\right)`, - }; - } - - return { base: String.raw`\mathrm{${identifier}}`, suffix: '' }; -} - -function escapeLatexText(value: string): string { - return value.replaceAll(/[\\{}]/g, String.raw`\$&`); -} From 6c9e0e001f24e95dc724478e1d06a6d0c29c9014 Mon Sep 17 00:00:00 2001 From: Nick Oelmann Date: Wed, 1 Jul 2026 17:40:58 +0200 Subject: [PATCH 04/11] #161: formal editor component --- .../components/formal-editor/FormalEditor.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx diff --git a/frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx b/frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx new file mode 100644 index 00000000..1e53525f --- /dev/null +++ b/frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react'; +import { BlockMath } from 'react-katex'; +import 'katex/dist/katex.min.css'; +import { Check, Copy, Lock } from 'lucide-react'; +import { toast } from 'sonner'; +import { Badge } from '@/components/ui/badge.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card.tsx'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle.tsx'; +import { CircuitResponse } from '@/api/dto/circuit.ts'; +import { toLabeledDirac } from '@/notation/dirac/labeledMapper.ts'; +import { toUnlabeledDirac } from '@/notation/dirac/unlabeledMapper.ts'; +import { Layout } from '@/notation/dirac/layout.ts'; + +interface FormalEditorViewProps { + circuit: CircuitResponse | undefined; +} + +const toDisplayMath = (latex: string): string => `\\[\n${latex}\n\\]`; + +export function FormalEditor({ circuit }: Readonly) { + const [layout, setLayout] = useState('inline'); + + const labeled = useMemo(() => (circuit ? toLabeledDirac(circuit, layout) : ''), [circuit, layout]); + const unlabeled = useMemo(() => (circuit ? toUnlabeledDirac(circuit, layout) : ''), [circuit, layout]); + + if (!labeled) { + return ( + + + No circuit to display + + + ); + } + + return ( + + +
+ + + Read-only + + + { + if (value) setLayout(value as Layout); + }} + aria-label="Notation layout" + > + Inline + Layered + +
+ + + +
+
+ ); +} + +function NotationBlock({ title, latex }: Readonly<{ title: string; latex: string }>) { + const [copied, setCopied] = useState(false); + + const copyLatex = async () => { + try { + await navigator.clipboard.writeText(toDisplayMath(latex)); + setCopied(true); + globalThis.setTimeout(() => setCopied(false), 2000); + toast.success('LaTeX copied to clipboard'); + } catch (error) { + console.error('Failed to copy LaTeX:', error); + toast.error('Could not copy LaTeX'); + } + }; + + return ( +
+ {title} + + + + + +
+ +
+
+
+
+ ); +} From 5461ad474ae34ab15f997e5efc7ce40a23366409 Mon Sep 17 00:00:00 2001 From: Nick Oelmann Date: Wed, 1 Jul 2026 17:42:47 +0200 Subject: [PATCH 05/11] #161: Tab integration and opening of formal editor component - needs adjustments when multiple circuits and circuit <-> qasm connection is merged --- .../src/hooks/editor/useEditorShortcuts.ts | 7 ++--- frontend/src/hooks/useQuantikzExport.ts | 2 +- frontend/src/store/tabs/tabsSlice.ts | 6 +++-- frontend/src/store/tabs/tabsTypes.ts | 1 + .../src/views/project-manager-view/File.tsx | 17 ++++++++++++ .../views/text-editor-view/TextEditorView.tsx | 3 ++- .../components/formal-editor/formalTab.ts | 26 +++++++++++++++++++ .../components/layout/EditorSlot.tsx | 15 ++++++++++- .../tabs/EditorTabContextMenuContent.tsx | 20 +++++++++----- 9 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts diff --git a/frontend/src/hooks/editor/useEditorShortcuts.ts b/frontend/src/hooks/editor/useEditorShortcuts.ts index b0adf2f7..42b93282 100644 --- a/frontend/src/hooks/editor/useEditorShortcuts.ts +++ b/frontend/src/hooks/editor/useEditorShortcuts.ts @@ -3,14 +3,15 @@ import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; import { requestSave } from '@/store/tabs/tabsSlice.ts'; import { safeCloseTab } from '@/store/tabs/tabsThunks.ts'; -export function useEditorShortcuts(activeFileId: string | null, activeGroupId: string) { +export function useEditorShortcuts(activeFileId: string | null, activeGroupId: string, isReadOnly = false) { const dispatch = useAppDispatch(); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); - if (activeFileId) { + // Read-only tabs (e.g. the formal editor) have nothing to save. + if (activeFileId && !isReadOnly) { dispatch(requestSave(activeFileId)); } } @@ -27,5 +28,5 @@ export function useEditorShortcuts(activeFileId: string | null, activeGroupId: s globalThis.addEventListener('keydown', handleKeyDown); return () => globalThis.removeEventListener('keydown', handleKeyDown); - }, [activeFileId, activeGroupId, dispatch]); + }, [activeFileId, activeGroupId, isReadOnly, dispatch]); } diff --git a/frontend/src/hooks/useQuantikzExport.ts b/frontend/src/hooks/useQuantikzExport.ts index 66eebfdd..ac727f8a 100644 --- a/frontend/src/hooks/useQuantikzExport.ts +++ b/frontend/src/hooks/useQuantikzExport.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { CircuitResponse } from '@/api/dto/circuit.ts'; -import { toQuantikz, toStandaloneQuantikzDocument } from '@/views/circuit-view/util/quantikzMapper.ts'; +import { toQuantikz, toStandaloneQuantikzDocument } from '@/notation/quantikz/quantikzMapper.ts'; export type ExportStatus = 'idle' | 'copied' | 'error'; diff --git a/frontend/src/store/tabs/tabsSlice.ts b/frontend/src/store/tabs/tabsSlice.ts index d3aa41fa..6724724e 100644 --- a/frontend/src/store/tabs/tabsSlice.ts +++ b/frontend/src/store/tabs/tabsSlice.ts @@ -221,9 +221,11 @@ export const tabsSlice = createSlice({ const group = state.groups.find((g) => g.id === targetGroupId); if (!group) return; + const tab = action.payload.tab; const newTab: Tab = { - ...action.payload.tab, - language: getLanguageByExtension(action.payload.tab.title), + ...tab, + // Formal tabs aren't backed by the editor, so they have no language to derive. + language: tab.kind === 'formal' ? tab.language : getLanguageByExtension(tab.title), }; const exists = group.openTabs.find((t) => t.id === action.payload.tab.id); diff --git a/frontend/src/store/tabs/tabsTypes.ts b/frontend/src/store/tabs/tabsTypes.ts index 3a284709..32a7ce40 100644 --- a/frontend/src/store/tabs/tabsTypes.ts +++ b/frontend/src/store/tabs/tabsTypes.ts @@ -2,6 +2,7 @@ export interface Tab { id: string; // Unique file id title: string; // Filename language: string; // language setting + kind?: 'code' | 'formal'; // Tab content type; absent means a regular code tab. } export interface EditorGroup { diff --git a/frontend/src/views/project-manager-view/File.tsx b/frontend/src/views/project-manager-view/File.tsx index 8e5d0a68..bb4fd338 100644 --- a/frontend/src/views/project-manager-view/File.tsx +++ b/frontend/src/views/project-manager-view/File.tsx @@ -16,6 +16,9 @@ import { FileDetailsResponse, RenameFileRequest } from '@/api/dto/filesystem.ts' import { EntityForm } from '@/views/project-manager-view/util/FormUtils.tsx'; import { getFileIcon } from '@/views/project-manager-view/util/FileIcons.tsx'; import { toast } from 'sonner'; +import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; +import { openTab } from '@/store/tabs/tabsSlice.ts'; +import { canOpenInFormalEditor, createFormalTab } from '@/views/text-editor-view/components/formal-editor/formalTab.ts'; /** * Displays a {@link IFile File} @@ -48,6 +51,7 @@ export function File(file: Readonly) { + {canOpenInFormalEditor(name) && } {FileRename(id, dialogTrigger)} @@ -59,6 +63,19 @@ export function File(file: Readonly) { ); } +/** + * Opens the file in the formal (Dirac notation) editor as a dedicated tab. + * + * TEMPORARY: shows the single project circuit rather than the file's own circuit — see + * {@link canOpenInFormalEditor}. + */ +function OpenWithFormalEditor({ id, name }: Readonly<{ id: string; name: string }>) { + const dispatch = useAppDispatch(); + const openFormalTab = () => dispatch(openTab({ tab: createFormalTab(id, name) })); + + return Open with Formal Editor; +} + function FileRename(id: string, trigger: (element: Promise) => void) { const getFile = () => { return api.get('/api/file/' + id); diff --git a/frontend/src/views/text-editor-view/TextEditorView.tsx b/frontend/src/views/text-editor-view/TextEditorView.tsx index 9b602dcd..9a3678c3 100644 --- a/frontend/src/views/text-editor-view/TextEditorView.tsx +++ b/frontend/src/views/text-editor-view/TextEditorView.tsx @@ -15,7 +15,8 @@ export function TextEditorView() { const { groups, activeGroupId, isDragging } = useAppSelector((state) => state.tabs); const activeGroup = groups.find((g) => g.id === activeGroupId); const activeTabId = activeGroup?.activeTabId || null; - useEditorShortcuts(activeTabId, activeGroupId); + const activeTab = activeGroup?.openTabs.find((t) => t.id === activeTabId) ?? null; + useEditorShortcuts(activeTabId, activeGroupId, activeTab?.kind === 'formal'); useMonacoGarbageCollector(); useEditorCommands(); useLSPSetup(); diff --git a/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts b/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts new file mode 100644 index 00000000..bdceddfc --- /dev/null +++ b/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts @@ -0,0 +1,26 @@ +import { Tab } from '@/store/tabs/tabsTypes.ts'; + +// Formal-editor tabs live in their own id namespace, so a file can be open as both a normal code +// tab and a formal (Dirac) tab at the same time. +const FORMAL_TAB_ID_PREFIX = 'formal:'; + +/** + * TEMPORARY: the formal editor is offered for OpenQASM files only. + * + * Until the circuit ↔ file binding (feature #146) lands there is a single project circuit, so + * every formal tab shows that same circuit regardless of which .qasm file it was opened from. + * Once #146 is merged this should resolve the circuit belonging to the file instead. + */ +export function canOpenInFormalEditor(fileName: string): boolean { + return fileName.toLowerCase().endsWith('.qasm'); +} + +/** Builds the formal (Dirac notation) tab for a given file. */ +export function createFormalTab(fileId: string, fileName: string): Tab { + return { + id: `${FORMAL_TAB_ID_PREFIX}${fileId}`, + title: `${fileName} (Dirac)`, + language: '', + kind: 'formal', + }; +} diff --git a/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx b/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx index d3aecb61..164fae7c 100644 --- a/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx +++ b/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx @@ -1,17 +1,30 @@ import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; +import { useAppSelector } from '@/hooks/useAppSelector.ts'; import { setActiveGroup } from '@/store/tabs/tabsSlice.ts'; import { EditorTabBar } from '@/views/text-editor-view/components/tabs/EditorTabBar.tsx'; import { CardContent } from '@/components/ui/card.tsx'; import QLPEditor from '@/views/text-editor-view/components/core/QLPEditor.tsx'; +import { FormalEditor } from '@/views/text-editor-view/components/formal-editor/FormalEditor.tsx'; +import { useProject } from '@/contexts/ProjectContext.tsx'; export function EditorSlot({ groupId }: Readonly<{ groupId: string }>) { const dispatch = useAppDispatch(); + // A formal tab renders the Dirac notation instead of the Monaco editor. Non-formal (code) keep the existing editor. + const isFormalActive = useAppSelector((state) => { + const group = state.tabs.groups.find((g) => g.id === groupId); + const activeTab = group?.openTabs.find((t) => t.id === group.activeTabId); + return activeTab?.kind === 'formal'; + }); + + // TODO: resolve the circuit the tab was opened from. Atm formal tab only shows the single project circuit (wait for #146) + const { circuit } = useProject(); + return (
dispatch(setActiveGroup(groupId))}> - + {isFormalActive ? : }
); diff --git a/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx b/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx index f80d1f5d..7107960f 100644 --- a/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx +++ b/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx @@ -39,6 +39,7 @@ export function EditorTabContextMenuContent({ }: Readonly) { const metaKey = getKeyLabel(); const optionKey = getOptionKeyLabel(); + const isReadOnly = tab.kind === 'formal'; return ( @@ -66,8 +67,9 @@ export function EditorTabContextMenuContent({ ), )} - {isActive && } - {isActive && ( + {/* Formal (read-only) tabs have no language and cannot be saved. */} + {isActive && !isReadOnly && } + {isActive && !isReadOnly && ( Language @@ -84,11 +86,15 @@ export function EditorTabContextMenuContent({ )} - - - Save - {metaKey} + S - + {!isReadOnly && ( + <> + + + Save + {metaKey} + S + + + )} ); } From 215353032b2a70af16031a0db7c884f6c9e48731 Mon Sep 17 00:00:00 2001 From: Nick Oelmann Date: Wed, 8 Jul 2026 10:34:41 +0200 Subject: [PATCH 06/11] #161: Order gates within one layer by qubit label & remove unlabeled Dirac Notation --- .../src/notation/dirac/labeledMapper.test.ts | 14 ++ frontend/src/notation/dirac/labeledMapper.ts | 15 +- frontend/src/notation/dirac/layout.ts | 18 +-- .../notation/dirac/unlabeledMapper.test.ts | 143 ------------------ .../src/notation/dirac/unlabeledMapper.ts | 122 --------------- .../src/views/project-manager-view/File.tsx | 2 +- .../components/formal-editor/FormalEditor.tsx | 3 - 7 files changed, 37 insertions(+), 280 deletions(-) delete mode 100644 frontend/src/notation/dirac/unlabeledMapper.test.ts delete mode 100644 frontend/src/notation/dirac/unlabeledMapper.ts diff --git a/frontend/src/notation/dirac/labeledMapper.test.ts b/frontend/src/notation/dirac/labeledMapper.test.ts index c3b23f9f..805d4f0b 100644 --- a/frontend/src/notation/dirac/labeledMapper.test.ts +++ b/frontend/src/notation/dirac/labeledMapper.test.ts @@ -166,6 +166,20 @@ describe('toLabeledDirac', () => { ); }); + it('orders gates within a layer by ascending qubit index', () => { + // One layer with gates stored as q2, q0, q1 — rendered ascending as q0, q1, q2. + const result = toLabeledDirac( + circuit( + [quantumRegister('q', 3)], + [layer(gate('H', [sel('q', 2)]), gate('X', [sel('q', 0)]), gate('Y', [sel('q', 1)]))], + ), + ); + + expect(result).toBe( + String.raw`\mathrm{X}_{q_{0}} \cdot \mathrm{Y}_{q_{1}} \cdot \mathrm{H}_{q_{2}} \cdot \lvert 000\rangle_{q_{0} q_{1} q_{2}}`, + ); + }); + it('returns an empty string when there are no qubits', () => { expect(toLabeledDirac(circuit([], []))).toBe(''); }); diff --git a/frontend/src/notation/dirac/labeledMapper.ts b/frontend/src/notation/dirac/labeledMapper.ts index 8eae1727..b8718809 100644 --- a/frontend/src/notation/dirac/labeledMapper.ts +++ b/frontend/src/notation/dirac/labeledMapper.ts @@ -7,6 +7,7 @@ import { } from '@/api/dto/circuit.ts'; import { gateSymbol } from '@/notation/dirac/symbols.ts'; import { assembleDirac, buildLayerGroups, Layout } from '@/notation/dirac/layout.ts'; +import { buildWireIndex, WireIndex } from '@/lib/circuitIndex.ts'; /** * Export a circuit as labelled Dirac notation. @@ -17,7 +18,10 @@ export function toLabeledDirac(circuit: CircuitResponse, layout: Layout = 'inlin const ket = renderInitialState(circuit.registers, resolveLabel); if (!ket) return ''; - const layerGroups = buildLayerGroups(circuit, (gate) => renderOperator(gate, resolveLabel)); + const wireIndex = buildWireIndex(circuit.registers, 'quantum'); + const orderKey = (gate: ElementaryQuantumGateDto) => topmostWire(gate, wireIndex); + + const layerGroups = buildLayerGroups(circuit, (gate) => renderOperator(gate, resolveLabel), orderKey); return assembleDirac(layerGroups, ket, layout); } @@ -72,6 +76,15 @@ function renderOperator( return `${base}${dagger}${suffix}_{${labels}}`; } +// The gate's topmost qubit (lowest wire index), used to order gates within a layer. +function topmostWire(gate: ElementaryQuantumGateDto, wireIndex: WireIndex): number { + const wires = [...gate.controlQubits, ...gate.targetQubits] + .map((selector) => wireIndex.getWireIndex(selector)) + .filter((wire): wire is number => wire !== undefined); + + return wires.length > 0 ? Math.min(...wires) : Number.MAX_SAFE_INTEGER; +} + // Escape the characters that are special inside a KaTeX \text{} group. function escapeLatexText(value: string): string { return value diff --git a/frontend/src/notation/dirac/layout.ts b/frontend/src/notation/dirac/layout.ts index e263058d..05d61d78 100644 --- a/frontend/src/notation/dirac/layout.ts +++ b/frontend/src/notation/dirac/layout.ts @@ -7,26 +7,24 @@ export type Layout = 'inline' | 'layered'; /** * Groups the rendered operators per circuit layer, in reverse application order (last-applied * layer first). `renderOperation` turns one gate into its token; an empty token is dropped, and - * empty layers are omitted. Shared by both Dirac mappers, which only differ in `renderOperation`. + * empty layers are omitted. Within a layer the gates commute, so `orderKey` (if given) sorts them + * ascending — e.g. by qubit index — for a stable reading order. */ export function buildLayerGroups( circuit: CircuitResponse, renderOperation: (gate: ElementaryQuantumGateDto) => string, + orderKey?: (gate: ElementaryQuantumGateDto) => number, ): string[][] { const layerGroups: string[][] = []; for (let layerIdx = circuit.layers.length - 1; layerIdx >= 0; layerIdx--) { - const operations = circuit.layers[layerIdx].quantumOperations; - const tokens: string[] = []; + const gates = circuit.layers[layerIdx].quantumOperations.filter( + (operation): operation is ElementaryQuantumGateDto => operation.type === 'ELEMENTARY_QUANTUM_GATE', + ); - for (let opIdx = operations.length - 1; opIdx >= 0; opIdx--) { - const operation = operations[opIdx]; + if (orderKey) gates.sort((a, b) => orderKey(a) - orderKey(b)); - if (operation.type !== 'ELEMENTARY_QUANTUM_GATE') continue; - - const token = renderOperation(operation); - if (token) tokens.push(token); - } + const tokens = gates.map(renderOperation).filter((token) => token.length > 0); if (tokens.length > 0) layerGroups.push(tokens); } diff --git a/frontend/src/notation/dirac/unlabeledMapper.test.ts b/frontend/src/notation/dirac/unlabeledMapper.test.ts deleted file mode 100644 index eb526604..00000000 --- a/frontend/src/notation/dirac/unlabeledMapper.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { toUnlabeledDirac } from './unlabeledMapper.ts'; -import { - CircuitResponse, - ElementaryQuantumGateDto, - ElementSelectorDto, - LayerResponse, - QuantumOperationDto, - QuantumRegisterResponse, -} from '@/api/dto/circuit.ts'; - -const quantumRegister = (id: string, numberOfQubits: number, name = id): QuantumRegisterResponse => ({ - id, - name, - type: 'Quantum_Register', - numberOfQubits, -}); - -const sel = (registerId: string, index: number): ElementSelectorDto => ({ registerId, index }); - -const gate = ( - identifier: string, - targetQubits: ElementSelectorDto[], - controlQubits: ElementSelectorDto[] = [], - overrides: Partial = {}, -): ElementaryQuantumGateDto => ({ - type: 'ELEMENTARY_QUANTUM_GATE', - identifier: identifier as ElementaryQuantumGateDto['identifier'], - inverseForm: false, - targetQubits, - controlQubits, - rotationAngle: 0, - ...overrides, -}); - -const layer = (...ops: QuantumOperationDto[]): LayerResponse => ({ quantumOperations: ops }); - -const circuit = (registers: QuantumRegisterResponse[], layers: LayerResponse[]): CircuitResponse => ({ - id: 'test-circuit', - registers, - layers, -}); - -describe('toUnlabeledDirac', () => { - it('renders a single-qubit gate as a tensor product with identities', () => { - const result = toUnlabeledDirac(circuit([quantumRegister('q', 2)], [layer(gate('H', [sel('q', 0)]))])); - - expect(result).toBe(String.raw`\left(\mathrm{H} \otimes I\right) \cdot \lvert 00\rangle`); - }); - - it('places a single-qubit gate at its wire', () => { - const result = toUnlabeledDirac( - circuit([quantumRegister('q', 2)], [layer(gate('RZ', [sel('q', 1)], [], { rotationAngle: Math.PI / 2 }))]), - ); - - expect(result).toBe( - String.raw`\left(I \otimes \mathrm{R}_{z}\!\left(\frac{\pi}{2}\right)\right) \cdot \lvert 00\rangle`, - ); - }); - - it('renders an adjacent, in-order multi-qubit gate as a single block (I ⊗ CNOT)', () => { - // CNOT(control=q1, target=q2) on 3 qubits — wires [1, 2] are contiguous and ascending. - const cnot = gate('CX', [sel('q', 2)], [sel('q', 1)]); - - const result = toUnlabeledDirac(circuit([quantumRegister('q', 3)], [layer(cnot)])); - - expect(result).toBe(String.raw`\left(I \otimes \mathrm{CNOT}\right) \cdot \lvert 000\rangle`); - }); - - it('conjugates a non-adjacent gate with SWAPs', () => { - // CNOT(control=q0, target=q2) on 3 qubits — wires [0, 2] are not contiguous. - const cnot = gate('CX', [sel('q', 2)], [sel('q', 0)]); - - const result = toUnlabeledDirac(circuit([quantumRegister('q', 3)], [layer(cnot)])); - - expect(result).toBe( - String.raw`\left(I \otimes \mathrm{SWAP}\right) \cdot \left(\mathrm{CNOT} \otimes I\right) \cdot \left(I \otimes \mathrm{SWAP}\right) \cdot \lvert 000\rangle`, - ); - }); - - it('conjugates a control-below-target gate with SWAPs (operand order preserved)', () => { - // CNOT(control=q1, target=q0) — operand order [1, 0] is descending, so it needs a SWAP. - const cnot = gate('CX', [sel('q', 0)], [sel('q', 1)]); - - const result = toUnlabeledDirac(circuit([quantumRegister('q', 2)], [layer(cnot)])); - - expect(result).toBe( - String.raw`\left(\mathrm{SWAP}\right) \cdot \left(\mathrm{CNOT}\right) \cdot \left(\mathrm{SWAP}\right) \cdot \lvert 00\rangle`, - ); - }); - - it('marks an inverse gate with a dagger', () => { - const result = toUnlabeledDirac( - circuit([quantumRegister('q', 1)], [layer(gate('T', [sel('q', 0)], [], { inverseForm: true }))]), - ); - - expect(result).toBe(String.raw`\left(\mathrm{T}^{\dagger}\right) \cdot \lvert 0\rangle`); - }); - - it('composes operators right-to-left across layers', () => { - const result = toUnlabeledDirac( - circuit([quantumRegister('q', 1)], [layer(gate('H', [sel('q', 0)])), layer(gate('X', [sel('q', 0)]))]), - ); - - expect(result).toBe(String.raw`\left(\mathrm{X}\right) \cdot \left(\mathrm{H}\right) \cdot \lvert 0\rangle`); - }); - - it('skips non-unitary operations such as measurements', () => { - const measurement: QuantumOperationDto = { - type: 'MEASUREMENT', - identifier: 'MEASURE', - inverseForm: false, - targetQubits: [sel('q', 0)], - controlQubits: [], - classicBits: [], - }; - - const result = toUnlabeledDirac(circuit([quantumRegister('q', 1)], [layer(measurement)])); - - expect(result).toBe(String.raw`\lvert 0\rangle`); - }); - - it('breaks per layer in the layered layout', () => { - const result = toUnlabeledDirac( - circuit([quantumRegister('q', 1)], [layer(gate('H', [sel('q', 0)])), layer(gate('X', [sel('q', 0)]))]), - 'layered', - ); - - expect(result).toBe( - [ - '\\begin{aligned}', - String.raw`& \left(\mathrm{X}\right) \\`, - String.raw`& \cdot \left(\mathrm{H}\right) \\`, - String.raw`& \cdot \lvert 0\rangle`, - '\\end{aligned}', - ].join('\n'), - ); - }); - - it('returns an empty string when there are no qubits', () => { - expect(toUnlabeledDirac(circuit([], []))).toBe(''); - }); -}); diff --git a/frontend/src/notation/dirac/unlabeledMapper.ts b/frontend/src/notation/dirac/unlabeledMapper.ts deleted file mode 100644 index da9c2e2a..00000000 --- a/frontend/src/notation/dirac/unlabeledMapper.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { CircuitResponse, ElementaryQuantumGateDto, getCircuitWidth } from '@/api/dto/circuit.ts'; -import { buildWireIndex, WireIndex } from '@/lib/circuitIndex.ts'; -import { gateSymbolLatex } from '@/notation/dirac/symbols.ts'; -import { assembleDirac, buildLayerGroups, Layout } from '@/notation/dirac/layout.ts'; - -const COMPOSITION = String.raw` \cdot `; -const IDENTITY = 'I'; -const SWAP = String.raw`\mathrm{SWAP}`; - -interface Placement { - start: number; - span: number; - symbol: string; -} - -/** - * Export a circuit as unlabelled Dirac notation: operators as tensor products with identities - * (e.g. `I \otimes \mathrm{CNOT}`) applied to `\lvert 0\ldots0\rangle`. - */ -export function toUnlabeledDirac(circuit: CircuitResponse, layout: Layout = 'inline'): string { - const numQubits = getCircuitWidth(circuit); - if (numQubits === 0) return ''; - - const wireIndex = buildWireIndex(circuit.registers, 'quantum'); - const layerGroups = buildLayerGroups(circuit, (gate) => renderGate(gate, numQubits, wireIndex)); - - const ket = String.raw`\lvert ${'0'.repeat(numQubits)}\rangle`; - - return assembleDirac(layerGroups, ket, layout); -} - -function renderGate(gate: ElementaryQuantumGateDto, numQubits: number, wireIndex: WireIndex): string { - const symbol = gateSymbolLatex(gate); - - // Operand wires in the gate's order (controls first, then targets), never sorted. - const wires = [...gate.controlQubits, ...gate.targetQubits] - .map((selector) => wireIndex.getWireIndex(selector)) - .filter((wire): wire is number => wire !== undefined); - - if (wires.length === 0) return ''; - - if (wires.length === 1) { - return tensorFactor(numQubits, [{ start: wires[0], span: 1, symbol }]); - } - - const base = Math.min(...wires); - - // Contiguous and already in ascending operand order → one clean tensor factor. - const isContiguousBlock = wires.every((wire, index) => wire === base + index); - if (isContiguousBlock) { - return tensorFactor(numQubits, [{ start: base, span: wires.length, symbol }]); - } - - return renderWithSwapConjugation(numQubits, wires, base, symbol); -} - -/** - * A gate on non-adjacent/out-of-order wires is written as `S^\dagger (I \otimes G \otimes I) S`, - * where `S` routes the operands into the contiguous block `[base, base + k)` and cancels around it. - */ -function renderWithSwapConjugation(numQubits: number, wires: number[], base: number, symbol: string): string { - const routing = routeOperandsToBlock(wires, numQubits, base); - const swapFactor = (position: number) => tensorFactor(numQubits, [{ start: position, span: 2, symbol: SWAP }]); - - const block = tensorFactor(numQubits, [{ start: base, span: wires.length, symbol }]); - const parts = [...routing.map(swapFactor), block, ...[...routing].reverse().map(swapFactor)]; - - return parts.join(COMPOSITION); -} - -/** Adjacent SWAPs (each given by its lower position) that bring the operands into `[base, base + k)`. */ -function routeOperandsToBlock(wires: number[], numQubits: number, base: number): number[] { - const target = new Array(numQubits); - wires.forEach((wire, index) => { - target[base + index] = wire; - }); - - const operandSet = new Set(wires); - const remaining: number[] = []; - for (let wire = 0; wire < numQubits; wire++) { - if (!operandSet.has(wire)) remaining.push(wire); - } - let remainingIdx = 0; - for (let position = 0; position < numQubits; position++) { - target[position] ??= remaining[remainingIdx++]; - } - - // Insertion sort into `target`; never disturbs already-placed positions. - const arrangement = Array.from({ length: numQubits }, (_, index) => index); - const swaps: number[] = []; - for (let position = 0; position < numQubits; position++) { - let current = arrangement.indexOf(target[position]); - while (current > position) { - swaps.push(current - 1); - [arrangement[current - 1], arrangement[current]] = [arrangement[current], arrangement[current - 1]]; - current--; - } - } - - return swaps; -} - -/** Builds `\left(f_0 \otimes ... \right)`, filling wires not covered by a placement with `I`. */ -function tensorFactor(numQubits: number, placements: Placement[]): string { - const placementByStart = new Map(placements.map((placement) => [placement.start, placement])); - const entries: string[] = []; - - let wire = 0; - while (wire < numQubits) { - const placement = placementByStart.get(wire); - if (placement) { - entries.push(placement.symbol); - wire += placement.span; - } else { - entries.push(IDENTITY); - wire++; - } - } - - const tensorFactor = entries.join(String.raw` \otimes `); - return String.raw`\left(${tensorFactor}\right)`; -} diff --git a/frontend/src/views/project-manager-view/File.tsx b/frontend/src/views/project-manager-view/File.tsx index bb4fd338..a1632f98 100644 --- a/frontend/src/views/project-manager-view/File.tsx +++ b/frontend/src/views/project-manager-view/File.tsx @@ -73,7 +73,7 @@ function OpenWithFormalEditor({ id, name }: Readonly<{ id: string; name: string const dispatch = useAppDispatch(); const openFormalTab = () => dispatch(openTab({ tab: createFormalTab(id, name) })); - return Open with Formal Editor; + return Open in Dirac Notation; } function FileRename(id: string, trigger: (element: Promise) => void) { diff --git a/frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx b/frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx index 1e53525f..3934245d 100644 --- a/frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx +++ b/frontend/src/views/text-editor-view/components/formal-editor/FormalEditor.tsx @@ -9,7 +9,6 @@ import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/c import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle.tsx'; import { CircuitResponse } from '@/api/dto/circuit.ts'; import { toLabeledDirac } from '@/notation/dirac/labeledMapper.ts'; -import { toUnlabeledDirac } from '@/notation/dirac/unlabeledMapper.ts'; import { Layout } from '@/notation/dirac/layout.ts'; interface FormalEditorViewProps { @@ -22,7 +21,6 @@ export function FormalEditor({ circuit }: Readonly) { const [layout, setLayout] = useState('inline'); const labeled = useMemo(() => (circuit ? toLabeledDirac(circuit, layout) : ''), [circuit, layout]); - const unlabeled = useMemo(() => (circuit ? toUnlabeledDirac(circuit, layout) : ''), [circuit, layout]); if (!labeled) { return ( @@ -57,7 +55,6 @@ export function FormalEditor({ circuit }: Readonly) { - ); From 64eae7554742e003a4d2f146b2987dc2315d23a3 Mon Sep 17 00:00:00 2001 From: Nick Oelmann Date: Wed, 22 Jul 2026 15:33:58 +0200 Subject: [PATCH 07/11] #161: Adapt to new qasm-circuit multitab files --- frontend/src/store/tabs/tabsSlice.ts | 16 ++++-- frontend/src/store/tabs/tabsTypes.ts | 6 ++- .../src/views/project-manager-view/File.tsx | 41 +++++++++++---- .../components/formal-editor/formalTab.ts | 24 +-------- .../components/layout/EditorSlot.tsx | 20 ++++--- .../components/tabs/EditorTabBar.tsx | 13 ++++- .../tabs/EditorTabContextMenuContent.tsx | 52 ++++++++++++++----- .../components/tabs/EditorTabLabel.tsx | 5 +- 8 files changed, 117 insertions(+), 60 deletions(-) diff --git a/frontend/src/store/tabs/tabsSlice.ts b/frontend/src/store/tabs/tabsSlice.ts index aa0686ff..840d5857 100644 --- a/frontend/src/store/tabs/tabsSlice.ts +++ b/frontend/src/store/tabs/tabsSlice.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DEFAULT_LANG, languages } from '@/views/text-editor-view/languages/languages.ts'; -import { EditorGroup, PendingClose, Tab, TabsState } from '@/store/tabs/tabsTypes.ts'; +import { EditorGroup, PendingClose, Tab, TabsState, TabViewMode } from '@/store/tabs/tabsTypes.ts'; export const GROUP_MAIN = 'group-main'; export const GROUP_RIGHT = 'group-right'; @@ -239,8 +239,7 @@ export const tabsSlice = createSlice({ const tab = action.payload.tab; const newTab: Tab = { ...tab, - // Formal tabs aren't backed by the editor, so they have no language to derive. - language: tab.kind === 'formal' ? tab.language : getLanguageByExtension(tab.title), + language: getLanguageByExtension(tab.title), }; const exists = group.openTabs.find((t) => t.id === action.payload.tab.id); @@ -282,6 +281,16 @@ export const tabsSlice = createSlice({ closeAll: () => initialState, + // Switches how a tab is displayed (source vs. Dirac notation). Applied to every group the + // tab is open in so split views stay in sync, since both share the same underlying file. + setTabViewMode: (state, action: PayloadAction<{ tabId: string; viewMode: TabViewMode }>) => { + const { tabId, viewMode } = action.payload; + state.groups.forEach((group) => { + const tab = group.openTabs.find((t) => t.id === tabId); + if (tab) tab.viewMode = viewMode; + }); + }, + setActiveTab: (state, action: PayloadAction<{ tabId: string; groupId: string }>) => { const group = state.groups.find((g) => g.id === action.payload.groupId); if (group) { @@ -360,6 +369,7 @@ export const { closeTab, closeOthers, closeAll, + setTabViewMode, setActiveTab, moveTab, requestLanguageChange, diff --git a/frontend/src/store/tabs/tabsTypes.ts b/frontend/src/store/tabs/tabsTypes.ts index 062ba375..12bbd94f 100644 --- a/frontend/src/store/tabs/tabsTypes.ts +++ b/frontend/src/store/tabs/tabsTypes.ts @@ -1,8 +1,12 @@ +// How a tab's file is displayed. A .qasm file can be shown as its source ('code') or as the +// read-only Dirac notation ('formal') — two views of the same tab, not two separate tabs. +export type TabViewMode = 'code' | 'formal'; + export interface Tab { id: string; // Unique file id title: string; // Filename language: string; // language setting - kind?: 'code' | 'formal'; // Tab content type; absent means a regular code tab. + viewMode?: TabViewMode; // Current view; absent means the regular code (text editor) view. } export interface EditorGroup { diff --git a/frontend/src/views/project-manager-view/File.tsx b/frontend/src/views/project-manager-view/File.tsx index a1632f98..48ad6e73 100644 --- a/frontend/src/views/project-manager-view/File.tsx +++ b/frontend/src/views/project-manager-view/File.tsx @@ -4,7 +4,15 @@ import { ParentRefresh, SelectedFolder, } from '@/views/project-manager-view/ProjectManagerContexts.ts'; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu.tsx'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from '@/components/ui/context-menu.tsx'; import { Delete } from '@/views/project-manager-view/Delete.tsx'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.tsx'; import { JSX, useCallback, useContext, useState } from 'react'; @@ -17,8 +25,9 @@ import { EntityForm } from '@/views/project-manager-view/util/FormUtils.tsx'; import { getFileIcon } from '@/views/project-manager-view/util/FileIcons.tsx'; import { toast } from 'sonner'; import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; -import { openTab } from '@/store/tabs/tabsSlice.ts'; -import { canOpenInFormalEditor, createFormalTab } from '@/views/text-editor-view/components/formal-editor/formalTab.ts'; +import { openTab, setTabViewMode } from '@/store/tabs/tabsSlice.ts'; +import { canOpenInFormalEditor } from '@/views/text-editor-view/components/formal-editor/formalTab.ts'; +import { TabViewMode } from '@/store/tabs/tabsTypes.ts'; /** * Displays a {@link IFile File} @@ -51,7 +60,7 @@ export function File(file: Readonly) { - {canOpenInFormalEditor(name) && } + {canOpenInFormalEditor(name) && } {FileRename(id, dialogTrigger)} @@ -64,16 +73,26 @@ export function File(file: Readonly) { } /** - * Opens the file in the formal (Dirac notation) editor as a dedicated tab. - * - * TEMPORARY: shows the single project circuit rather than the file's own circuit — see - * {@link canOpenInFormalEditor}. + * Lets the user open the file in a chosen view. Both views share one tab; opening either also makes + * the file's circuit the active one, and the Dirac notation is just a read-only view of that tab. */ -function OpenWithFormalEditor({ id, name }: Readonly<{ id: string; name: string }>) { +function OpenWithSubmenu({ id, name }: Readonly<{ id: string; name: string }>) { const dispatch = useAppDispatch(); - const openFormalTab = () => dispatch(openTab({ tab: createFormalTab(id, name) })); + const openWith = (viewMode: TabViewMode) => { + // Open (or focus) the file's tab, then switch it to the requested view. + dispatch(openTab({ tab: { id, title: name, language: '' } })); + dispatch(setTabViewMode({ tabId: id, viewMode })); + }; - return Open in Dirac Notation; + return ( + + Open With + + openWith('code')}>Text Editor + openWith('formal')}>Dirac Notation + + + ); } function FileRename(id: string, trigger: (element: Promise) => void) { diff --git a/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts b/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts index bdceddfc..26b5c418 100644 --- a/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts +++ b/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts @@ -1,26 +1,4 @@ -import { Tab } from '@/store/tabs/tabsTypes.ts'; - -// Formal-editor tabs live in their own id namespace, so a file can be open as both a normal code -// tab and a formal (Dirac) tab at the same time. -const FORMAL_TAB_ID_PREFIX = 'formal:'; - -/** - * TEMPORARY: the formal editor is offered for OpenQASM files only. - * - * Until the circuit ↔ file binding (feature #146) lands there is a single project circuit, so - * every formal tab shows that same circuit regardless of which .qasm file it was opened from. - * Once #146 is merged this should resolve the circuit belonging to the file instead. - */ +/** The formal (Dirac notation) view is offered for OpenQASM files only. */ export function canOpenInFormalEditor(fileName: string): boolean { return fileName.toLowerCase().endsWith('.qasm'); } - -/** Builds the formal (Dirac notation) tab for a given file. */ -export function createFormalTab(fileId: string, fileName: string): Tab { - return { - id: `${FORMAL_TAB_ID_PREFIX}${fileId}`, - title: `${fileName} (Dirac)`, - language: '', - kind: 'formal', - }; -} diff --git a/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx b/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx index 164fae7c..9145aa91 100644 --- a/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx +++ b/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx @@ -5,26 +5,34 @@ import { EditorTabBar } from '@/views/text-editor-view/components/tabs/EditorTab import { CardContent } from '@/components/ui/card.tsx'; import QLPEditor from '@/views/text-editor-view/components/core/QLPEditor.tsx'; import { FormalEditor } from '@/views/text-editor-view/components/formal-editor/FormalEditor.tsx'; -import { useProject } from '@/contexts/ProjectContext.tsx'; +import { useCircuitTabs } from '@/contexts/CircuitTabsContext.tsx'; export function EditorSlot({ groupId }: Readonly<{ groupId: string }>) { const dispatch = useAppDispatch(); - // A formal tab renders the Dirac notation instead of the Monaco editor. Non-formal (code) keep the existing editor. + // When the active tab is in Dirac view, overlay the read-only notation. The Monaco editor stays + // mounted underneath (just hidden) so unsaved edits and editor state survive the view switch. const isFormalActive = useAppSelector((state) => { const group = state.tabs.groups.find((g) => g.id === groupId); const activeTab = group?.openTabs.find((t) => t.id === group.activeTabId); - return activeTab?.kind === 'formal'; + return activeTab?.viewMode === 'formal'; }); - // TODO: resolve the circuit the tab was opened from. Atm formal tab only shows the single project circuit (wait for #146) - const { circuit } = useProject(); + // The Dirac view renders the per-file circuit of the active file, so it reflects circuit edits. + const { activeCircuit } = useCircuitTabs(); return (
dispatch(setActiveGroup(groupId))}> - {isFormalActive ? : } +
+ +
+ {isFormalActive && ( +
+ +
+ )}
); diff --git a/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx b/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx index 13f59188..d0cce304 100644 --- a/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx +++ b/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx @@ -1,4 +1,11 @@ -import { moveTab, requestLanguageChange, requestSave, setActiveTab, setDragging } from '@/store/tabs/tabsSlice.ts'; +import { + moveTab, + requestLanguageChange, + requestSave, + setActiveTab, + setDragging, + setTabViewMode, +} from '@/store/tabs/tabsSlice.ts'; import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu.tsx'; import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; import { useAppSelector } from '@/hooks/useAppSelector.ts'; @@ -103,6 +110,10 @@ export function EditorTabBar({ groupId }: Readonly) { }), ) } + onOpenWith={(viewMode) => { + dispatch(setTabViewMode({ tabId: tab.id, viewMode })); + dispatch(setActiveTab({ tabId: tab.id, groupId })); + }} onSave={() => dispatch(requestSave(tab.id))} /> diff --git a/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx b/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx index 7107960f..c182c971 100644 --- a/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx +++ b/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx @@ -11,7 +11,8 @@ import { GROUP_BOTTOM, GROUP_MAIN, GROUP_RIGHT } from '@/store/tabs/tabsSlice.ts import { languages } from '@/views/text-editor-view/languages/languages.ts'; import { Check } from 'lucide-react'; import { getKeyLabel, getOptionKeyLabel } from '@/views/text-editor-view/utils/getKeyLabel.ts'; -import { Tab } from '@/store/tabs/tabsTypes.ts'; +import { Tab, TabViewMode } from '@/store/tabs/tabsTypes.ts'; +import { canOpenInFormalEditor } from '@/views/text-editor-view/components/formal-editor/formalTab.ts'; interface TabContextMenuProps { tab: Tab; @@ -23,6 +24,7 @@ interface TabContextMenuProps { onCloseAll: () => void; onMoveTab: (toGroupId: string) => void; onChangeLanguage: (langId: string) => void; + onOpenWith: (viewMode: TabViewMode) => void; onSave: () => void; } @@ -35,11 +37,14 @@ export function EditorTabContextMenuContent({ onCloseAll, onMoveTab, onChangeLanguage, + onOpenWith, onSave, }: Readonly) { const metaKey = getKeyLabel(); const optionKey = getOptionKeyLabel(); - const isReadOnly = tab.kind === 'formal'; + // The Dirac notation is only a meaningful view for OpenQASM files. + const canShowDirac = canOpenInFormalEditor(tab.title); + const currentViewMode: TabViewMode = tab.viewMode ?? 'code'; return ( @@ -52,6 +57,30 @@ export function EditorTabContextMenuContent({ Close All + {canShowDirac && ( + <> + + + Open With + + {[ + { mode: 'code' as const, label: 'Text Editor' }, + { mode: 'formal' as const, label: 'Dirac Notation' }, + ].map((option) => ( + onOpenWith(option.mode)}> + {option.label} + {option.mode === currentViewMode && ( + + + + )} + + ))} + + + + )} + {[ @@ -67,9 +96,8 @@ export function EditorTabContextMenuContent({ ), )} - {/* Formal (read-only) tabs have no language and cannot be saved. */} - {isActive && !isReadOnly && } - {isActive && !isReadOnly && ( + {isActive && } + {isActive && ( Language @@ -86,15 +114,11 @@ export function EditorTabContextMenuContent({ )} - {!isReadOnly && ( - <> - - - Save - {metaKey} + S - - - )} + + + Save + {metaKey} + S + ); } diff --git a/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx b/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx index f52dc9a1..14d5b8a4 100644 --- a/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx +++ b/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx @@ -27,7 +27,10 @@ export const EditorTabLabel = React.forwardRef - {tab.title} + + {tab.title} + {tab.viewMode === 'formal' && ' (Dirac)'} + - - -
- -
-
- - - ); -} diff --git a/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts b/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts deleted file mode 100644 index 26b5c418..00000000 --- a/frontend/src/views/text-editor-view/components/formal-editor/formalTab.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** The formal (Dirac notation) view is offered for OpenQASM files only. */ -export function canOpenInFormalEditor(fileName: string): boolean { - return fileName.toLowerCase().endsWith('.qasm'); -} diff --git a/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx b/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx index 9145aa91..d3aecb61 100644 --- a/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx +++ b/frontend/src/views/text-editor-view/components/layout/EditorSlot.tsx @@ -1,38 +1,17 @@ import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; -import { useAppSelector } from '@/hooks/useAppSelector.ts'; import { setActiveGroup } from '@/store/tabs/tabsSlice.ts'; import { EditorTabBar } from '@/views/text-editor-view/components/tabs/EditorTabBar.tsx'; import { CardContent } from '@/components/ui/card.tsx'; import QLPEditor from '@/views/text-editor-view/components/core/QLPEditor.tsx'; -import { FormalEditor } from '@/views/text-editor-view/components/formal-editor/FormalEditor.tsx'; -import { useCircuitTabs } from '@/contexts/CircuitTabsContext.tsx'; export function EditorSlot({ groupId }: Readonly<{ groupId: string }>) { const dispatch = useAppDispatch(); - // When the active tab is in Dirac view, overlay the read-only notation. The Monaco editor stays - // mounted underneath (just hidden) so unsaved edits and editor state survive the view switch. - const isFormalActive = useAppSelector((state) => { - const group = state.tabs.groups.find((g) => g.id === groupId); - const activeTab = group?.openTabs.find((t) => t.id === group.activeTabId); - return activeTab?.viewMode === 'formal'; - }); - - // The Dirac view renders the per-file circuit of the active file, so it reflects circuit edits. - const { activeCircuit } = useCircuitTabs(); - return (
dispatch(setActiveGroup(groupId))}> -
- -
- {isFormalActive && ( -
- -
- )} +
); diff --git a/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx b/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx index d0cce304..13f59188 100644 --- a/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx +++ b/frontend/src/views/text-editor-view/components/tabs/EditorTabBar.tsx @@ -1,11 +1,4 @@ -import { - moveTab, - requestLanguageChange, - requestSave, - setActiveTab, - setDragging, - setTabViewMode, -} from '@/store/tabs/tabsSlice.ts'; +import { moveTab, requestLanguageChange, requestSave, setActiveTab, setDragging } from '@/store/tabs/tabsSlice.ts'; import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu.tsx'; import { useAppDispatch } from '@/hooks/useAppDispatch.ts'; import { useAppSelector } from '@/hooks/useAppSelector.ts'; @@ -110,10 +103,6 @@ export function EditorTabBar({ groupId }: Readonly) { }), ) } - onOpenWith={(viewMode) => { - dispatch(setTabViewMode({ tabId: tab.id, viewMode })); - dispatch(setActiveTab({ tabId: tab.id, groupId })); - }} onSave={() => dispatch(requestSave(tab.id))} /> diff --git a/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx b/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx index c182c971..f80d1f5d 100644 --- a/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx +++ b/frontend/src/views/text-editor-view/components/tabs/EditorTabContextMenuContent.tsx @@ -11,8 +11,7 @@ import { GROUP_BOTTOM, GROUP_MAIN, GROUP_RIGHT } from '@/store/tabs/tabsSlice.ts import { languages } from '@/views/text-editor-view/languages/languages.ts'; import { Check } from 'lucide-react'; import { getKeyLabel, getOptionKeyLabel } from '@/views/text-editor-view/utils/getKeyLabel.ts'; -import { Tab, TabViewMode } from '@/store/tabs/tabsTypes.ts'; -import { canOpenInFormalEditor } from '@/views/text-editor-view/components/formal-editor/formalTab.ts'; +import { Tab } from '@/store/tabs/tabsTypes.ts'; interface TabContextMenuProps { tab: Tab; @@ -24,7 +23,6 @@ interface TabContextMenuProps { onCloseAll: () => void; onMoveTab: (toGroupId: string) => void; onChangeLanguage: (langId: string) => void; - onOpenWith: (viewMode: TabViewMode) => void; onSave: () => void; } @@ -37,14 +35,10 @@ export function EditorTabContextMenuContent({ onCloseAll, onMoveTab, onChangeLanguage, - onOpenWith, onSave, }: Readonly) { const metaKey = getKeyLabel(); const optionKey = getOptionKeyLabel(); - // The Dirac notation is only a meaningful view for OpenQASM files. - const canShowDirac = canOpenInFormalEditor(tab.title); - const currentViewMode: TabViewMode = tab.viewMode ?? 'code'; return ( @@ -57,30 +51,6 @@ export function EditorTabContextMenuContent({ Close All - {canShowDirac && ( - <> - - - Open With - - {[ - { mode: 'code' as const, label: 'Text Editor' }, - { mode: 'formal' as const, label: 'Dirac Notation' }, - ].map((option) => ( - onOpenWith(option.mode)}> - {option.label} - {option.mode === currentViewMode && ( - - - - )} - - ))} - - - - )} - {[ diff --git a/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx b/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx index 14d5b8a4..f52dc9a1 100644 --- a/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx +++ b/frontend/src/views/text-editor-view/components/tabs/EditorTabLabel.tsx @@ -27,10 +27,7 @@ export const EditorTabLabel = React.forwardRef - - {tab.title} - {tab.viewMode === 'formal' && ' (Dirac)'} - + {tab.title} + + + + + + + ); +} diff --git a/frontend/src/views/inspector-view/diracInspect.ts b/frontend/src/views/inspector-view/diracInspect.ts new file mode 100644 index 00000000..25a3f162 --- /dev/null +++ b/frontend/src/views/inspector-view/diracInspect.ts @@ -0,0 +1,4 @@ +/** The Dirac notation is only meaningful for OpenQASM circuits. */ +export function canInspectWithDirac(fileName: string): boolean { + return fileName.toLowerCase().endsWith('.qasm'); +}