From 36bab7686413fcd2abae31bbb7bd791ef280fe12 Mon Sep 17 00:00:00 2001 From: Kevin Chen <109507575+kcccr123@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:02:14 -0500 Subject: [PATCH 1/2] python tutor visual mode --- frontend/src/features/canvas/Canvas.tsx | 129 +++- .../canvas/components/CallStack.module.css | 13 + .../features/canvas/components/CallStack.tsx | 38 +- .../features/canvas/components/CanvasBox.tsx | 6 + .../src/features/canvas/hooks/useCanvas.tsx | 100 ++- .../src/features/canvas/utils/box.renderer.ts | 25 +- .../src/features/canvas/utils/box.types.ts | 16 +- .../canvas/utils/pythonTutorReferences.ts | 329 +++++++++ .../canvas/utils/pythonTutorRenderer.ts | 673 ++++++++++++++++++ .../canvasControls/CanvasControls.tsx | 35 +- .../src/features/editors/Editor.module.css | 22 +- .../features/editors/boxEditor/BoxEditor.tsx | 11 + .../features/editors/boxEditor/Content.tsx | 39 +- .../editors/boxEditor/InlineTargetEditor.tsx | 218 ++++++ .../boxEditor/classBoxes/ClassContent.tsx | 74 +- .../collectionBoxes/CollectionContent.tsx | 21 +- .../collectionBoxes/CollectionItem.tsx | 196 +++-- .../functionBoxes/FunctionContent.tsx | 74 +- .../utils/pythonTutorInlinePrimitives.ts | 208 ++++++ .../memoryModelEditor/MemoryModelEditor.tsx | 4 + .../hooks/useLocalStorage.ts | 1 + .../hooks/useMemoryModelEditorState.ts | 6 + .../memoryModelEditor/utils/localStorage.ts | 16 +- .../src/features/palette/Palette.module.css | 9 + frontend/src/features/palette/Palette.tsx | 43 +- .../palette/components/PaletteBox.tsx | 7 +- .../src/features/palette/hooks/usePalette.tsx | 9 +- .../src/features/palette/utils/BoxRenderer.ts | 17 +- frontend/src/features/shared/types.ts | 17 +- frontend/src/styles/theme.css | 18 + 30 files changed, 2219 insertions(+), 155 deletions(-) create mode 100644 frontend/src/features/canvas/utils/pythonTutorReferences.ts create mode 100644 frontend/src/features/canvas/utils/pythonTutorRenderer.ts create mode 100644 frontend/src/features/editors/boxEditor/InlineTargetEditor.tsx create mode 100644 frontend/src/features/editors/utils/pythonTutorInlinePrimitives.ts diff --git a/frontend/src/features/canvas/Canvas.tsx b/frontend/src/features/canvas/Canvas.tsx index 1f6f57c..ebc5b15 100644 --- a/frontend/src/features/canvas/Canvas.tsx +++ b/frontend/src/features/canvas/Canvas.tsx @@ -8,13 +8,22 @@ import React, { useMemo, } from "react"; import Draggable from "react-draggable"; -import { CanvasElement, BoxType, ID } from "../shared/types"; +import { + CanvasElement, + BoxType, + ID, + VisualStyle, +} from "../shared/types"; import CanvasBox from "./components/CanvasBox"; import BoxEditor from "../editors/boxEditor/BoxEditor"; import CallStack from "./components/CallStack"; import { useCanvasRefs } from "./hooks/useCanvas"; import { validateElements } from "./utils/validation"; import styles from "./Canvas.module.css"; +import { + createElementsByIdMap, +} from "./utils/pythonTutorReferences"; +import { findOrphanedGeneratedPrimitiveIds } from "../editors/utils/pythonTutorInlinePrimitives"; const EDITOR_MAP: Record> = { primitive: BoxEditor, @@ -44,6 +53,8 @@ interface FloatingEditorProps { elements: CanvasElement[]; editorScale: number; questionFunctionNames?: string[]; + visualStyle?: VisualStyle; + onElementsChange: React.Dispatch>; } function FloatingEditor({ @@ -64,6 +75,8 @@ function FloatingEditor({ elements, editorScale, questionFunctionNames, + visualStyle = "memoryviz", + onElementsChange, }: FloatingEditorProps) { const nodeRef = useRef(null); @@ -109,6 +122,8 @@ function FloatingEditor({ sandbox={sandbox} elements={elements} questionFunctionNames={questionFunctionNames} + visualStyle={visualStyle} + onElementsChange={onElementsChange} /> @@ -131,7 +146,8 @@ interface CanvasProps { scale?: number; onScaleChange?: (scale: number) => void; editorScale?: number; - questionFunctionNames?: string[]; + questionFunctionNames?: string[]; + visualStyle?: VisualStyle; } function Canvas({ @@ -148,6 +164,7 @@ function Canvas({ scale: externalScale, editorScale = 1, questionFunctionNames, + visualStyle = "memoryviz", }: CanvasProps) { const [openEditors, setOpenEditors] = useState([]); const [selectedElement, setSelectedElement] = useState( @@ -301,6 +318,18 @@ function Canvas({ (event: React.DragEvent) => { event.preventDefault(); const boxType = event.dataTransfer.getData("application/box-type"); + const primitiveBoxTypes = new Set([ + "none", + "int", + "float", + "str", + "bool", + "primitive", + ]); + + if (visualStyle === "pythonTutor" && primitiveBoxTypes.has(boxType)) { + return; + } const newKind = createNewElement(boxType); if (!newKind) return; @@ -332,7 +361,7 @@ function Canvas({ return [...prev, newElement]; }); }, - [elements, ids, sandbox, svgRef, setElements] + [elements, ids, sandbox, svgRef, setElements, visualStyle] ); const saveElement = useCallback( @@ -368,10 +397,17 @@ function Canvas({ [elements, setElements, removeId] ); - const openElementEditor = useCallback((element: CanvasElement) => { - setOpenEditors([element]); - setSelectedElement(element); - }, []); + const openElementEditor = useCallback( + (element: CanvasElement) => { + if (visualStyle === "pythonTutor" && element.kind.name === "primitive") { + return; + } + + setOpenEditors([element]); + setSelectedElement(element); + }, + [visualStyle] + ); const closeElementEditor = useCallback((boxId: number) => { setOpenEditors((prev) => prev.filter((el) => el.boxId !== boxId)); @@ -385,6 +421,19 @@ function Canvas({ } }, [onEditorOpenerReady, openElementEditor]); + useEffect(() => { + if (visualStyle !== "pythonTutor") { + return; + } + + setOpenEditors((prev) => + prev.filter((element) => element.kind.name !== "primitive") + ); + setSelectedElement((prev) => + prev?.kind.name === "primitive" ? null : prev + ); + }, [visualStyle]); + // Close all open editors when Escape is pressed useEffect(() => { if (openEditors.length === 0) return; @@ -400,6 +449,57 @@ function Canvas({ }, [openEditors.length]); const functionFrames = elements.filter((el) => el.kind.name === "function"); + const elementsById = useMemo(() => createElementsByIdMap(elements), [elements]); + const visibleObjects = useMemo( + () => + elements.filter((el) => { + if (el.kind.name === "function") return false; + return !(visualStyle === "pythonTutor" && el.kind.name === "primitive"); + }), + [elements, visualStyle] + ); + + useEffect(() => { + const orphanedGeneratedPrimitiveIds = findOrphanedGeneratedPrimitiveIds(elements); + if (orphanedGeneratedPrimitiveIds.length === 0) { + return; + } + + orphanedGeneratedPrimitiveIds.forEach((id) => removeId(id)); + setElements((prev) => + prev.filter( + (element) => + !( + element.kind.name === "primitive" && + element.generatedInlinePrimitive && + typeof element.id === "number" && + orphanedGeneratedPrimitiveIds.includes(element.id) + ) + ) + ); + setOpenEditors((prev) => + prev.filter( + (element) => + !( + element.kind.name === "primitive" && + typeof element.id === "number" && + orphanedGeneratedPrimitiveIds.includes(element.id) + ) + ) + ); + setSelectedElement((prev) => { + if ( + prev && + prev.kind.name === "primitive" && + typeof prev.id === "number" && + orphanedGeneratedPrimitiveIds.includes(prev.id) + ) { + return null; + } + + return prev; + }); + }, [elements, removeId, setElements]); const handleCallStackReorder = useCallback( (fromIndex: number, toIndex: number) => { @@ -461,19 +561,21 @@ function Canvas({ onReorder={handleCallStackReorder} onWidthChange={setCallStackWidth} scale={scale} + visualStyle={visualStyle} + elementsById={elementsById} /> - {elements - .filter((el) => el.kind.name !== "function") - .map((el) => ( + {visibleObjects.map((el) => ( openElementEditor(el)} + openInterface={(target) => openElementEditor(target ?? el)} updatePosition={createPositionUpdater(el.boxId)} invalidated={el.invalidated} callStackWidth={callStackWidth} + visualStyle={visualStyle} + elementsById={elementsById} /> ))} @@ -504,6 +606,8 @@ function Canvas({ elements={elements} editorScale={editorScale} questionFunctionNames={questionFunctionNames} + visualStyle={visualStyle} + onElementsChange={setElements} /> ); })} @@ -586,7 +690,8 @@ const areEqual = (prev: Readonly, next: Readonly) => { prev.sandbox === next.sandbox && prev.scale === next.scale && prev.editorScale === next.editorScale && - prev.questionFunctionNames === next.questionFunctionNames + prev.questionFunctionNames === next.questionFunctionNames && + prev.visualStyle === next.visualStyle ); }; diff --git a/frontend/src/features/canvas/components/CallStack.module.css b/frontend/src/features/canvas/components/CallStack.module.css index 4908af1..89f5d49 100644 --- a/frontend/src/features/canvas/components/CallStack.module.css +++ b/frontend/src/features/canvas/components/CallStack.module.css @@ -34,6 +34,19 @@ font-size: 1rem; } +.pythonTutorTitle { + font-family: "Segoe UI", Arial, sans-serif; + font-weight: 600; + letter-spacing: 0.01em; + text-transform: none; +} + +.pythonTutorSelectionHighlight { + fill: var(--python-tutor-selected-frame-bg); + stroke: none; + opacity: 0.9; +} + .selectionHighlight { fill: none; stroke: var(--accent-blue); diff --git a/frontend/src/features/canvas/components/CallStack.tsx b/frontend/src/features/canvas/components/CallStack.tsx index 9f9c275..cd580a9 100644 --- a/frontend/src/features/canvas/components/CallStack.tsx +++ b/frontend/src/features/canvas/components/CallStack.tsx @@ -6,7 +6,10 @@ import React, { useState, useId, } from "react"; -import { CanvasElement } from "../../shared/types"; +import { + CanvasElement, + VisualStyle, +} from "../../shared/types"; import { BoxDimensions } from "../utils/box.types"; import CanvasBox from "./CanvasBox"; import styles from "./CallStack.module.css"; @@ -37,6 +40,8 @@ interface CallStackProps { y?: number; width?: number; scale?: number; + visualStyle?: VisualStyle; + elementsById?: Map; } interface DragState { @@ -69,6 +74,8 @@ const CallStack: React.FC = ({ y = 73, width = 205, scale = 1, + visualStyle = "memoryviz", + elementsById, }) => { const clipPathId = useId(); @@ -115,15 +122,6 @@ const CallStack: React.FC = ({ columnHeight - HEADER_HEIGHT - TOP_PADDING - BOTTOM_PADDING ); - // Log for debugging - remove after verification - console.log('CallStack centering:', { - viewportHeight, - yPosition, - bottomSpacing, - columnHeight, - 'Should be equal': yPosition === bottomSpacing - }); - const layout = useMemo((): LayoutItem[] => { let yOffset = 0; const baseY = yPosition + HEADER_HEIGHT + TOP_PADDING + visibleHeight; @@ -367,13 +365,15 @@ const CallStack: React.FC = ({ /> - Call Stack + {visualStyle === "pythonTutor" ? "Frames" : "Call Stack"} @@ -406,13 +406,17 @@ const CallStack: React.FC = ({ boxSizes[frame.boxId]?.width ?? DEFAULT_BOX_WIDTH; return ( ); @@ -420,11 +424,13 @@ const CallStack: React.FC = ({ onSelect(frame)} + openInterface={(target) => onSelect(target ?? frame)} updatePosition={() => {}} onSizeChange={handleBoxSizeChange} invalidated={frame.invalidated} disableDrag={true} + visualStyle={visualStyle} + elementsById={elementsById} /> ))} diff --git a/frontend/src/features/canvas/components/CanvasBox.tsx b/frontend/src/features/canvas/components/CanvasBox.tsx index 42be324..6c58ae2 100644 --- a/frontend/src/features/canvas/components/CanvasBox.tsx +++ b/frontend/src/features/canvas/components/CanvasBox.tsx @@ -14,6 +14,9 @@ export default function CanvasBox({ invalidated = false, disableDrag = false, callStackWidth, + visualStyle = "memoryviz", + elementsById, + renderMode = "canvas", }: CanvasBoxProps) { const { gRef, dragState, dimensions } = useBoxDragState(); @@ -27,6 +30,9 @@ export default function CanvasBox({ invalidated, disableDrag, callStackWidth, + visualStyle, + elementsById, + renderMode, }); // Report size changes for parent components (like CallStack) diff --git a/frontend/src/features/canvas/hooks/useCanvas.tsx b/frontend/src/features/canvas/hooks/useCanvas.tsx index 78f0b8b..9bc96ce 100644 --- a/frontend/src/features/canvas/hooks/useCanvas.tsx +++ b/frontend/src/features/canvas/hooks/useCanvas.tsx @@ -5,7 +5,11 @@ import { useRef, useEffect, useCallback } from "react"; import { createBoxRenderer } from "../utils/box.renderer"; -import { CanvasElement } from "../../shared/types"; +import { + CanvasElement, + RenderMode, + VisualStyle, +} from "../../shared/types"; import { DragState, BoxDimensions } from "../utils/box.types"; import { getCallStackBounds, @@ -127,19 +131,26 @@ export function useDraggableBox({ invalidated = false, disableDrag = false, callStackWidth, + visualStyle = "memoryviz", + elementsById, + renderMode = "canvas", }: { gRef: React.RefObject; element: CanvasElement; dimensions: React.MutableRefObject; dragState: React.MutableRefObject; - openInterface: (element: CanvasElement) => void; + openInterface: (element: CanvasElement | null) => void; updatePosition: (x: number, y: number) => void; invalidated?: boolean; disableDrag?: boolean; callStackWidth?: number; + visualStyle?: VisualStyle; + elementsById?: Map; + renderMode?: RenderMode; }) { const livePosRef = useRef({ x: element.x, y: element.y }); const movedRef = useRef(false); + const boxSvgRef = useRef(null); const CLICK_EPS = 3; const getSvgPoint = useCallback( @@ -226,9 +237,67 @@ export function useDraggableBox({ }); }, [handleMouseMove, updatePosition, dragState]); + const findReferencedElement = useCallback( + (event: MouseEvent | React.MouseEvent) => { + if (visualStyle !== "pythonTutor" || !elementsById || !boxSvgRef.current) { + return null; + } + + const boxSvg = boxSvgRef.current; + const ctm = boxSvg.getScreenCTM(); + if (!ctm) return null; + + const point = boxSvg.createSVGPoint(); + point.x = event.clientX; + point.y = event.clientY; + const localPoint = point.matrixTransform(ctm.inverse()); + + const hitTargets = Array.from( + boxSvg.querySelectorAll("[data-ref-target-id]") + ); + + for (const hitTarget of hitTargets) { + const x = parseFloat(hitTarget.getAttribute("x") || "0"); + const y = parseFloat(hitTarget.getAttribute("y") || "0"); + const width = parseFloat(hitTarget.getAttribute("width") || "0"); + const height = parseFloat(hitTarget.getAttribute("height") || "0"); + + const isInside = + localPoint.x >= x && + localPoint.x <= x + width && + localPoint.y >= y && + localPoint.y <= y + height; + + if (!isInside) continue; + + const targetId = parseInt( + hitTarget.getAttribute("data-ref-target-id") || "", + 10 + ); + if (!Number.isInteger(targetId)) continue; + + const target = elementsById.get(targetId); + if ( + target && + !(visualStyle === "pythonTutor" && target.kind.name === "primitive") + ) { + return target; + } + } + + return null; + }, + [elementsById, visualStyle] + ); + const handleMouseDown = useCallback( (event: MouseEvent | React.MouseEvent) => { event.stopPropagation(); + if (findReferencedElement(event)) { + movedRef.current = false; + return; + } + movedRef.current = false; dragState.current.isDragging = true; @@ -248,6 +317,7 @@ export function useDraggableBox({ getSvgPoint, handleMouseMove, handleMouseUp, + findReferencedElement, dragState, ] ); @@ -260,7 +330,12 @@ export function useDraggableBox({ const container = gRef.current; if (!container) return; - const svgElement = createBoxRenderer(element); + const svgElement = createBoxRenderer(element, { + visualStyle, + elementsById, + renderMode, + }); + boxSvgRef.current = svgElement; const padding = 12; container.innerHTML = ""; @@ -312,13 +387,24 @@ export function useDraggableBox({ const handleClick = (ev: MouseEvent) => { ev.stopPropagation(); if (movedRef.current) return; - openInterface(element); + const targetElement = findReferencedElement(ev); + openInterface(targetElement ?? element); + }; + + const handleOverlayMouseMove = (ev: MouseEvent) => { + const targetElement = findReferencedElement(ev); + overlay.style.cursor = targetElement + ? "pointer" + : disableDrag + ? "pointer" + : "grab"; }; if (!disableDrag) { overlay.addEventListener("mousedown", handleMouseDown as EventListener); } overlay.addEventListener("click", handleClick); + overlay.addEventListener("mousemove", handleOverlayMouseMove); svgElement.appendChild(overlay); return () => { @@ -329,6 +415,8 @@ export function useDraggableBox({ ); } overlay.removeEventListener("click", handleClick); + overlay.removeEventListener("mousemove", handleOverlayMouseMove); + boxSvgRef.current = null; }; }, [ element, @@ -338,5 +426,9 @@ export function useDraggableBox({ gRef, dimensions, disableDrag, + visualStyle, + elementsById, + renderMode, + findReferencedElement, ]); } diff --git a/frontend/src/features/canvas/utils/box.renderer.ts b/frontend/src/features/canvas/utils/box.renderer.ts index f0a90bd..2bad878 100644 --- a/frontend/src/features/canvas/utils/box.renderer.ts +++ b/frontend/src/features/canvas/utils/box.renderer.ts @@ -4,12 +4,13 @@ */ import MemoryViz from "memory-viz"; -import { CanvasElement } from "../../shared/types"; +import { CanvasElement, RenderMode, VisualStyle } from "../../shared/types"; import { MemoryVizConfig } from "./box.types"; import { getBoxConfig, DEFAULT_CANVAS_STYLE, } from "../../shared/boxConfig"; +import { createPythonTutorBoxRenderer } from "./pythonTutorRenderer"; /** * Base MemoryViz configuration for all canvas boxes. @@ -40,7 +41,27 @@ const DEFAULT_MEMORY_VIZ_CONFIG: Partial = { * const svg = createBoxRenderer(element); * container.appendChild(svg); */ -export function createBoxRenderer(element: CanvasElement): SVGSVGElement { +export function createBoxRenderer( + element: CanvasElement, + options: { + visualStyle?: VisualStyle; + elementsById?: Map; + renderMode?: RenderMode; + } = {} +): SVGSVGElement { + if (options.visualStyle === "pythonTutor") { + const svg = createPythonTutorBoxRenderer(element, { + elementsById: options.elementsById, + renderMode: options.renderMode, + }); + + if (element.color) { + applyErrorStyling(svg, element.color); + } + + return svg; + } + const { MemoryModel } = MemoryViz; const { kind, id, boxId } = element; const kindName = kind.name; diff --git a/frontend/src/features/canvas/utils/box.types.ts b/frontend/src/features/canvas/utils/box.types.ts index 03f0f6e..41c22cf 100644 --- a/frontend/src/features/canvas/utils/box.types.ts +++ b/frontend/src/features/canvas/utils/box.types.ts @@ -1,4 +1,9 @@ -import { CanvasElement, ID } from "../../shared/types"; +import { + CanvasElement, + ID, + RenderMode, + VisualStyle, +} from "../../shared/types"; /** * Core props for the CanvasBox component @@ -24,6 +29,15 @@ export interface CanvasBoxProps { /** Actual rendered width of the call stack (x + columnWidth), used for constraints */ callStackWidth?: number; + + /** Active visual style for rendering */ + visualStyle?: VisualStyle; + + /** Lookup map for resolving ID references in alternate renderers */ + elementsById?: Map; + + /** Renderer mode for size/layout differences */ + renderMode?: RenderMode; } /** diff --git a/frontend/src/features/canvas/utils/pythonTutorReferences.ts b/frontend/src/features/canvas/utils/pythonTutorReferences.ts new file mode 100644 index 0000000..6e65ef0 --- /dev/null +++ b/frontend/src/features/canvas/utils/pythonTutorReferences.ts @@ -0,0 +1,329 @@ +import { + BoxTypeName, + CanvasElement, + PrimitiveKind, + RenderMode, +} from "../../shared/types"; + +type ReferenceTarget = number | string | null | "_" | undefined; + +export interface PythonTutorReferenceDisplay { + kind: "blank" | "primitive" | "reference" | "unknown"; + label: string; + targetId: number | null; +} + +function normalizeNumericId(value: ReferenceTarget): number | null { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + + if (typeof value === "string" && /^\d+$/.test(value)) { + return parseInt(value, 10); + } + + return null; +} + +function createElement( + boxId: number, + id: number | "_", + kind: CanvasElement["kind"] +): CanvasElement { + return { + boxId, + id, + kind, + x: 0, + y: 0, + }; +} + +function collectReferencedIds(elements: CanvasElement[]): Set { + const referencedIds = new Set(); + + const addReference = (target: ReferenceTarget) => { + const numericId = normalizeNumericId(target); + if (numericId !== null) { + referencedIds.add(numericId); + } + }; + + elements.forEach((element) => { + switch (element.kind.name) { + case "function": + element.kind.params.forEach((param) => addReference(param.targetId)); + break; + case "class": + element.kind.classVariables.forEach((variable) => + addReference(variable.targetId) + ); + break; + case "list": + case "tuple": + case "set": + element.kind.value.forEach((value) => addReference(value)); + break; + case "dict": + Object.entries(element.kind.value).forEach(([key, value]) => { + addReference(key); + addReference(value); + }); + break; + default: + break; + } + }); + + return referencedIds; +} + +export function getPythonTutorFrameTitle(functionName: string): string { + return functionName === "__main__" ? "Global frame" : functionName || "Frame"; +} + +export function isPrimitiveElement( + element: CanvasElement | null | undefined +): element is CanvasElement & { kind: PrimitiveKind } { + return element?.kind.name === "primitive"; +} + +export function formatPrimitiveValue(kind: PrimitiveKind): string { + if (kind.type === "NoneType") { + return "None"; + } + + if (kind.type === "bool") { + return kind.value === "true" ? "True" : "False"; + } + + if (kind.type === "str") { + return JSON.stringify(kind.value ?? ""); + } + + return `${kind.value ?? ""}`; +} + +export function resolveInlineDisplay( + targetId: ReferenceTarget, + elementsById?: Map +): PythonTutorReferenceDisplay { + const numericId = normalizeNumericId(targetId); + + if (numericId === null) { + return { + kind: "blank", + label: "", + targetId: null, + }; + } + + const target = elementsById?.get(numericId); + if (!target) { + return { + kind: "unknown", + label: `unknown id${numericId}`, + targetId: numericId, + }; + } + + if (isPrimitiveElement(target)) { + return { + kind: "primitive", + label: formatPrimitiveValue(target.kind), + targetId: numericId, + }; + } + + return { + kind: "reference", + label: `id${numericId}`, + targetId: numericId, + }; +} + +export function getHiddenPrimitiveIds(elements: CanvasElement[]): Set { + const elementsById = createElementsByIdMap(elements); + const referencedIds = collectReferencedIds(elements); + const hiddenPrimitiveIds = new Set(); + + referencedIds.forEach((id) => { + if (isPrimitiveElement(elementsById.get(id))) { + hiddenPrimitiveIds.add(id); + } + }); + + return hiddenPrimitiveIds; +} + +export function getPythonTutorDisplayType( + element: CanvasElement | null | undefined +): string { + if (!element) return "unknown"; + + switch (element.kind.name) { + case "primitive": + return element.kind.type === "NoneType" ? "None" : element.kind.type; + case "list": + case "tuple": + case "set": + case "dict": + return element.kind.type; + case "class": + return element.kind.className || "object"; + case "function": + return "function"; + default: + return "unknown"; + } +} + +export function createElementsByIdMap( + elements: CanvasElement[] +): Map { + return new Map( + elements + .filter((element): element is CanvasElement & { id: number } => + typeof element.id === "number" + ) + .map((element) => [element.id, element]) + ); +} + +export function createPythonTutorPalettePreview( + boxType: BoxTypeName, + renderMode: RenderMode = "palette" +): { + element: CanvasElement; + elementsById: Map; + renderMode: RenderMode; +} { + const elementsById = new Map(); + + switch (boxType) { + case "function": + return { + element: createElement(1, "_", { + name: "function", + type: "function", + value: null, + functionName: "NoFunction", + params: [], + }), + elementsById, + renderMode, + }; + case "class": + return { + element: createElement(2, 1, { + name: "class", + type: "class", + value: null, + className: "NoClass", + classVariables: [], + }), + elementsById, + renderMode, + }; + case "none": + return { + element: createElement(3, 1, { + name: "primitive", + type: "NoneType", + value: "None", + }), + elementsById, + renderMode, + }; + case "int": + return { + element: createElement(4, 1, { + name: "primitive", + type: "int", + value: "0", + }), + elementsById, + renderMode, + }; + case "float": + return { + element: createElement(5, 1, { + name: "primitive", + type: "float", + value: "0.0", + }), + elementsById, + renderMode, + }; + case "str": + return { + element: createElement(6, 1, { + name: "primitive", + type: "str", + value: "hello", + }), + elementsById, + renderMode, + }; + case "bool": + return { + element: createElement(7, 1, { + name: "primitive", + type: "bool", + value: "false", + }), + elementsById, + renderMode, + }; + case "list": + return { + element: createElement(8, 1, { + name: "list", + type: "list", + value: [], + }), + elementsById, + renderMode, + }; + case "tuple": + return { + element: createElement(9, 1, { + name: "tuple", + type: "tuple", + value: [], + }), + elementsById, + renderMode, + }; + case "set": + return { + element: createElement(10, 1, { + name: "set", + type: "set", + value: [], + }), + elementsById, + renderMode, + }; + case "dict": + return { + element: createElement(11, 1, { + name: "dict", + type: "dict", + value: {}, + }), + elementsById, + renderMode, + }; + default: + return { + element: createElement(12, 1, { + name: "primitive", + type: "int", + value: "0", + }), + elementsById, + renderMode, + }; + } +} diff --git a/frontend/src/features/canvas/utils/pythonTutorRenderer.ts b/frontend/src/features/canvas/utils/pythonTutorRenderer.ts new file mode 100644 index 0000000..464b34b --- /dev/null +++ b/frontend/src/features/canvas/utils/pythonTutorRenderer.ts @@ -0,0 +1,673 @@ +import { + CanvasElement, + ClassKind, + DictKind, + FunctionKind, + ListKind, + PrimitiveKind, + RenderMode, + SetKind, + TupleKind, +} from "../../shared/types"; +import { + formatPrimitiveValue, + getPythonTutorDisplayType, + getPythonTutorFrameTitle, + resolveInlineDisplay, + PythonTutorReferenceDisplay, +} from "./pythonTutorReferences"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const FONT_FAMILY = 'Consolas, "Courier New", monospace'; +const LABEL_Y = 10; +const OBJECT_TOP = 20; +const PADDING_X = 10; +const ROW_HEIGHT = 30; +const SLOT_HEIGHT = 28; +const CELL_HEIGHT = 56; +const CELL_MIN_WIDTH = 52; +const VALUE_FONT_SIZE = 16; +const LABEL_FONT_SIZE = 12; +const INDEX_FONT_SIZE = 11; + +const COLORS = { + objectFill: "var(--python-tutor-box-bg)", + border: "var(--python-tutor-border)", + labelText: "var(--python-tutor-header-text)", + bodyText: "var(--python-tutor-body-text)", + referenceText: "var(--python-tutor-reference-text)", + frameLine: "var(--python-tutor-frame-line)", +}; + +interface RenderContext { + elementsById?: Map; + renderMode?: RenderMode; +} + +interface KeyValueRow { + left: string; + right: PythonTutorReferenceDisplay; +} + +function createSvg(width: number, height: number): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("xmlns", SVG_NS); + svg.setAttribute("width", `${width}`); + svg.setAttribute("height", `${height}`); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.style.overflow = "visible"; + svg.style.display = "block"; + return svg; +} + +function createNode( + tag: T +): SVGElementTagNameMap[T] { + return document.createElementNS(SVG_NS, tag); +} + +function appendRect( + svg: SVGSVGElement, + x: number, + y: number, + width: number, + height: number, + options: { + fill: string; + stroke?: string; + strokeWidth?: number; + rx?: number; + ry?: number; + opacity?: number; + } +): SVGRectElement { + const rect = createNode("rect"); + rect.setAttribute("x", `${x}`); + rect.setAttribute("y", `${y}`); + rect.setAttribute("width", `${width}`); + rect.setAttribute("height", `${height}`); + rect.setAttribute("rx", `${options.rx ?? 1}`); + rect.setAttribute("ry", `${options.ry ?? 1}`); + rect.style.fill = options.fill; + rect.style.stroke = options.stroke ?? "none"; + rect.style.strokeWidth = `${options.strokeWidth ?? 0}`; + if (typeof options.opacity === "number") { + rect.style.opacity = `${options.opacity}`; + } + svg.appendChild(rect); + return rect; +} + +function appendLine( + svg: SVGSVGElement, + x1: number, + y1: number, + x2: number, + y2: number, + color: string = COLORS.border, + strokeWidth: number = 1 +): void { + const line = createNode("line"); + line.setAttribute("x1", `${x1}`); + line.setAttribute("y1", `${y1}`); + line.setAttribute("x2", `${x2}`); + line.setAttribute("y2", `${y2}`); + line.style.stroke = color; + line.style.strokeWidth = `${strokeWidth}`; + svg.appendChild(line); +} + +function appendText( + svg: SVGSVGElement, + text: string, + x: number, + y: number, + options: { + fill?: string; + fontSize?: number; + fontWeight?: string; + anchor?: "start" | "middle" | "end"; + } = {} +): SVGTextElement { + const node = createNode("text"); + node.textContent = text; + node.setAttribute("x", `${x}`); + node.setAttribute("y", `${y}`); + node.setAttribute("text-anchor", options.anchor ?? "start"); + node.setAttribute("dominant-baseline", "middle"); + node.style.fontFamily = FONT_FAMILY; + node.style.fontSize = `${options.fontSize ?? 14}px`; + node.style.fontWeight = options.fontWeight ?? "400"; + node.style.fill = options.fill ?? COLORS.bodyText; + svg.appendChild(node); + return node; +} + +function appendBounds(svg: SVGSVGElement, width: number, height: number): void { + appendRect(svg, 0, 0, width, height, { + fill: "transparent", + strokeWidth: 0, + rx: 0, + ry: 0, + opacity: 0, + }); +} + +function appendReferenceHitRect( + svg: SVGSVGElement, + x: number, + y: number, + width: number, + height: number, + targetId: number | null +): void { + if (targetId === null) return; + + const rect = appendRect(svg, x, y, width, height, { + fill: "transparent", + strokeWidth: 0, + rx: 0, + ry: 0, + opacity: 0, + }); + rect.setAttribute("data-ref-target-id", `${targetId}`); +} + +function measureText(text: string, fontSize: number = 14): number { + return text.length * fontSize * 0.61; +} + +function getReferenceColor(display: PythonTutorReferenceDisplay): string { + return display.kind === "reference" || display.kind === "unknown" + ? COLORS.referenceText + : COLORS.bodyText; +} + +function isClickableReference(display: PythonTutorReferenceDisplay): boolean { + return display.kind === "reference"; +} + +function getIdLabel(element: CanvasElement): string { + return typeof element.id === "number" ? `id${element.id}` : ""; +} + +function getObjectLabel(title: string, idLabel: string): string { + return idLabel ? `${title} ${idLabel}` : title; +} + +function appendObjectLabel( + svg: SVGSVGElement, + title: string, + idLabel: string +): void { + appendText(svg, getObjectLabel(title, idLabel), 2, LABEL_Y, { + fill: COLORS.labelText, + fontSize: LABEL_FONT_SIZE, + }); +} + +function appendObjectShell( + svg: SVGSVGElement, + width: number, + height: number +): void { + appendRect(svg, 0, OBJECT_TOP, width, height, { + fill: COLORS.objectFill, + stroke: COLORS.border, + strokeWidth: 1.25, + rx: 1, + ry: 1, + }); +} + +function appendFrameBracket( + svg: SVGSVGElement, + x: number, + y: number, + height: number +): void { + appendLine(svg, x, y, x, y + height, COLORS.frameLine, 1); + appendLine(svg, x, y, x + 10, y, COLORS.frameLine, 1); + appendLine(svg, x, y + height, x + 10, y + height, COLORS.frameLine, 1); +} + +function appendFrameSlot( + svg: SVGSVGElement, + display: PythonTutorReferenceDisplay, + x: number, + y: number, + width: number, + height: number +): void { + appendFrameBracket(svg, x, y, height); + + if (!display.label) { + return; + } + + appendText(svg, display.label, x + 16, y + height / 2, { + fill: getReferenceColor(display), + fontSize: VALUE_FONT_SIZE, + }); + + if (!isClickableReference(display)) { + return; + } + + const textWidth = measureText(display.label, VALUE_FONT_SIZE); + appendReferenceHitRect( + svg, + x, + y - 2, + Math.max(width, textWidth + 18), + height + 4, + display.targetId + ); +} + +function renderFrameBox( + element: CanvasElement & { kind: FunctionKind }, + context: RenderContext +): SVGSVGElement { + const rows = (element.kind.params || []).map((param) => ({ + left: param.name, + right: resolveInlineDisplay(param.targetId, context.elementsById), + })); + const bodyRows = + rows.length > 0 + ? rows + : [{ left: "", right: resolveInlineDisplay(null, context.elementsById) }]; + const title = getPythonTutorFrameTitle(element.kind.functionName); + const leftWidth = Math.max( + 44, + ...bodyRows.map((row) => measureText(row.left || "", 17)) + ); + const rightWidth = Math.max( + 46, + ...bodyRows.map((row) => measureText(row.right.label, VALUE_FONT_SIZE)) + ); + const slotWidth = Math.max(44, rightWidth + 18); + const width = Math.max( + context.renderMode === "palette" ? 172 : 150, + measureText(title, 18) + 8, + leftWidth + slotWidth + 34 + ); + const height = 28 + bodyRows.length * 40; + const svg = createSvg(width, height); + + appendBounds(svg, width, height); + appendText(svg, title, 0, 14, { + fill: COLORS.bodyText, + fontSize: 18, + }); + + bodyRows.forEach((row, index) => { + const rowTop = 28 + index * 40; + const slotX = leftWidth + 18; + + appendText(svg, row.left, 0, rowTop + 18, { + fill: COLORS.bodyText, + fontSize: 16, + }); + appendFrameSlot(svg, row.right, slotX, rowTop + 4, slotWidth, SLOT_HEIGHT); + }); + + return svg; +} + +function renderPrimitiveBox(element: CanvasElement): SVGSVGElement { + const primitiveKind = element.kind as PrimitiveKind; + const title = getPythonTutorDisplayType(element); + const idLabel = getIdLabel(element); + const label = getObjectLabel(title, idLabel); + const value = formatPrimitiveValue(primitiveKind); + const width = Math.max( + 84, + measureText(label, LABEL_FONT_SIZE) + 8, + measureText(value, 18) + 26 + ); + const height = OBJECT_TOP + 44; + const svg = createSvg(width, height); + + appendBounds(svg, width, height); + appendObjectLabel(svg, title, idLabel); + appendObjectShell(svg, width, 44); + appendText(svg, value, width / 2, OBJECT_TOP + 22, { + anchor: "middle", + fill: COLORS.bodyText, + fontSize: 18, + }); + + return svg; +} + +function renderSequenceBox( + element: CanvasElement & { kind: ListKind | TupleKind | SetKind }, + context: RenderContext +): SVGSVGElement { + const values = Array.isArray(element.kind.value) ? element.kind.value : []; + const displays = values.map((value) => + resolveInlineDisplay(value, context.elementsById) + ); + const label = getObjectLabel( + getPythonTutorDisplayType(element), + getIdLabel(element) + ); + const hasIndexes = + element.kind.name === "list" || element.kind.name === "tuple"; + const cellWidths = displays.map((display) => + Math.max(CELL_MIN_WIDTH, measureText(display.label, VALUE_FONT_SIZE) + 22) + ); + const bodyWidth = + cellWidths.length > 0 + ? cellWidths.reduce((sum, cellWidth) => sum + cellWidth, 0) + : 72; + const width = Math.max( + context.renderMode === "palette" ? 150 : 130, + bodyWidth, + measureText(label, LABEL_FONT_SIZE) + 8 + ); + const height = OBJECT_TOP + CELL_HEIGHT; + const svg = createSvg(width, height); + + appendBounds(svg, width, height); + appendObjectLabel(svg, getPythonTutorDisplayType(element), getIdLabel(element)); + appendObjectShell(svg, width, CELL_HEIGHT); + + if (displays.length === 0) { + return svg; + } + + let currentX = 0; + displays.forEach((display, index) => { + const cellWidth = cellWidths[index]; + + if (index > 0) { + appendLine(svg, currentX, OBJECT_TOP, currentX, OBJECT_TOP + CELL_HEIGHT); + } + + if (hasIndexes) { + appendText(svg, `${index}`, currentX + 7, OBJECT_TOP + 10, { + fill: COLORS.labelText, + fontSize: INDEX_FONT_SIZE, + }); + } + + appendText( + svg, + display.label, + currentX + cellWidth / 2, + OBJECT_TOP + 32, + { + anchor: "middle", + fill: getReferenceColor(display), + fontSize: VALUE_FONT_SIZE, + } + ); + + if (isClickableReference(display)) { + appendReferenceHitRect( + svg, + currentX, + OBJECT_TOP, + cellWidth, + CELL_HEIGHT, + display.targetId + ); + } + + currentX += cellWidth; + }); + + return svg; +} + +function renderDictBox( + element: CanvasElement & { kind: DictKind }, + context: RenderContext +): SVGSVGElement { + const rows = Object.entries(element.kind.value || {}).map(([key, value]) => ({ + left: resolveInlineDisplay(key, context.elementsById), + right: resolveInlineDisplay(value, context.elementsById), + })); + const bodyRows = + rows.length > 0 + ? rows + : [ + { + left: resolveInlineDisplay(null, context.elementsById), + right: resolveInlineDisplay(null, context.elementsById), + }, + ]; + const label = getObjectLabel("dict", getIdLabel(element)); + const leftWidth = Math.max( + 50, + ...bodyRows.map((row) => measureText(row.left.label, 14) + 12) + ); + const rightWidth = Math.max( + 50, + ...bodyRows.map((row) => measureText(row.right.label, 14) + 12) + ); + const dividerGap = 12; + const width = Math.max( + context.renderMode === "palette" ? 156 : 136, + leftWidth + rightWidth + dividerGap, + measureText(label, LABEL_FONT_SIZE) + 8 + ); + const height = OBJECT_TOP + Math.max(1, bodyRows.length) * ROW_HEIGHT; + const leftColumnWidth = Math.max(50, (width - dividerGap) / 2); + const rightStart = leftColumnWidth + dividerGap; + const rightColumnWidth = width - rightStart; + const dividerX = leftColumnWidth + dividerGap / 2; + const svg = createSvg(width, height); + + appendBounds(svg, width, height); + appendObjectLabel(svg, "dict", getIdLabel(element)); + appendObjectShell(svg, width, Math.max(1, bodyRows.length) * ROW_HEIGHT); + appendLine(svg, dividerX, OBJECT_TOP, dividerX, height); + + bodyRows.forEach((row, index) => { + const rowTop = OBJECT_TOP + index * ROW_HEIGHT; + if (index > 0) { + appendLine(svg, 0, rowTop, width, rowTop); + } + + appendText( + svg, + row.left.label, + leftColumnWidth / 2, + rowTop + ROW_HEIGHT / 2, + { + anchor: "middle", + fill: getReferenceColor(row.left), + fontSize: 14, + } + ); + appendText( + svg, + row.right.label, + rightStart + rightColumnWidth / 2, + rowTop + ROW_HEIGHT / 2, + { + anchor: "middle", + fill: getReferenceColor(row.right), + fontSize: 14, + } + ); + + if (isClickableReference(row.left)) { + appendReferenceHitRect( + svg, + 0, + rowTop, + leftColumnWidth, + ROW_HEIGHT, + row.left.targetId + ); + } + + if (isClickableReference(row.right)) { + appendReferenceHitRect( + svg, + rightStart, + rowTop, + rightColumnWidth, + ROW_HEIGHT, + row.right.targetId + ); + } + }); + + return svg; +} + +function renderKeyValueObjectBox( + title: string, + idLabel: string, + rows: KeyValueRow[], + renderMode: RenderMode = "canvas" +): SVGSVGElement { + const bodyRows = + rows.length > 0 + ? rows + : [{ left: "", right: resolveInlineDisplay(null) }]; + const label = getObjectLabel(title, idLabel); + const leftWidth = Math.max( + 52, + ...bodyRows.map((row) => measureText(row.left, 14) + 10) + ); + const rightWidth = Math.max( + 58, + ...bodyRows.map((row) => measureText(row.right.label, 14) + 14) + ); + const width = Math.max( + renderMode === "palette" ? 170 : 144, + leftWidth + rightWidth + PADDING_X * 2, + measureText(label, LABEL_FONT_SIZE) + 8 + ); + const height = OBJECT_TOP + Math.max(1, bodyRows.length) * ROW_HEIGHT; + const dividerX = Math.max(PADDING_X + leftWidth, width * 0.45); + const svg = createSvg(width, height); + + appendBounds(svg, width, height); + appendObjectLabel(svg, title, idLabel); + appendObjectShell(svg, width, Math.max(1, bodyRows.length) * ROW_HEIGHT); + appendLine(svg, dividerX, OBJECT_TOP, dividerX, height); + + bodyRows.forEach((row, index) => { + const rowTop = OBJECT_TOP + index * ROW_HEIGHT; + if (index > 0) { + appendLine(svg, 0, rowTop, width, rowTop); + } + + appendText(svg, row.left, PADDING_X, rowTop + ROW_HEIGHT / 2, { + fill: COLORS.bodyText, + fontSize: 14, + }); + appendText( + svg, + row.right.label, + dividerX + 8, + rowTop + ROW_HEIGHT / 2, + { + fill: getReferenceColor(row.right), + fontSize: 14, + } + ); + + if (isClickableReference(row.right)) { + appendReferenceHitRect( + svg, + dividerX, + rowTop, + width - dividerX, + ROW_HEIGHT, + row.right.targetId + ); + } + }); + + return svg; +} + +function renderClassBox( + element: CanvasElement & { kind: ClassKind }, + context: RenderContext +): SVGSVGElement { + const rows = (element.kind.classVariables || []).map((variable) => ({ + left: variable.name, + right: resolveInlineDisplay(variable.targetId, context.elementsById), + })); + + return renderKeyValueObjectBox( + element.kind.className || "object", + getIdLabel(element), + rows, + context.renderMode + ); +} + +function getFunctionSignature(element: CanvasElement & { kind: FunctionKind }): string { + const name = element.kind.functionName || "function"; + const params = (element.kind.params || []) + .map((param) => param.name || "_") + .join(", "); + return `${name}(${params})`; +} + +function renderFunctionObjectBox( + element: CanvasElement & { kind: FunctionKind }, + context: RenderContext +): SVGSVGElement { + const label = getObjectLabel("function", getIdLabel(element)); + const signature = getFunctionSignature(element); + const width = Math.max( + context.renderMode === "palette" ? 150 : 132, + measureText(label, LABEL_FONT_SIZE) + 8, + measureText(signature, 18) + 8 + ); + const height = OBJECT_TOP + 24; + const svg = createSvg(width, height); + + appendBounds(svg, width, height); + appendText(svg, label, 2, LABEL_Y, { + fill: COLORS.labelText, + fontSize: LABEL_FONT_SIZE, + }); + appendText(svg, signature, 0, OBJECT_TOP + 14, { + fill: COLORS.bodyText, + fontSize: 18, + }); + + return svg; +} + +export function createPythonTutorBoxRenderer( + element: CanvasElement, + context: RenderContext = {} +): SVGSVGElement { + switch (element.kind.name) { + case "primitive": + return renderPrimitiveBox(element); + case "function": + return typeof element.id === "number" + ? renderFunctionObjectBox( + element as CanvasElement & { kind: FunctionKind }, + context + ) + : renderFrameBox(element as CanvasElement & { kind: FunctionKind }, context); + case "list": + case "tuple": + case "set": + return renderSequenceBox( + element as CanvasElement & { kind: ListKind | TupleKind | SetKind }, + context + ); + case "dict": + return renderDictBox(element as CanvasElement & { kind: DictKind }, context); + case "class": + return renderClassBox(element as CanvasElement & { kind: ClassKind }, context); + default: + return renderPrimitiveBox(element); + } +} diff --git a/frontend/src/features/canvasControls/CanvasControls.tsx b/frontend/src/features/canvasControls/CanvasControls.tsx index 305ea51..3dec321 100644 --- a/frontend/src/features/canvasControls/CanvasControls.tsx +++ b/frontend/src/features/canvasControls/CanvasControls.tsx @@ -4,7 +4,7 @@ */ import React, { useState } from "react"; -import { CanvasElement } from "../shared/types"; +import { CanvasElement, VisualStyle } from "../shared/types"; import { ClearCanvasButton, DownloadButton, ZoomControls, UndoButton, RedoButton, FeedbackButton } from "../canvas/components/CanvasButtons"; import { useTheme } from "../../contexts/ThemeContext"; import styles from "./CanvasControls.module.css"; @@ -22,6 +22,8 @@ interface CanvasControlsProps { onScaleChange?: (scale: number) => void; editorScale?: number; onEditorScaleChange?: (scale: number) => void; + visualStyle?: VisualStyle; + onVisualStyleChange?: (style: VisualStyle) => void; } type ControlTab = "actions" | "view" | "settings"; @@ -61,6 +63,8 @@ export default function CanvasControls({ onScaleChange, editorScale = 1, onEditorScaleChange, + visualStyle = "memoryviz", + onVisualStyleChange, }: CanvasControlsProps) { const { theme, toggleTheme } = useTheme(); const isDarkMode = theme === 'dark'; @@ -172,6 +176,35 @@ export default function CanvasControls({ + {onVisualStyleChange && ( +
+ + +
+ )} +
diff --git a/frontend/src/features/editors/Editor.module.css b/frontend/src/features/editors/Editor.module.css index 10a152b..136120a 100644 --- a/frontend/src/features/editors/Editor.module.css +++ b/frontend/src/features/editors/Editor.module.css @@ -174,7 +174,9 @@ .classNameInput, .functionNameInput, -.primitiveValueContainer { +.primitiveValueContainer, +.inlineTargetModeSelect, +.inlineTargetSelect { width: 100%; font-size: 0.95rem; padding: 8px 12px; @@ -188,12 +190,28 @@ .classNameInput:focus, .functionNameInput:focus, -.primitiveValueContainer:focus { +.primitiveValueContainer:focus, +.inlineTargetModeSelect:focus, +.inlineTargetSelect:focus { outline: 2px solid var(--border-focus); outline-offset: 1px; border-color: transparent; } +.inlineTargetEditor { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 220px; +} + +.inlineTargetBody { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + .variableNameBox { flex: 1; font-size: 0.95rem; diff --git a/frontend/src/features/editors/boxEditor/BoxEditor.tsx b/frontend/src/features/editors/boxEditor/BoxEditor.tsx index f04c427..fbf6ecd 100644 --- a/frontend/src/features/editors/boxEditor/BoxEditor.tsx +++ b/frontend/src/features/editors/boxEditor/BoxEditor.tsx @@ -41,6 +41,8 @@ const BoxEditorModule = ({ sandbox = true, elements = [], questionFunctionNames, + visualStyle = "memoryviz", + onElementsChange, }: BoxEditorType) => { // Shared hover state for remove button const { hoverRemove, setHoverRemove } = useGlobalStates(); @@ -85,6 +87,10 @@ const BoxEditorModule = ({ const collectionData = metadata.kind.name === "dict" ? collectionPairs : collectionItems; + const commitElementKind = (kind: typeof metadata.kind) => { + onSave(ownId, kind, invalidated); + }; + // Hook to sync the module and apply save logic when clicking outside useModule( onSave, @@ -134,12 +140,14 @@ const BoxEditorModule = ({ dataType={dataType} value={contentValue} setValue={setContentValue} + functionName={functionName} functionParams={functionParams} setFunctionParams={setFunctionParams} collectionItems={collectionItems} setCollectionItems={setCollectionItems} collectionPairs={collectionPairs} setCollectionPairs={setCollectionPairs} + className={ownClassName} ownClassVariables={ownClassVariables} setOwnClassVariables={setOwnClassVariables} ids={ids} @@ -147,6 +155,9 @@ const BoxEditorModule = ({ removeId={removeId} sandbox={sandbox} elements={elements} + visualStyle={visualStyle} + onCommitKind={commitElementKind} + onElementsChange={onElementsChange} /> diff --git a/frontend/src/features/editors/boxEditor/Content.tsx b/frontend/src/features/editors/boxEditor/Content.tsx index 8e53215..500077c 100644 --- a/frontend/src/features/editors/boxEditor/Content.tsx +++ b/frontend/src/features/editors/boxEditor/Content.tsx @@ -2,7 +2,8 @@ import PrimitiveContent from "./primitiveBoxes/PrimitiveContent"; import FunctionContent from "./functionBoxes/FunctionContent"; import CollectionContent from "./collectionBoxes/CollectionContent"; import ClassContent from "./classBoxes/ClassContent"; -import { ID } from "../../shared/types"; +import type { Dispatch, SetStateAction } from "react"; +import { BoxType, ID, VisualStyle } from "../../shared/types"; /** * Props for the Content component. @@ -12,12 +13,14 @@ interface Props { dataType: any; // The selected data type (used for primitive types) value: string; // The value for primitive types setValue: any; // Setter for primitive value + functionName: string; functionParams: any; // Parameter list for function boxes setFunctionParams: any; // Setter for function parameters collectionItems: any; // List/set/tuple items setCollectionItems: any; // Setter for collection items collectionPairs: any; // Key-value pairs for dicts setCollectionPairs: any; // Setter for dict pairs + className: string; ids: any; addId: (id: ID) => void; removeId: (id: ID) => void; @@ -25,6 +28,9 @@ interface Props { setOwnClassVariables: any; sandbox: boolean; elements?: any[]; // All canvas elements for ID usage tracking + visualStyle?: VisualStyle; + onCommitKind?: (kind: BoxType) => void; + onElementsChange?: Dispatch>; } /** @@ -41,12 +47,14 @@ const Content = ({ dataType, value, setValue, + functionName, functionParams, setFunctionParams, collectionItems, setCollectionItems, collectionPairs, setCollectionPairs, + className, ownClassVariables, setOwnClassVariables, ids, @@ -54,6 +62,9 @@ const Content = ({ removeId, sandbox, elements = [], + visualStyle = "memoryviz", + onCommitKind, + onElementsChange, }: Props) => { const kind = metadata.kind.name; @@ -68,12 +79,17 @@ const Content = ({ ); } @@ -88,8 +104,12 @@ const Content = ({ addId={addId} removeId={removeId} sandbox={sandbox} - validationErrors={metadata.validationErrors} + validationErrors={metadata.errors} elements={elements} + ownerElement={metadata} + visualStyle={visualStyle} + onCommitKind={onCommitKind} + onElementsChange={onElementsChange} /> ); } @@ -104,8 +124,12 @@ const Content = ({ addId={addId} removeId={removeId} sandbox={sandbox} - validationErrors={metadata.validationErrors} + validationErrors={metadata.errors} elements={elements} + ownerElement={metadata} + visualStyle={visualStyle} + onCommitKind={onCommitKind} + onElementsChange={onElementsChange} /> ); } @@ -115,12 +139,17 @@ const Content = ({ ); } diff --git a/frontend/src/features/editors/boxEditor/InlineTargetEditor.tsx b/frontend/src/features/editors/boxEditor/InlineTargetEditor.tsx new file mode 100644 index 0000000..ba1697b --- /dev/null +++ b/frontend/src/features/editors/boxEditor/InlineTargetEditor.tsx @@ -0,0 +1,218 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + CanvasElement, + ElementError, + ID, + PrimitiveType, +} from "../../shared/types"; +import PrimitiveContent from "./primitiveBoxes/PrimitiveContent"; +import styles from "../Editor.module.css"; +import FieldValidationTooltip from "./FieldValidationTooltip"; +import { getErrorsForId, isIdInvalid } from "../utils/validationHelpers"; +import { + createDefaultPrimitiveKind, + getReferenceOptionLabel, + getReferenceableElements, + normalizeInlineTarget, + upsertInlinePrimitive, + InlineTargetValue, +} from "../utils/pythonTutorInlinePrimitives"; +import { + createElementsByIdMap, + isPrimitiveElement, +} from "../../canvas/utils/pythonTutorReferences"; + +interface InlineTargetEditorProps { + ownerElement: CanvasElement; + currentTarget: InlineTargetValue; + blankValue?: ID | string; + onTargetChange: (nextTarget: ID | string) => void; + addId: (id: ID) => void; + elements: CanvasElement[]; + onElementsChange?: React.Dispatch>; + validationErrors?: ElementError[]; +} + +const BLANK_OPTION = "__blank__"; + +export default function InlineTargetEditor({ + ownerElement, + currentTarget, + blankValue = "_", + onTargetChange, + addId, + elements, + onElementsChange, + validationErrors, +}: InlineTargetEditorProps) { + const elementsById = useMemo(() => createElementsByIdMap(elements), [elements]); + const currentNumericTarget = normalizeInlineTarget(currentTarget); + const currentTargetElement = + currentNumericTarget !== null ? elementsById.get(currentNumericTarget) : undefined; + const currentPrimitiveTarget = + currentTargetElement && isPrimitiveElement(currentTargetElement) + ? currentTargetElement + : null; + const [mode, setMode] = useState<"reference" | "primitive">( + currentPrimitiveTarget ? "primitive" : "reference" + ); + const [primitiveType, setPrimitiveType] = useState( + currentPrimitiveTarget?.kind.type ?? "int" + ); + const [primitiveValue, setPrimitiveValue] = useState( + currentPrimitiveTarget?.kind.value ?? "0" + ); + + const referenceableElements = useMemo( + () => getReferenceableElements(elements), + [elements] + ); + const hasExplicitReferenceOption = + currentNumericTarget !== null && + referenceableElements.some((element) => element.id === currentNumericTarget); + const hasReferenceError = isIdInvalid(validationErrors, currentNumericTarget); + const referenceErrors = getErrorsForId(validationErrors, currentNumericTarget); + + useEffect(() => { + if (!currentPrimitiveTarget) { + return; + } + + setPrimitiveType(currentPrimitiveTarget.kind.type); + setPrimitiveValue(currentPrimitiveTarget.kind.value); + }, [currentPrimitiveTarget]); + + const applyPrimitiveKind = (type: PrimitiveType, value: string) => { + if (!onElementsChange) return; + + const primitiveKind = { + name: "primitive" as const, + type, + value, + }; + const nextPrimitiveState = upsertInlinePrimitive({ + elements, + ownerElement, + currentTarget, + primitiveKind, + }); + + if (nextPrimitiveState.targetId !== currentNumericTarget) { + onTargetChange(nextPrimitiveState.targetId); + } + if (nextPrimitiveState.created) { + addId(nextPrimitiveState.targetId); + } + + onElementsChange((prevElements) => { + const liveOwnerElement = + prevElements.find((element) => element.boxId === ownerElement.boxId) ?? + ownerElement; + + return upsertInlinePrimitive({ + elements: prevElements, + ownerElement: liveOwnerElement, + currentTarget, + primitiveKind, + }).elements; + }); + }; + + const handleModeSelectChange = ( + event: React.ChangeEvent + ) => { + const nextValue = event.target.value as PrimitiveType | "reference"; + + if (nextValue === "reference") { + setMode("reference"); + if (currentPrimitiveTarget) { + onTargetChange(blankValue); + } + return; + } + + const defaultKind = createDefaultPrimitiveKind(nextValue); + setMode("primitive"); + setPrimitiveType(defaultKind.type); + setPrimitiveValue(defaultKind.value); + applyPrimitiveKind(defaultKind.type, defaultKind.value); + }; + + const handlePrimitiveValueChange: React.Dispatch< + React.SetStateAction + > = (nextValue) => { + const resolvedValue = + typeof nextValue === "function" ? nextValue(primitiveValue) : nextValue; + + setPrimitiveValue(resolvedValue); + applyPrimitiveKind(primitiveType, resolvedValue); + }; + + const handleReferenceChange = (event: React.ChangeEvent) => { + const nextValue = event.target.value; + + if (nextValue === BLANK_OPTION) { + onTargetChange(blankValue); + return; + } + + onTargetChange(parseInt(nextValue, 10)); + }; + + const referenceSelect = ( + + ); + + return ( +
+ + +
+ {mode === "primitive" ? ( + + ) : ( + + {referenceSelect} + + )} +
+
+ ); +} diff --git a/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx b/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx index 35e10b5..5fd3312 100644 --- a/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx +++ b/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx @@ -1,8 +1,16 @@ import styles from "../../Editor.module.css"; -import { ID, ElementError } from "../../../shared/types"; +import type { Dispatch, SetStateAction } from "react"; +import { + BoxType, + CanvasElement, + ID, + ElementError, + VisualStyle, +} from "../../../shared/types"; import IdSelector from "../../idEditor/IdEditor"; import { isIdInvalid, getErrorsForId } from "../../utils/validationHelpers"; import FieldValidationTooltip from "../FieldValidationTooltip"; +import InlineTargetEditor from "../InlineTargetEditor"; /** * Props for the ClassContent component. @@ -10,12 +18,17 @@ import FieldValidationTooltip from "../FieldValidationTooltip"; interface Props { classVariables: any; // Array of variable objects for the class setVariables: any; // Setter to update the list of variables + className: string; ids: any; addId: (id: ID) => void; removeId: (id: ID) => void; sandbox: boolean; validationErrors?: ElementError[]; // Validation errors for highlighting elements?: any[]; // All canvas elements for ID usage tracking + ownerElement: CanvasElement; + visualStyle?: VisualStyle; + onCommitKind?: (kind: BoxType) => void; + onElementsChange?: Dispatch>; } /** @@ -31,12 +44,17 @@ interface Props { const ClassContent = ({ classVariables, setVariables, + className, ids, addId, removeId, sandbox, validationErrors, elements = [], + ownerElement, + visualStyle = "memoryviz", + onCommitKind, + onElementsChange, }: Props) => { // Add a new empty variable to the list const addVariable = () => @@ -56,11 +74,19 @@ const ClassContent = ({ // Update the targetId of a variable at a given index const setTargetId = (i: number, id: ID) => - setVariables( - classVariables.map((v: any, idx: any) => + { + const nextVariables = classVariables.map((v: any, idx: any) => idx === i ? { ...v, targetId: id } : v - ) - ); + ); + setVariables(nextVariables); + onCommitKind?.({ + name: "class", + type: "class", + value: null, + className, + classVariables: nextVariables, + }); + }; return (
@@ -78,21 +104,33 @@ const ClassContent = ({ className={styles.variableNameBox} />
- - setTargetId(idx, id)} - onRemove={removeId} - buttonClassName={`${styles.collectionIdBox} ${ - hasError ? styles.errorId : "" - }`} - sandbox={sandbox} - editable={true} + {visualStyle === "pythonTutor" ? ( + setTargetId(idx, id as ID)} + addId={addId} elements={elements} + onElementsChange={onElementsChange} + validationErrors={fieldErrors} /> - + ) : ( + + setTargetId(idx, id)} + onRemove={removeId} + buttonClassName={`${styles.collectionIdBox} ${ + hasError ? styles.errorId : "" + }`} + sandbox={sandbox} + editable={true} + elements={elements} + /> + + )}
@@ -514,6 +517,7 @@ export default function MemoryModelEditor({ scale={canvasScale} onScaleChange={setCanvasScale} editorScale={editorScale} + visualStyle={state.visualStyle} questionFunctionNames={ state.isSandboxMode && currentQuestionData ? getQuestionFunctionNames(currentQuestionData) diff --git a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts index f5d0d0b..b499921 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts @@ -19,6 +19,7 @@ export function useUILocalStorage(state: UIState): void { state.questionType, state.submissionResults, state.sandboxMode, + state.visualStyle, state.questionView, state.isInfoPanelOpen, state.canvasScale, diff --git a/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts b/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts index 1a50fc1..b95f8dc 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts @@ -4,6 +4,7 @@ import { SubmissionResult, Tab, PaletteTab, + VisualStyle, } from "../../shared/types"; import { loadInitialCanvasData, @@ -49,6 +50,9 @@ export function useMemoryModelEditorState(sandbox: boolean) { ? initialUIData.sandboxMode : sandbox ); + const [visualStyle, setVisualStyle] = useState( + initialUIData.visualStyle ?? "memoryviz" + ); const [questionView, setQuestionView] = useState( initialUIData.questionView ?? "root" ); @@ -101,6 +105,8 @@ export function useMemoryModelEditorState(sandbox: boolean) { setSubmissionResults, isSandboxMode, setIsSandboxMode, + visualStyle, + setVisualStyle, questionView, setQuestionView, tabScrollPositions, diff --git a/frontend/src/features/memoryModelEditor/utils/localStorage.ts b/frontend/src/features/memoryModelEditor/utils/localStorage.ts index 2f63868..a4a43c5 100644 --- a/frontend/src/features/memoryModelEditor/utils/localStorage.ts +++ b/frontend/src/features/memoryModelEditor/utils/localStorage.ts @@ -1,6 +1,11 @@ // Storage utility functions - pure functions for localStorage operations -import { CanvasElement, SubmissionResult, Tab } from "../../shared/types"; +import { + CanvasElement, + SubmissionResult, + Tab, + VisualStyle, +} from "../../shared/types"; // Storage keys const CANVAS_STORAGE_KEY = "canvas_key"; @@ -22,6 +27,7 @@ const DEFAULT_UI_STATE = { questionType: null as "test" | "practice" | "prep" | null, submissionResults: null as SubmissionResult | null, sandboxMode: null as boolean | null, + visualStyle: "memoryviz" as VisualStyle, }; export interface CanvasData { @@ -54,12 +60,17 @@ export interface UIState { questionType: "test" | "practice" | "prep" | null; submissionResults: SubmissionResult | null; sandboxMode: boolean | null; + visualStyle?: VisualStyle; questionView?: QuestionView; isInfoPanelOpen?: boolean; canvasScale?: number; editorScale?: number; } +function normalizeVisualStyle(rawStyle: unknown): VisualStyle { + return rawStyle === "pythonTutor" ? "pythonTutor" : "memoryviz"; +} + /** * Loads initial canvas data from localStorage * @returns Canvas data with elements, ids, and classes @@ -109,6 +120,8 @@ export function loadInitialUIData(): UIState { const sandboxMode = typeof parsed?.sandboxMode === "boolean" ? parsed.sandboxMode : null; + const visualStyle = normalizeVisualStyle(parsed?.visualStyle); + const validViews = ["root", "loading", "test", "list", "question", "practice", "prep"]; const questionView = typeof parsed?.questionView === "string" && validViews.includes(parsed.questionView) && parsed.questionView !== "loading" @@ -138,6 +151,7 @@ export function loadInitialUIData(): UIState { questionType, submissionResults, sandboxMode, + visualStyle, questionView, isInfoPanelOpen, canvasScale, diff --git a/frontend/src/features/palette/Palette.module.css b/frontend/src/features/palette/Palette.module.css index 488869e..9f12b34 100644 --- a/frontend/src/features/palette/Palette.module.css +++ b/frontend/src/features/palette/Palette.module.css @@ -215,6 +215,15 @@ gap: 16px; } +.emptyState { + margin: 0; + color: var(--text-tertiary); + font-size: 0.88rem; + line-height: 1.5; + text-align: center; + max-width: 220px; +} + /* Override memory-viz box background fills for light mode in palette */ :root[data-theme="light"] .paletteBoxes :global(svg g path[fill]:not([fill="none"])), .paletteBoxes :global(svg g path[fill]:not([fill="none"])) { diff --git a/frontend/src/features/palette/Palette.tsx b/frontend/src/features/palette/Palette.tsx index 34273f3..20cb7df 100644 --- a/frontend/src/features/palette/Palette.tsx +++ b/frontend/src/features/palette/Palette.tsx @@ -3,7 +3,12 @@ import PaletteBox from "./components/PaletteBox"; import CanvasControls from "../canvasControls/CanvasControls"; import { useResizable } from "./hooks/useResizable"; import styles from "./Palette.module.css"; -import { PaletteTab, BoxTypeName, CanvasElement } from "../shared/types"; +import { + PaletteTab, + BoxTypeName, + CanvasElement, + VisualStyle, +} from "../shared/types"; // Move constants here for better organization const ALL_TYPES: readonly BoxTypeName[] = [ @@ -67,6 +72,8 @@ interface PaletteProps { onScaleChange?: (scale: number) => void; editorScale?: number; onEditorScaleChange?: (scale: number) => void; + visualStyle?: VisualStyle; + onVisualStyleChange?: (style: VisualStyle) => void; } // Extract TabButton component inline @@ -97,6 +104,17 @@ function filterBoxesByRequired( return boxes.filter((boxType) => requiredBoxes.includes(boxType)); } +function filterBoxesByVisualStyle( + boxes: readonly BoxTypeName[], + visualStyle: VisualStyle +): BoxTypeName[] { + if (visualStyle !== "pythonTutor") { + return [...boxes]; + } + + return boxes.filter((boxType) => !PRIMITIVE_TYPES.includes(boxType)); +} + export default function Palette({ activeTab, setActive, @@ -114,6 +132,8 @@ export default function Palette({ onScaleChange, editorScale, onEditorScaleChange, + visualStyle = "memoryviz", + onVisualStyleChange, }: PaletteProps) { const allBoxes = TAB_BOX_MAPPING[activeTab]; @@ -121,6 +141,7 @@ export default function Palette({ isPracticeMode && requiredBoxes ? filterBoxesByRequired(allBoxes, requiredBoxes) : allBoxes; + const visibleBoxes = filterBoxesByVisualStyle(boxes, visualStyle); const { topHeight, handleMouseDown, containerRef } = useResizable({ initialTopPercent: 60, @@ -184,9 +205,21 @@ export default function Palette({ transition: 'transform 0.2s ease', }} > - {boxes.map((boxType) => ( - - ))} + {visibleBoxes.length > 0 ? ( + visibleBoxes.map((boxType) => ( + + )) + ) : ( +

+ {visualStyle === "pythonTutor" + ? "Primitive values are created inline in Python Tutor mode." + : "No boxes available in this tab."} +

+ )} @@ -218,6 +251,8 @@ export default function Palette({ onScaleChange={onScaleChange} editorScale={editorScale} onEditorScaleChange={onEditorScaleChange} + visualStyle={visualStyle} + onVisualStyleChange={onVisualStyleChange} /> diff --git a/frontend/src/features/palette/components/PaletteBox.tsx b/frontend/src/features/palette/components/PaletteBox.tsx index 95a335b..784b5af 100644 --- a/frontend/src/features/palette/components/PaletteBox.tsx +++ b/frontend/src/features/palette/components/PaletteBox.tsx @@ -1,10 +1,11 @@ import { useRef } from "react"; import { usePaletteBoxEffect } from "../hooks/usePalette"; -import { BoxTypeName } from "../../shared/types"; +import { BoxTypeName, VisualStyle } from "../../shared/types"; interface PaletteBoxProps { /** The type of box to render (e.g., "primitive", "list", "dict") */ boxType: BoxTypeName; + visualStyle: VisualStyle; } /** @@ -13,12 +14,12 @@ interface PaletteBoxProps { * preview using `usePaletteBoxEffect`, and supports drag-and-drop to * create a new box on the canvas. */ -export default function PaletteBox({ boxType }: PaletteBoxProps) { +export default function PaletteBox({ boxType, visualStyle }: PaletteBoxProps) { /** Ref to the div container that will hold the rendered SVG */ const containerRef = useRef(null); /** Custom hook to render the box preview inside the container */ - usePaletteBoxEffect(containerRef, boxType); + usePaletteBoxEffect(containerRef, boxType, visualStyle); /** Drag-and-drop: set box type on drag start */ const handleDragStart = (event: React.DragEvent) => { diff --git a/frontend/src/features/palette/hooks/usePalette.tsx b/frontend/src/features/palette/hooks/usePalette.tsx index 5f6a9f7..53be004 100644 --- a/frontend/src/features/palette/hooks/usePalette.tsx +++ b/frontend/src/features/palette/hooks/usePalette.tsx @@ -5,7 +5,7 @@ import { useEffect, RefObject } from "react"; import { createBoxRenderer } from "../utils/BoxRenderer"; -import { BoxTypeName } from "../../shared/types"; +import { BoxTypeName, VisualStyle } from "../../shared/types"; /** * Renders a static preview SVG for a palette box type. @@ -21,14 +21,15 @@ import { BoxTypeName } from "../../shared/types"; */ export const usePaletteBoxEffect = ( containerRef: RefObject, - boxType: BoxTypeName + boxType: BoxTypeName, + visualStyle: VisualStyle ) => { useEffect(() => { const container = containerRef.current; if (!container) return; try { - const svg = createBoxRenderer(boxType); + const svg = createBoxRenderer(boxType, visualStyle); container.innerHTML = ""; container.appendChild(svg); @@ -40,5 +41,5 @@ export const usePaletteBoxEffect = ( } catch (error) { console.error(`Failed to render box type ${boxType}:`, error); } - }, [boxType, containerRef]); + }, [boxType, containerRef, visualStyle]); }; diff --git a/frontend/src/features/palette/utils/BoxRenderer.ts b/frontend/src/features/palette/utils/BoxRenderer.ts index c684828..6542f8e 100644 --- a/frontend/src/features/palette/utils/BoxRenderer.ts +++ b/frontend/src/features/palette/utils/BoxRenderer.ts @@ -1,8 +1,21 @@ import MemoryViz from "memory-viz"; -import { BoxTypeName } from "../../shared/types"; +import { BoxTypeName, VisualStyle } from "../../shared/types"; import { getBoxConfig, DEFAULT_PALETTE_STYLE } from "../../shared/boxConfig"; +import { createPythonTutorBoxRenderer } from "../../canvas/utils/pythonTutorRenderer"; +import { createPythonTutorPalettePreview } from "../../canvas/utils/pythonTutorReferences"; + +export function createBoxRenderer( + boxType: BoxTypeName, + visualStyle: VisualStyle = "memoryviz" +): SVGSVGElement { + if (visualStyle === "pythonTutor") { + const preview = createPythonTutorPalettePreview(boxType); + return createPythonTutorBoxRenderer(preview.element, { + elementsById: preview.elementsById, + renderMode: preview.renderMode, + }); + } -export function createBoxRenderer(boxType: BoxTypeName): SVGSVGElement { const { MemoryModel } = MemoryViz; const config = getBoxConfig(boxType); diff --git a/frontend/src/features/shared/types.ts b/frontend/src/features/shared/types.ts index 059d23e..254f2ec 100644 --- a/frontend/src/features/shared/types.ts +++ b/frontend/src/features/shared/types.ts @@ -1,6 +1,10 @@ +import type { Dispatch, SetStateAction } from "react"; + export type PrimitiveType = "NoneType" | "int" | "float" | "str" | "bool"; export type CollectionType = "list" | "tuple" | "set" | "dict"; export type SpecialType = "function" | "class"; +export type VisualStyle = "memoryviz" | "pythonTutor"; +export type RenderMode = "canvas" | "palette"; /** * Box type names (used for palette and box configuration lookup) @@ -92,6 +96,7 @@ export interface CanvasElement { x: number; y: number; kind: BoxType; + generatedInlinePrimitive?: boolean; invalidated?: boolean; errors?: ElementError[]; // Unified error system (validation + feedback) color?: string; // Optional color to apply to the element (e.g., for errors, warnings, etc.) @@ -151,14 +156,8 @@ export type ID = number | "_"; export type ClassID = string | "_"; export interface BoxEditorType { - metadata: { - id: ID; - kind: BoxType; - className?: ClassID; - errors?: ElementError[]; // Updated to use unified error system - invalidated?: boolean; - }; - onSave: (id: ID, kind: BoxType) => void; + metadata: CanvasElement; + onSave: (id: ID, kind: BoxType, invalidated?: boolean) => void; onRemove: () => void; onClose: () => void; @@ -173,6 +172,8 @@ export interface BoxEditorType { sandbox?: boolean; elements?: any[]; questionFunctionNames?: string[]; + visualStyle?: VisualStyle; + onElementsChange?: Dispatch>; } export type Tab = "feedback" | "question"; diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 09fdb1a..20b425d 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -66,6 +66,15 @@ --fade-box-fill: #f8fafc; --fade-box-line-color: #e2e8f0; --hide-box-fill: #ffffff; + + /* Python Tutor styling */ + --python-tutor-box-bg: #f1efc6; + --python-tutor-border: #7d7b63; + --python-tutor-header-text: #666666; + --python-tutor-body-text: #111111; + --python-tutor-reference-text: #1f4f82; + --python-tutor-selected-frame-bg: #d8dee8; + --python-tutor-frame-line: #a8a8a8; } :root[data-theme="dark"] { @@ -132,6 +141,15 @@ --fade-box-fill: #2d2d2d; --fade-box-line-color: #3a3a3a; --hide-box-fill: #2d2d2d; + + /* Python Tutor styling */ + --python-tutor-box-bg: #f1efc6; + --python-tutor-border: #7d7b63; + --python-tutor-header-text: #666666; + --python-tutor-body-text: #111111; + --python-tutor-reference-text: #1f4f82; + --python-tutor-selected-frame-bg: #d8dee8; + --python-tutor-frame-line: #a8a8a8; } /* Apply theme to body */ From 4922294ff792a2aaa04e1dcdd1cb02bfbf4bd02c Mon Sep 17 00:00:00 2001 From: Kevin Chen <109507575+kcccr123@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:29:45 -0400 Subject: [PATCH 2/2] visual --- frontend/src/features/canvas/Canvas.test.tsx | 233 +++++++++++ frontend/src/features/canvas/Canvas.tsx | 67 ++- .../features/canvas/components/CallStack.tsx | 8 + .../features/canvas/components/CanvasBox.tsx | 16 +- .../PythonTutorReferenceArrows.test.tsx | 265 ++++++++++++ .../components/PythonTutorReferenceArrows.tsx | 389 ++++++++++++++++++ .../src/features/canvas/hooks/useCanvas.tsx | 16 +- .../src/features/canvas/utils/box.renderer.ts | 6 + .../src/features/canvas/utils/box.types.ts | 6 + .../canvas/utils/pythonTutorReferences.ts | 15 +- .../canvas/utils/pythonTutorRenderer.test.ts | 138 +++++++ .../canvas/utils/pythonTutorRenderer.ts | 339 +++++++++------ .../canvasControls/CanvasControls.module.css | 5 + .../canvasControls/CanvasControls.test.tsx | 95 +++++ .../canvasControls/CanvasControls.tsx | 70 ++++ .../features/editors/boxEditor/BoxEditor.tsx | 2 + .../features/editors/boxEditor/Content.tsx | 6 + .../PythonTutorPrimitiveMode.test.tsx | 143 +++++++ .../boxEditor/classBoxes/ClassContent.tsx | 7 +- .../collectionBoxes/CollectionContent.tsx | 3 + .../collectionBoxes/CollectionItem.tsx | 11 +- .../functionBoxes/FunctionContent.tsx | 7 +- .../memoryModelEditor/MemoryModelEditor.tsx | 16 + .../hooks/useLocalStorage.ts | 2 + .../hooks/useMemoryModelEditorState.ts | 8 + .../utils/localStorage.test.ts | 67 +++ .../memoryModelEditor/utils/localStorage.ts | 10 + .../src/features/palette/Palette.test.tsx | 56 +++ frontend/src/features/palette/Palette.tsx | 30 +- frontend/src/features/shared/types.ts | 1 + 30 files changed, 1889 insertions(+), 148 deletions(-) create mode 100644 frontend/src/features/canvas/Canvas.test.tsx create mode 100644 frontend/src/features/canvas/components/PythonTutorReferenceArrows.test.tsx create mode 100644 frontend/src/features/canvas/components/PythonTutorReferenceArrows.tsx create mode 100644 frontend/src/features/canvas/utils/pythonTutorRenderer.test.ts create mode 100644 frontend/src/features/canvasControls/CanvasControls.test.tsx create mode 100644 frontend/src/features/editors/boxEditor/PythonTutorPrimitiveMode.test.tsx create mode 100644 frontend/src/features/memoryModelEditor/utils/localStorage.test.ts create mode 100644 frontend/src/features/palette/Palette.test.tsx diff --git a/frontend/src/features/canvas/Canvas.test.tsx b/frontend/src/features/canvas/Canvas.test.tsx new file mode 100644 index 0000000..65f7311 --- /dev/null +++ b/frontend/src/features/canvas/Canvas.test.tsx @@ -0,0 +1,233 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import Canvas from "./Canvas"; +import { CanvasElement } from "../shared/types"; + +jest.mock("./components/CanvasBox", () => ({ + __esModule: true, + default: ({ element }: { element: CanvasElement }) => ( + + ), +})); + +jest.mock("./components/CallStack", () => ({ + __esModule: true, + default: () => , +})); + +jest.mock("./components/PythonTutorReferenceArrows", () => ({ + __esModule: true, + default: ({ enabled }: { enabled: boolean }) => + enabled ? : null, +})); + +jest.mock("./utils/validation", () => ({ + validateElements: (elements: CanvasElement[]) => elements, +})); + +jest.mock("../editors/utils/pythonTutorInlinePrimitives", () => ({ + findOrphanedGeneratedPrimitiveIds: () => [], +})); + +class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} +} + +describe("Canvas arrow overlay order", () => { + beforeAll(() => { + (global as typeof globalThis).ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + }); + + it("renders the arrow overlay after the object layer so arrows paint on top", () => { + const elements: CanvasElement[] = [ + { + boxId: 1, + id: "_", + x: 100, + y: 100, + kind: { + name: "function", + type: "function", + value: null, + functionName: "__main__", + params: [{ name: "node", targetId: 2 }], + }, + }, + { + boxId: 2, + id: 2, + x: 250, + y: 120, + kind: { + name: "class", + type: "class", + value: null, + className: "Node", + classVariables: [], + }, + }, + ]; + + const setElements = jest.fn(); + const svgPoint = { + x: 0, + y: 0, + matrixTransform: () => ({ x: 0, y: 0 }), + }; + + render( + + ); + + const svg = screen.getByTestId("canvas") as unknown as SVGSVGElement; + svg.getBoundingClientRect = () => + ({ + width: 800, + height: 600, + top: 0, + left: 0, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + svg.createSVGPoint = () => svgPoint as SVGPoint; + + const overlay = screen.getByTestId("arrow-overlay-layer"); + const objectBox = screen.getByTestId("canvas-box-2"); + const objectLayer = objectBox.parentElement; + + expect(objectLayer?.tagName.toLowerCase()).toBe("g"); + + const svgChildren = Array.from(svg.children); + expect(svgChildren.indexOf(objectLayer as Element)).toBeGreaterThanOrEqual(0); + expect(svgChildren.indexOf(overlay)).toBeGreaterThan( + svgChildren.indexOf(objectLayer as Element) + ); + }); + + it("hides primitive objects in inline Python Tutor mode but shows them in standalone mode", () => { + const primitiveElement: CanvasElement = { + boxId: 2, + id: 2, + x: 250, + y: 120, + kind: { + name: "primitive", + type: "int", + value: "7", + }, + }; + + const { rerender } = render( + + ); + + expect(screen.queryByTestId("canvas-box-2")).toBeNull(); + + rerender( + + ); + + expect(screen.getByTestId("canvas-box-2")).toBeInTheDocument(); + }); + + it("blocks primitive drops in inline Python Tutor mode and allows them in standalone mode", () => { + const dataTransfer = { + getData: () => "int", + } as unknown as DataTransfer; + const svgPoint = { + x: 0, + y: 0, + matrixTransform: () => ({ x: 100, y: 120 }), + }; + const setElements = jest.fn(); + + const { rerender } = render( + + ); + + let svg = screen.getByTestId("canvas") as unknown as SVGSVGElement; + svg.createSVGPoint = () => svgPoint as SVGPoint; + svg.getScreenCTM = () => + ({ + inverse: () => ({}), + }) as SVGMatrix; + + fireEvent.drop(svg, { + dataTransfer, + clientX: 100, + clientY: 120, + }); + + expect(setElements).not.toHaveBeenCalled(); + + rerender( + + ); + + svg = screen.getByTestId("canvas") as unknown as SVGSVGElement; + svg.createSVGPoint = () => svgPoint as SVGPoint; + svg.getScreenCTM = () => + ({ + inverse: () => ({}), + }) as SVGMatrix; + + fireEvent.drop(svg, { + dataTransfer, + clientX: 100, + clientY: 120, + }); + + expect(setElements).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/features/canvas/Canvas.tsx b/frontend/src/features/canvas/Canvas.tsx index ebc5b15..2997c0f 100644 --- a/frontend/src/features/canvas/Canvas.tsx +++ b/frontend/src/features/canvas/Canvas.tsx @@ -24,6 +24,7 @@ import { createElementsByIdMap, } from "./utils/pythonTutorReferences"; import { findOrphanedGeneratedPrimitiveIds } from "../editors/utils/pythonTutorInlinePrimitives"; +import PythonTutorReferenceArrows from "./components/PythonTutorReferenceArrows"; const EDITOR_MAP: Record> = { primitive: BoxEditor, @@ -54,6 +55,8 @@ interface FloatingEditorProps { editorScale: number; questionFunctionNames?: string[]; visualStyle?: VisualStyle; + pythonTutorReferenceArrows?: boolean; + pythonTutorStandalonePrimitives?: boolean; onElementsChange: React.Dispatch>; } @@ -76,6 +79,8 @@ function FloatingEditor({ editorScale, questionFunctionNames, visualStyle = "memoryviz", + pythonTutorReferenceArrows = false, + pythonTutorStandalonePrimitives = false, onElementsChange, }: FloatingEditorProps) { const nodeRef = useRef(null); @@ -123,6 +128,7 @@ function FloatingEditor({ elements={elements} questionFunctionNames={questionFunctionNames} visualStyle={visualStyle} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onElementsChange={onElementsChange} /> @@ -148,6 +154,8 @@ interface CanvasProps { editorScale?: number; questionFunctionNames?: string[]; visualStyle?: VisualStyle; + pythonTutorReferenceArrows?: boolean; + pythonTutorStandalonePrimitives?: boolean; } function Canvas({ @@ -165,6 +173,8 @@ function Canvas({ editorScale = 1, questionFunctionNames, visualStyle = "memoryviz", + pythonTutorReferenceArrows = false, + pythonTutorStandalonePrimitives = false, }: CanvasProps) { const [openEditors, setOpenEditors] = useState([]); const [selectedElement, setSelectedElement] = useState( @@ -179,6 +189,8 @@ function Canvas({ const { svgRef } = useCanvasRefs(); const wrapperRef = useRef(null); + const useInlinePythonTutorPrimitives = + visualStyle === "pythonTutor" && !pythonTutorStandalonePrimitives; const initialVB = typeof window !== "undefined" @@ -327,7 +339,7 @@ function Canvas({ "primitive", ]); - if (visualStyle === "pythonTutor" && primitiveBoxTypes.has(boxType)) { + if (useInlinePythonTutorPrimitives && primitiveBoxTypes.has(boxType)) { return; } @@ -361,7 +373,14 @@ function Canvas({ return [...prev, newElement]; }); }, - [elements, ids, sandbox, svgRef, setElements, visualStyle] + [ + elements, + ids, + sandbox, + svgRef, + setElements, + useInlinePythonTutorPrimitives, + ] ); const saveElement = useCallback( @@ -399,14 +418,14 @@ function Canvas({ const openElementEditor = useCallback( (element: CanvasElement) => { - if (visualStyle === "pythonTutor" && element.kind.name === "primitive") { + if (useInlinePythonTutorPrimitives && element.kind.name === "primitive") { return; } setOpenEditors([element]); setSelectedElement(element); }, - [visualStyle] + [useInlinePythonTutorPrimitives] ); const closeElementEditor = useCallback((boxId: number) => { @@ -422,7 +441,7 @@ function Canvas({ }, [onEditorOpenerReady, openElementEditor]); useEffect(() => { - if (visualStyle !== "pythonTutor") { + if (!useInlinePythonTutorPrimitives) { return; } @@ -432,7 +451,7 @@ function Canvas({ setSelectedElement((prev) => prev?.kind.name === "primitive" ? null : prev ); - }, [visualStyle]); + }, [useInlinePythonTutorPrimitives]); // Close all open editors when Escape is pressed useEffect(() => { @@ -454,12 +473,18 @@ function Canvas({ () => elements.filter((el) => { if (el.kind.name === "function") return false; - return !(visualStyle === "pythonTutor" && el.kind.name === "primitive"); + return !( + useInlinePythonTutorPrimitives && el.kind.name === "primitive" + ); }), - [elements, visualStyle] + [elements, useInlinePythonTutorPrimitives] ); useEffect(() => { + if (!useInlinePythonTutorPrimitives) { + return; + } + const orphanedGeneratedPrimitiveIds = findOrphanedGeneratedPrimitiveIds(elements); if (orphanedGeneratedPrimitiveIds.length === 0) { return; @@ -499,7 +524,7 @@ function Canvas({ return prev; }); - }, [elements, removeId, setElements]); + }, [elements, removeId, setElements, useInlinePythonTutorPrimitives]); const handleCallStackReorder = useCallback( (fromIndex: number, toIndex: number) => { @@ -562,6 +587,10 @@ function Canvas({ onWidthChange={setCallStackWidth} scale={scale} visualStyle={visualStyle} + pythonTutorReferenceArrows={pythonTutorReferenceArrows} + pythonTutorStandalonePrimitives={ + pythonTutorStandalonePrimitives + } elementsById={elementsById} /> @@ -575,10 +604,23 @@ function Canvas({ invalidated={el.invalidated} callStackWidth={callStackWidth} visualStyle={visualStyle} + pythonTutorReferenceArrows={pythonTutorReferenceArrows} + pythonTutorStandalonePrimitives={ + pythonTutorStandalonePrimitives + } elementsById={elementsById} /> ))} + + @@ -607,6 +649,8 @@ function Canvas({ editorScale={editorScale} questionFunctionNames={questionFunctionNames} visualStyle={visualStyle} + pythonTutorReferenceArrows={pythonTutorReferenceArrows} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onElementsChange={setElements} /> ); @@ -691,7 +735,10 @@ const areEqual = (prev: Readonly, next: Readonly) => { prev.scale === next.scale && prev.editorScale === next.editorScale && prev.questionFunctionNames === next.questionFunctionNames && - prev.visualStyle === next.visualStyle + prev.visualStyle === next.visualStyle && + prev.pythonTutorReferenceArrows === next.pythonTutorReferenceArrows && + prev.pythonTutorStandalonePrimitives === + next.pythonTutorStandalonePrimitives ); }; diff --git a/frontend/src/features/canvas/components/CallStack.tsx b/frontend/src/features/canvas/components/CallStack.tsx index cd580a9..3d7f489 100644 --- a/frontend/src/features/canvas/components/CallStack.tsx +++ b/frontend/src/features/canvas/components/CallStack.tsx @@ -41,6 +41,8 @@ interface CallStackProps { width?: number; scale?: number; visualStyle?: VisualStyle; + pythonTutorReferenceArrows?: boolean; + pythonTutorStandalonePrimitives?: boolean; elementsById?: Map; } @@ -75,6 +77,8 @@ const CallStack: React.FC = ({ width = 205, scale = 1, visualStyle = "memoryviz", + pythonTutorReferenceArrows = false, + pythonTutorStandalonePrimitives = false, elementsById, }) => { const clipPathId = useId(); @@ -430,6 +434,10 @@ const CallStack: React.FC = ({ invalidated={frame.invalidated} disableDrag={true} visualStyle={visualStyle} + pythonTutorReferenceArrows={pythonTutorReferenceArrows} + pythonTutorStandalonePrimitives={ + pythonTutorStandalonePrimitives + } elementsById={elementsById} /> diff --git a/frontend/src/features/canvas/components/CanvasBox.tsx b/frontend/src/features/canvas/components/CanvasBox.tsx index 6c58ae2..9020ae8 100644 --- a/frontend/src/features/canvas/components/CanvasBox.tsx +++ b/frontend/src/features/canvas/components/CanvasBox.tsx @@ -15,6 +15,8 @@ export default function CanvasBox({ disableDrag = false, callStackWidth, visualStyle = "memoryviz", + pythonTutorReferenceArrows = false, + pythonTutorStandalonePrimitives = false, elementsById, renderMode = "canvas", }: CanvasBoxProps) { @@ -31,6 +33,8 @@ export default function CanvasBox({ disableDrag, callStackWidth, visualStyle, + pythonTutorReferenceArrows, + pythonTutorStandalonePrimitives, elementsById, renderMode, }); @@ -136,5 +140,15 @@ export default function CanvasBox({ }; }, [checkAndConstrainPosition, disableDrag, element.kind.name]); - return ; + return ( + + ); } diff --git a/frontend/src/features/canvas/components/PythonTutorReferenceArrows.test.tsx b/frontend/src/features/canvas/components/PythonTutorReferenceArrows.test.tsx new file mode 100644 index 0000000..4ee43ac --- /dev/null +++ b/frontend/src/features/canvas/components/PythonTutorReferenceArrows.test.tsx @@ -0,0 +1,265 @@ +import React, { useRef } from "react"; +import { render, waitFor } from "@testing-library/react"; +import PythonTutorReferenceArrows from "./PythonTutorReferenceArrows"; + +class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} +} + +function makeRect( + left: number, + top: number, + width: number, + height: number +): DOMRect { + return { + x: left, + y: top, + left, + top, + width, + height, + right: left + width, + bottom: top + height, + toJSON: () => ({}), + } as DOMRect; +} + +function Harness({ + enabled, + includePrimitiveTargets = false, + targetKind = "class", +}: { + enabled: boolean; + includePrimitiveTargets?: boolean; + targetKind?: "class" | "primitive"; +}) { + const svgRef = useRef(null); + + return ( + + + + + + ); +} + +function parseCubicPath(pathData: string) { + const matches = pathData.match(/-?\d+(?:\.\d+)?/g); + if (!matches || matches.length !== 8) { + throw new Error(`Unexpected path data: ${pathData}`); + } + + const [startX, startY, control1X, control1Y, control2X, control2Y, endX, endY] = + matches.map(Number); + + return { + startX, + startY, + control1X, + control1Y, + control2X, + control2Y, + endX, + endY, + }; +} + +describe("PythonTutorReferenceArrows", () => { + beforeAll(() => { + (global as typeof globalThis).ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => + window.setTimeout(callback, 0)) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((handle: number) => + window.clearTimeout(handle)) as typeof window.cancelAnimationFrame; + }); + + it("draws arrow paths only when enabled and source/target markers exist", async () => { + const { container, rerender } = render(); + + const svg = container.querySelector("svg") as SVGSVGElement; + const target = container.querySelector( + '[data-canvas-element-id="2"]' + ) as SVGGElement; + const source = container.querySelector( + '[data-ref-source-target-id="2"]' + ) as SVGCircleElement; + + svg.getBoundingClientRect = () => makeRect(0, 0, 400, 300); + target.getBoundingClientRect = () => makeRect(240, 120, 80, 60); + source.getBoundingClientRect = () => makeRect(100, 140, 8, 8); + + expect( + container.querySelector('g[data-python-tutor-arrow-overlay="true"] path') + ).toBeNull(); + + rerender(); + + const rerenderedSvg = container.querySelector("svg") as SVGSVGElement; + const rerenderedTarget = container.querySelector( + '[data-canvas-element-id="2"]' + ) as SVGGElement; + const rerenderedSource = container.querySelector( + '[data-ref-source-target-id="2"]' + ) as SVGCircleElement; + + rerenderedSvg.getBoundingClientRect = () => makeRect(0, 0, 400, 300); + rerenderedTarget.getBoundingClientRect = () => makeRect(240, 120, 80, 60); + rerenderedSource.getBoundingClientRect = () => makeRect(100, 140, 8, 8); + + await waitFor(() => { + const arrowPath = container.querySelector( + 'g[data-python-tutor-arrow-overlay="true"] path' + ); + expect(arrowPath).not.toBeNull(); + expect(arrowPath?.getAttribute("d")).toContain("M"); + }); + }); + + it("uses a vertical endpoint tangent when entering from above", async () => { + const { container } = render(); + + const svg = container.querySelector("svg") as SVGSVGElement; + const target = container.querySelector( + '[data-canvas-element-id="2"]' + ) as SVGGElement; + const source = container.querySelector( + '[data-ref-source-target-id="2"]' + ) as SVGCircleElement; + + svg.getBoundingClientRect = () => makeRect(0, 0, 400, 300); + target.getBoundingClientRect = () => makeRect(160, 160, 80, 60); + source.getBoundingClientRect = () => makeRect(196, 40, 8, 8); + + await waitFor(() => { + const arrowPath = container.querySelector( + 'g[data-python-tutor-arrow-overlay="true"] path' + ); + expect(arrowPath).not.toBeNull(); + + const path = parseCubicPath(arrowPath?.getAttribute("d") || ""); + expect(path.control2X).toBeCloseTo(path.endX, 5); + expect(path.control2Y).not.toBeCloseTo(path.endY, 5); + expect(path.endY).toBeCloseTo(160, 5); + }); + }); + + it("uses a vertical endpoint tangent when entering from below", async () => { + const { container } = render(); + + const svg = container.querySelector("svg") as SVGSVGElement; + const target = container.querySelector( + '[data-canvas-element-id="2"]' + ) as SVGGElement; + const source = container.querySelector( + '[data-ref-source-target-id="2"]' + ) as SVGCircleElement; + + svg.getBoundingClientRect = () => makeRect(0, 0, 400, 300); + target.getBoundingClientRect = () => makeRect(160, 80, 80, 60); + source.getBoundingClientRect = () => makeRect(196, 220, 8, 8); + + await waitFor(() => { + const arrowPath = container.querySelector( + 'g[data-python-tutor-arrow-overlay="true"] path' + ); + expect(arrowPath).not.toBeNull(); + + const path = parseCubicPath(arrowPath?.getAttribute("d") || ""); + expect(path.control2X).toBeCloseTo(path.endX, 5); + expect(path.control2Y).not.toBeCloseTo(path.endY, 5); + expect(path.endY).toBeCloseTo(140, 5); + }); + }); + + it("keeps a horizontal endpoint tangent when entering from the left", async () => { + const { container } = render(); + + const svg = container.querySelector("svg") as SVGSVGElement; + const target = container.querySelector( + '[data-canvas-element-id="2"]' + ) as SVGGElement; + const source = container.querySelector( + '[data-ref-source-target-id="2"]' + ) as SVGCircleElement; + + svg.getBoundingClientRect = () => makeRect(0, 0, 400, 300); + target.getBoundingClientRect = () => makeRect(240, 120, 80, 60); + source.getBoundingClientRect = () => makeRect(100, 140, 8, 8); + + await waitFor(() => { + const arrowPath = container.querySelector( + 'g[data-python-tutor-arrow-overlay="true"] path' + ); + expect(arrowPath).not.toBeNull(); + + const path = parseCubicPath(arrowPath?.getAttribute("d") || ""); + expect(path.control2X).not.toBeCloseTo(path.endX, 5); + expect(path.control2Y).toBeCloseTo(path.endY, 5); + expect(path.endX).toBeCloseTo(240, 5); + }); + }); + + it("includes primitive targets only when configured", async () => { + const { container, rerender } = render( + + ); + + const svg = container.querySelector("svg") as SVGSVGElement; + const target = container.querySelector( + '[data-canvas-element-id="2"]' + ) as SVGGElement; + const source = container.querySelector( + '[data-ref-source-target-id="2"]' + ) as SVGCircleElement; + + svg.getBoundingClientRect = () => makeRect(0, 0, 400, 300); + target.getBoundingClientRect = () => makeRect(240, 120, 80, 60); + source.getBoundingClientRect = () => makeRect(100, 140, 8, 8); + + await waitFor(() => { + expect( + container.querySelector('g[data-python-tutor-arrow-overlay="true"] path') + ).toBeNull(); + }); + + rerender( + + ); + + const rerenderedSvg = container.querySelector("svg") as SVGSVGElement; + const rerenderedTarget = container.querySelector( + '[data-canvas-element-id="2"]' + ) as SVGGElement; + const rerenderedSource = container.querySelector( + '[data-ref-source-target-id="2"]' + ) as SVGCircleElement; + + rerenderedSvg.getBoundingClientRect = () => makeRect(0, 0, 400, 300); + rerenderedTarget.getBoundingClientRect = () => makeRect(240, 120, 80, 60); + rerenderedSource.getBoundingClientRect = () => makeRect(100, 140, 8, 8); + + await waitFor(() => { + expect( + container.querySelector('g[data-python-tutor-arrow-overlay="true"] path') + ).not.toBeNull(); + }); + }); +}); diff --git a/frontend/src/features/canvas/components/PythonTutorReferenceArrows.tsx b/frontend/src/features/canvas/components/PythonTutorReferenceArrows.tsx new file mode 100644 index 0000000..edd0bc9 --- /dev/null +++ b/frontend/src/features/canvas/components/PythonTutorReferenceArrows.tsx @@ -0,0 +1,389 @@ +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { CanvasElement } from "../../shared/types"; + +interface PythonTutorReferenceArrowsProps { + svgRef: React.RefObject; + enabled: boolean; + includePrimitiveTargets?: boolean; + elements: CanvasElement[]; +} + +interface Point { + x: number; + y: number; +} + +type RectSide = "left" | "right" | "top" | "bottom"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +interface ArrowPath { + key: string; + d: string; +} + +interface RectIntersection { + point: Point; + side: RectSide; +} + +function parseViewBox(svg: SVGSVGElement): Rect { + const viewBox = svg.viewBox?.baseVal; + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { + x: viewBox.x, + y: viewBox.y, + width: viewBox.width, + height: viewBox.height, + }; + } + + const rawViewBox = svg.getAttribute("viewBox"); + if (rawViewBox) { + const [x, y, width, height] = rawViewBox + .split(/\s+/) + .map((value) => parseFloat(value)); + if ([x, y, width, height].every((value) => Number.isFinite(value))) { + return { x, y, width, height }; + } + } + + const rect = svg.getBoundingClientRect(); + return { + x: 0, + y: 0, + width: rect.width || 1, + height: rect.height || 1, + }; +} + +function toSvgPoint( + svg: SVGSVGElement, + clientX: number, + clientY: number +): Point { + const rootRect = svg.getBoundingClientRect(); + const viewBox = parseViewBox(svg); + const width = rootRect.width || 1; + const height = rootRect.height || 1; + + return { + x: viewBox.x + ((clientX - rootRect.left) / width) * viewBox.width, + y: viewBox.y + ((clientY - rootRect.top) / height) * viewBox.height, + }; +} + +function rectToSvgRect(svg: SVGSVGElement, rect: DOMRect): Rect | null { + if (rect.width <= 0 || rect.height <= 0) { + return null; + } + + const topLeft = toSvgPoint(svg, rect.left, rect.top); + const bottomRight = toSvgPoint(svg, rect.right, rect.bottom); + + return { + x: Math.min(topLeft.x, bottomRight.x), + y: Math.min(topLeft.y, bottomRight.y), + width: Math.abs(bottomRight.x - topLeft.x), + height: Math.abs(bottomRight.y - topLeft.y), + }; +} + +function getRectPerimeterPoint(rect: Rect, towardPoint: Point): RectIntersection { + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + const dx = towardPoint.x - centerX; + const dy = towardPoint.y - centerY; + + if (dx === 0 && dy === 0) { + return { + point: { x: centerX, y: centerY }, + side: "top", + }; + } + + const scaleX = + dx === 0 ? Number.POSITIVE_INFINITY : rect.width / 2 / Math.abs(dx); + const scaleY = + dy === 0 ? Number.POSITIVE_INFINITY : rect.height / 2 / Math.abs(dy); + const preferVertical = Math.abs(dy) >= Math.abs(dx); + const isTie = Math.abs(scaleX - scaleY) < 1e-6; + const useHorizontalEdge = + scaleY < scaleX || (isTie && preferVertical); + const side: RectSide = useHorizontalEdge + ? dy < 0 + ? "top" + : "bottom" + : dx < 0 + ? "left" + : "right"; + const scale = useHorizontalEdge ? scaleY : scaleX; + + return { + point: { + x: centerX + dx * scale, + y: centerY + dy * scale, + }, + side, + }; +} + +function createArrowPath(start: Point, end: Point, entrySide: RectSide): string { + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + const departVertically = Math.abs(deltaY) > Math.abs(deltaX); + const startOffset = Math.max( + 26, + (departVertically ? Math.abs(deltaY) : Math.abs(deltaX)) * 0.35 + ); + const endOffset = Math.max( + 26, + ((entrySide === "top" || entrySide === "bottom") + ? Math.abs(deltaY) + : Math.abs(deltaX)) * 0.35 + ); + + const control1 = departVertically + ? { + x: start.x, + y: start.y + (deltaY >= 0 ? 1 : -1) * startOffset, + } + : { + x: start.x + (deltaX >= 0 ? 1 : -1) * startOffset, + y: start.y, + }; + + const control2 = (() => { + switch (entrySide) { + case "left": + return { x: end.x - endOffset, y: end.y }; + case "right": + return { x: end.x + endOffset, y: end.y }; + case "top": + return { x: end.x, y: end.y - endOffset }; + case "bottom": + return { x: end.x, y: end.y + endOffset }; + default: + return { x: end.x, y: end.y }; + } + })(); + + return `M ${start.x} ${start.y} C ${control1.x} ${control1.y}, ${control2.x} ${control2.y}, ${end.x} ${end.y}`; +} + +function areArrowPathsEqual(current: ArrowPath[], next: ArrowPath[]): boolean { + if (current.length !== next.length) { + return false; + } + + return current.every( + (arrow, index) => + arrow.key === next[index]?.key && arrow.d === next[index]?.d + ); +} + +function collectArrowPaths( + svg: SVGSVGElement, + includePrimitiveTargets: boolean +): ArrowPath[] { + const targetNodes = Array.from( + svg.querySelectorAll( + '[data-canvas-element-id][data-canvas-render-mode="canvas"]' + ) + ); + const targetsById = new Map(); + + targetNodes.forEach((node) => { + const targetId = parseInt(node.getAttribute("data-canvas-element-id") || "", 10); + const kind = node.getAttribute("data-canvas-kind"); + if ( + !Number.isInteger(targetId) || + (!includePrimitiveTargets && kind === "primitive") + ) { + return; + } + + const rect = rectToSvgRect(svg, node.getBoundingClientRect()); + if (!rect) { + return; + } + + targetsById.set(targetId, rect); + }); + + const sourceNodes = Array.from( + svg.querySelectorAll("[data-ref-source-target-id]") + ); + + return sourceNodes.flatMap((node, index) => { + const targetId = parseInt( + node.getAttribute("data-ref-source-target-id") || "", + 10 + ); + if (!Number.isInteger(targetId)) { + return []; + } + + const targetRect = targetsById.get(targetId); + if (!targetRect) { + return []; + } + + const sourceRect = node.getBoundingClientRect(); + if (sourceRect.width <= 0 || sourceRect.height <= 0) { + return []; + } + + const sourcePoint = toSvgPoint( + svg, + sourceRect.left + sourceRect.width / 2, + sourceRect.top + sourceRect.height / 2 + ); + const endPoint = getRectPerimeterPoint(targetRect, sourcePoint); + + return [ + { + key: `${targetId}-${index}`, + d: createArrowPath(sourcePoint, endPoint.point, endPoint.side), + }, + ]; + }); +} + +export default function PythonTutorReferenceArrows({ + svgRef, + enabled, + includePrimitiveTargets = false, + elements, +}: PythonTutorReferenceArrowsProps) { + const rawMarkerId = useId(); + const markerId = useMemo( + () => `python-tutor-reference-arrow-${rawMarkerId.replace(/:/g, "")}`, + [rawMarkerId] + ); + const [arrows, setArrows] = useState([]); + const frameRef = useRef(null); + + const recompute = useCallback(() => { + const svg = svgRef.current; + if (!enabled || !svg) { + setArrows((current) => (current.length === 0 ? current : [])); + return; + } + + const nextArrows = collectArrowPaths(svg, includePrimitiveTargets); + setArrows((current) => + areArrowPathsEqual(current, nextArrows) ? current : nextArrows + ); + }, [enabled, includePrimitiveTargets, svgRef]); + + const scheduleRecompute = useCallback(() => { + if (!enabled) return; + if (frameRef.current !== null) return; + + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + recompute(); + }); + }, [enabled, recompute]); + + useEffect(() => { + if (!enabled) { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + setArrows([]); + return; + } + + const svg = svgRef.current; + if (!svg) return; + + scheduleRecompute(); + + const mutationObserver = new MutationObserver(() => { + scheduleRecompute(); + }); + mutationObserver.observe(svg, { + attributes: true, + childList: true, + subtree: true, + }); + + const resizeObserver = new ResizeObserver(() => { + scheduleRecompute(); + }); + resizeObserver.observe(svg); + + const handlePointerMove = () => { + scheduleRecompute(); + }; + const handleWheel = () => { + scheduleRecompute(); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("mousemove", handlePointerMove); + window.addEventListener("resize", scheduleRecompute); + svg.addEventListener("wheel", handleWheel, { passive: true }); + + return () => { + mutationObserver.disconnect(); + resizeObserver.disconnect(); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("mousemove", handlePointerMove); + window.removeEventListener("resize", scheduleRecompute); + svg.removeEventListener("wheel", handleWheel); + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + }, [enabled, scheduleRecompute, svgRef]); + + useEffect(() => { + if (!enabled) return; + scheduleRecompute(); + }, [enabled, elements, includePrimitiveTargets, scheduleRecompute]); + + if (!enabled) { + return null; + } + + return ( + <> + + + + + + + {arrows.map((arrow) => ( + + ))} + + + ); +} diff --git a/frontend/src/features/canvas/hooks/useCanvas.tsx b/frontend/src/features/canvas/hooks/useCanvas.tsx index 9bc96ce..d3c643f 100644 --- a/frontend/src/features/canvas/hooks/useCanvas.tsx +++ b/frontend/src/features/canvas/hooks/useCanvas.tsx @@ -132,6 +132,8 @@ export function useDraggableBox({ disableDrag = false, callStackWidth, visualStyle = "memoryviz", + pythonTutorReferenceArrows = false, + pythonTutorStandalonePrimitives = false, elementsById, renderMode = "canvas", }: { @@ -145,6 +147,8 @@ export function useDraggableBox({ disableDrag?: boolean; callStackWidth?: number; visualStyle?: VisualStyle; + pythonTutorReferenceArrows?: boolean; + pythonTutorStandalonePrimitives?: boolean; elementsById?: Map; renderMode?: RenderMode; }) { @@ -279,7 +283,11 @@ export function useDraggableBox({ const target = elementsById.get(targetId); if ( target && - !(visualStyle === "pythonTutor" && target.kind.name === "primitive") + !( + visualStyle === "pythonTutor" && + !pythonTutorStandalonePrimitives && + target.kind.name === "primitive" + ) ) { return target; } @@ -287,7 +295,7 @@ export function useDraggableBox({ return null; }, - [elementsById, visualStyle] + [elementsById, pythonTutorStandalonePrimitives, visualStyle] ); const handleMouseDown = useCallback( @@ -332,6 +340,8 @@ export function useDraggableBox({ const svgElement = createBoxRenderer(element, { visualStyle, + pythonTutorReferenceArrows, + pythonTutorStandalonePrimitives, elementsById, renderMode, }); @@ -427,6 +437,8 @@ export function useDraggableBox({ dimensions, disableDrag, visualStyle, + pythonTutorReferenceArrows, + pythonTutorStandalonePrimitives, elementsById, renderMode, findReferencedElement, diff --git a/frontend/src/features/canvas/utils/box.renderer.ts b/frontend/src/features/canvas/utils/box.renderer.ts index 2bad878..bc5299f 100644 --- a/frontend/src/features/canvas/utils/box.renderer.ts +++ b/frontend/src/features/canvas/utils/box.renderer.ts @@ -45,6 +45,8 @@ export function createBoxRenderer( element: CanvasElement, options: { visualStyle?: VisualStyle; + pythonTutorReferenceArrows?: boolean; + pythonTutorStandalonePrimitives?: boolean; elementsById?: Map; renderMode?: RenderMode; } = {} @@ -53,6 +55,10 @@ export function createBoxRenderer( const svg = createPythonTutorBoxRenderer(element, { elementsById: options.elementsById, renderMode: options.renderMode, + showReferenceArrows: + options.renderMode === "canvas" && options.pythonTutorReferenceArrows, + showPrimitiveReferencesAsObjects: + options.pythonTutorStandalonePrimitives, }); if (element.color) { diff --git a/frontend/src/features/canvas/utils/box.types.ts b/frontend/src/features/canvas/utils/box.types.ts index 41c22cf..5e02a82 100644 --- a/frontend/src/features/canvas/utils/box.types.ts +++ b/frontend/src/features/canvas/utils/box.types.ts @@ -33,6 +33,12 @@ export interface CanvasBoxProps { /** Active visual style for rendering */ visualStyle?: VisualStyle; + /** Whether Python Tutor mode should render reference arrows */ + pythonTutorReferenceArrows?: boolean; + + /** Whether Python Tutor mode should treat primitives as standalone objects */ + pythonTutorStandalonePrimitives?: boolean; + /** Lookup map for resolving ID references in alternate renderers */ elementsById?: Map; diff --git a/frontend/src/features/canvas/utils/pythonTutorReferences.ts b/frontend/src/features/canvas/utils/pythonTutorReferences.ts index 6e65ef0..e986816 100644 --- a/frontend/src/features/canvas/utils/pythonTutorReferences.ts +++ b/frontend/src/features/canvas/utils/pythonTutorReferences.ts @@ -13,6 +13,10 @@ export interface PythonTutorReferenceDisplay { targetId: number | null; } +interface ResolveInlineDisplayOptions { + showPrimitiveReferencesAsObjects?: boolean; +} + function normalizeNumericId(value: ReferenceTarget): number | null { if (typeof value === "number" && Number.isInteger(value)) { return value; @@ -106,7 +110,8 @@ export function formatPrimitiveValue(kind: PrimitiveKind): string { export function resolveInlineDisplay( targetId: ReferenceTarget, - elementsById?: Map + elementsById?: Map, + options: ResolveInlineDisplayOptions = {} ): PythonTutorReferenceDisplay { const numericId = normalizeNumericId(targetId); @@ -128,6 +133,14 @@ export function resolveInlineDisplay( } if (isPrimitiveElement(target)) { + if (options.showPrimitiveReferencesAsObjects) { + return { + kind: "reference", + label: `id${numericId}`, + targetId: numericId, + }; + } + return { kind: "primitive", label: formatPrimitiveValue(target.kind), diff --git a/frontend/src/features/canvas/utils/pythonTutorRenderer.test.ts b/frontend/src/features/canvas/utils/pythonTutorRenderer.test.ts new file mode 100644 index 0000000..ed4d9ca --- /dev/null +++ b/frontend/src/features/canvas/utils/pythonTutorRenderer.test.ts @@ -0,0 +1,138 @@ +import { CanvasElement } from "../../shared/types"; +import { createPythonTutorBoxRenderer } from "./pythonTutorRenderer"; + +function createFrameElement( + params: Array<{ name: string; targetId: number | null }> +): CanvasElement { + return { + boxId: 1, + id: "_", + x: 0, + y: 0, + kind: { + name: "function", + type: "function", + value: null, + functionName: "__main__", + params, + }, + }; +} + +describe("createPythonTutorBoxRenderer", () => { + it("replaces non-primitive reference labels with source markers in arrow mode", () => { + const objectElement: CanvasElement = { + boxId: 2, + id: 2, + x: 0, + y: 0, + kind: { + name: "class", + type: "class", + value: null, + className: "Node", + classVariables: [], + }, + }; + const primitiveElement: CanvasElement = { + boxId: 3, + id: 3, + x: 0, + y: 0, + kind: { + name: "primitive", + type: "int", + value: "7", + }, + }; + const elementsById = new Map([ + [2, objectElement], + [3, primitiveElement], + ]); + const svg = createPythonTutorBoxRenderer( + createFrameElement([ + { name: "obj", targetId: 2 }, + { name: "count", targetId: 3 }, + { name: "missing", targetId: 99 }, + ]), + { + elementsById, + renderMode: "canvas", + showReferenceArrows: true, + } + ); + + expect(svg.textContent).not.toContain("id2"); + expect(svg.textContent).toContain("7"); + expect(svg.textContent).toContain("unknown id99"); + expect(svg.querySelector('[data-ref-source-target-id="2"]')).not.toBeNull(); + expect(svg.querySelector('[data-ref-source-target-id="3"]')).toBeNull(); + expect(svg.querySelector('[data-ref-source-target-id="99"]')).toBeNull(); + }); + + it("keeps inline id labels when arrow mode is off", () => { + const objectElement: CanvasElement = { + boxId: 2, + id: 2, + x: 0, + y: 0, + kind: { + name: "class", + type: "class", + value: null, + className: "Node", + classVariables: [], + }, + }; + const svg = createPythonTutorBoxRenderer( + createFrameElement([{ name: "obj", targetId: 2 }]), + { + elementsById: new Map([[2, objectElement]]), + renderMode: "canvas", + showReferenceArrows: false, + } + ); + + expect(svg.textContent).toContain("id2"); + expect(svg.querySelector('[data-ref-source-target-id="2"]')).toBeNull(); + }); + + it("renders primitive targets as references in standalone mode", () => { + const primitiveElement: CanvasElement = { + boxId: 3, + id: 3, + x: 0, + y: 0, + kind: { + name: "primitive", + type: "int", + value: "7", + }, + }; + const svg = createPythonTutorBoxRenderer( + createFrameElement([{ name: "count", targetId: 3 }]), + { + elementsById: new Map([[3, primitiveElement]]), + renderMode: "canvas", + showReferenceArrows: false, + showPrimitiveReferencesAsObjects: true, + } + ); + + expect(svg.textContent).toContain("id3"); + expect(svg.textContent).not.toContain("7"); + + const arrowSvg = createPythonTutorBoxRenderer( + createFrameElement([{ name: "count", targetId: 3 }]), + { + elementsById: new Map([[3, primitiveElement]]), + renderMode: "canvas", + showReferenceArrows: true, + showPrimitiveReferencesAsObjects: true, + } + ); + + expect(arrowSvg.textContent).not.toContain("id3"); + expect(arrowSvg.querySelector('[data-ref-source-target-id="3"]')).not.toBeNull(); + }); +}); diff --git a/frontend/src/features/canvas/utils/pythonTutorRenderer.ts b/frontend/src/features/canvas/utils/pythonTutorRenderer.ts index 464b34b..a37df62 100644 --- a/frontend/src/features/canvas/utils/pythonTutorRenderer.ts +++ b/frontend/src/features/canvas/utils/pythonTutorRenderer.ts @@ -42,6 +42,8 @@ const COLORS = { interface RenderContext { elementsById?: Map; renderMode?: RenderMode; + showReferenceArrows?: boolean; + showPrimitiveReferencesAsObjects?: boolean; } interface KeyValueRow { @@ -49,6 +51,16 @@ interface KeyValueRow { right: PythonTutorReferenceDisplay; } +function resolveDisplay( + targetId: number | string | null | "_" | undefined, + context: RenderContext +): PythonTutorReferenceDisplay { + return resolveInlineDisplay(targetId, context.elementsById, { + showPrimitiveReferencesAsObjects: + context.showPrimitiveReferencesAsObjects, + }); +} + function createSvg(width: number, height: number): SVGSVGElement { const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("xmlns", SVG_NS); @@ -187,6 +199,90 @@ function isClickableReference(display: PythonTutorReferenceDisplay): boolean { return display.kind === "reference"; } +function shouldRenderReferenceSourceMarker( + display: PythonTutorReferenceDisplay, + context: RenderContext +): boolean { + return Boolean( + context.showReferenceArrows && + context.renderMode === "canvas" && + display.kind === "reference" && + display.targetId !== null + ); +} + +function getRenderedDisplayLabel( + display: PythonTutorReferenceDisplay, + context: RenderContext +): string { + return shouldRenderReferenceSourceMarker(display, context) ? "" : display.label; +} + +function appendReferenceSourceMarker( + svg: SVGSVGElement, + x: number, + y: number, + targetId: number | null +): void { + if (targetId === null) return; + + const marker = createNode("circle"); + marker.setAttribute("cx", `${x}`); + marker.setAttribute("cy", `${y}`); + marker.setAttribute("r", "4"); + marker.style.fill = COLORS.referenceText; + marker.setAttribute("data-ref-source-target-id", `${targetId}`); + svg.appendChild(marker); +} + +function appendDisplayValue( + svg: SVGSVGElement, + display: PythonTutorReferenceDisplay, + context: RenderContext, + options: { + x: number; + y: number; + fontSize: number; + anchor?: "start" | "middle" | "end"; + markerX?: number; + markerY?: number; + hitRect?: { + x: number; + y: number; + width: number; + height: number; + }; + } +): void { + const showMarker = shouldRenderReferenceSourceMarker(display, context); + + if (showMarker) { + appendReferenceSourceMarker( + svg, + options.markerX ?? options.x, + options.markerY ?? options.y, + display.targetId + ); + } else if (display.label) { + appendText(svg, display.label, options.x, options.y, { + anchor: options.anchor, + fill: getReferenceColor(display), + fontSize: options.fontSize, + }); + } + + if (options.hitRect && isClickableReference(display)) { + appendReferenceHitRect( + svg, + options.hitRect.x, + options.hitRect.y, + options.hitRect.width, + options.hitRect.height, + display.targetId + ); + } +} + function getIdLabel(element: CanvasElement): string { return typeof element.id === "number" ? `id${element.id}` : ""; } @@ -237,32 +333,33 @@ function appendFrameSlot( x: number, y: number, width: number, - height: number + height: number, + context: RenderContext ): void { appendFrameBracket(svg, x, y, height); - if (!display.label) { + if (!display.label && !shouldRenderReferenceSourceMarker(display, context)) { return; } - appendText(svg, display.label, x + 16, y + height / 2, { - fill: getReferenceColor(display), + const textWidth = measureText( + getRenderedDisplayLabel(display, context), + VALUE_FONT_SIZE + ); + + appendDisplayValue(svg, display, context, { + x: x + 16, + y: y + height / 2, fontSize: VALUE_FONT_SIZE, + markerX: x + 20, + markerY: y + height / 2, + hitRect: { + x, + y: y - 2, + width: Math.max(width, textWidth + 18), + height: height + 4, + }, }); - - if (!isClickableReference(display)) { - return; - } - - const textWidth = measureText(display.label, VALUE_FONT_SIZE); - appendReferenceHitRect( - svg, - x, - y - 2, - Math.max(width, textWidth + 18), - height + 4, - display.targetId - ); } function renderFrameBox( @@ -271,12 +368,12 @@ function renderFrameBox( ): SVGSVGElement { const rows = (element.kind.params || []).map((param) => ({ left: param.name, - right: resolveInlineDisplay(param.targetId, context.elementsById), + right: resolveDisplay(param.targetId, context), })); const bodyRows = rows.length > 0 ? rows - : [{ left: "", right: resolveInlineDisplay(null, context.elementsById) }]; + : [{ left: "", right: resolveDisplay(null, context) }]; const title = getPythonTutorFrameTitle(element.kind.functionName); const leftWidth = Math.max( 44, @@ -284,7 +381,9 @@ function renderFrameBox( ); const rightWidth = Math.max( 46, - ...bodyRows.map((row) => measureText(row.right.label, VALUE_FONT_SIZE)) + ...bodyRows.map((row) => + measureText(getRenderedDisplayLabel(row.right, context), VALUE_FONT_SIZE) + ) ); const slotWidth = Math.max(44, rightWidth + 18); const width = Math.max( @@ -309,7 +408,15 @@ function renderFrameBox( fill: COLORS.bodyText, fontSize: 16, }); - appendFrameSlot(svg, row.right, slotX, rowTop + 4, slotWidth, SLOT_HEIGHT); + appendFrameSlot( + svg, + row.right, + slotX, + rowTop + 4, + slotWidth, + SLOT_HEIGHT, + context + ); }); return svg; @@ -347,7 +454,7 @@ function renderSequenceBox( ): SVGSVGElement { const values = Array.isArray(element.kind.value) ? element.kind.value : []; const displays = values.map((value) => - resolveInlineDisplay(value, context.elementsById) + resolveDisplay(value, context) ); const label = getObjectLabel( getPythonTutorDisplayType(element), @@ -356,7 +463,10 @@ function renderSequenceBox( const hasIndexes = element.kind.name === "list" || element.kind.name === "tuple"; const cellWidths = displays.map((display) => - Math.max(CELL_MIN_WIDTH, measureText(display.label, VALUE_FONT_SIZE) + 22) + Math.max( + CELL_MIN_WIDTH, + measureText(getRenderedDisplayLabel(display, context), VALUE_FONT_SIZE) + 22 + ) ); const bodyWidth = cellWidths.length > 0 @@ -393,28 +503,20 @@ function renderSequenceBox( }); } - appendText( - svg, - display.label, - currentX + cellWidth / 2, - OBJECT_TOP + 32, - { - anchor: "middle", - fill: getReferenceColor(display), - fontSize: VALUE_FONT_SIZE, - } - ); - - if (isClickableReference(display)) { - appendReferenceHitRect( - svg, - currentX, - OBJECT_TOP, - cellWidth, - CELL_HEIGHT, - display.targetId - ); - } + appendDisplayValue(svg, display, context, { + x: currentX + cellWidth / 2, + y: OBJECT_TOP + 32, + anchor: "middle", + fontSize: VALUE_FONT_SIZE, + markerX: currentX + cellWidth / 2, + markerY: OBJECT_TOP + 32, + hitRect: { + x: currentX, + y: OBJECT_TOP, + width: cellWidth, + height: CELL_HEIGHT, + }, + }); currentX += cellWidth; }); @@ -427,26 +529,30 @@ function renderDictBox( context: RenderContext ): SVGSVGElement { const rows = Object.entries(element.kind.value || {}).map(([key, value]) => ({ - left: resolveInlineDisplay(key, context.elementsById), - right: resolveInlineDisplay(value, context.elementsById), + left: resolveDisplay(key, context), + right: resolveDisplay(value, context), })); const bodyRows = rows.length > 0 ? rows : [ { - left: resolveInlineDisplay(null, context.elementsById), - right: resolveInlineDisplay(null, context.elementsById), + left: resolveDisplay(null, context), + right: resolveDisplay(null, context), }, ]; const label = getObjectLabel("dict", getIdLabel(element)); const leftWidth = Math.max( 50, - ...bodyRows.map((row) => measureText(row.left.label, 14) + 12) + ...bodyRows.map((row) => + measureText(getRenderedDisplayLabel(row.left, context), 14) + 12 + ) ); const rightWidth = Math.max( 50, - ...bodyRows.map((row) => measureText(row.right.label, 14) + 12) + ...bodyRows.map((row) => + measureText(getRenderedDisplayLabel(row.right, context), 14) + 12 + ) ); const dividerGap = 12; const width = Math.max( @@ -472,50 +578,34 @@ function renderDictBox( appendLine(svg, 0, rowTop, width, rowTop); } - appendText( - svg, - row.left.label, - leftColumnWidth / 2, - rowTop + ROW_HEIGHT / 2, - { - anchor: "middle", - fill: getReferenceColor(row.left), - fontSize: 14, - } - ); - appendText( - svg, - row.right.label, - rightStart + rightColumnWidth / 2, - rowTop + ROW_HEIGHT / 2, - { - anchor: "middle", - fill: getReferenceColor(row.right), - fontSize: 14, - } - ); - - if (isClickableReference(row.left)) { - appendReferenceHitRect( - svg, - 0, - rowTop, - leftColumnWidth, - ROW_HEIGHT, - row.left.targetId - ); - } - - if (isClickableReference(row.right)) { - appendReferenceHitRect( - svg, - rightStart, - rowTop, - rightColumnWidth, - ROW_HEIGHT, - row.right.targetId - ); - } + appendDisplayValue(svg, row.left, context, { + x: leftColumnWidth / 2, + y: rowTop + ROW_HEIGHT / 2, + anchor: "middle", + fontSize: 14, + markerX: leftColumnWidth / 2, + markerY: rowTop + ROW_HEIGHT / 2, + hitRect: { + x: 0, + y: rowTop, + width: leftColumnWidth, + height: ROW_HEIGHT, + }, + }); + appendDisplayValue(svg, row.right, context, { + x: rightStart + rightColumnWidth / 2, + y: rowTop + ROW_HEIGHT / 2, + anchor: "middle", + fontSize: 14, + markerX: rightStart + rightColumnWidth / 2, + markerY: rowTop + ROW_HEIGHT / 2, + hitRect: { + x: rightStart, + y: rowTop, + width: rightColumnWidth, + height: ROW_HEIGHT, + }, + }); }); return svg; @@ -525,12 +615,12 @@ function renderKeyValueObjectBox( title: string, idLabel: string, rows: KeyValueRow[], - renderMode: RenderMode = "canvas" + context: RenderContext ): SVGSVGElement { const bodyRows = rows.length > 0 ? rows - : [{ left: "", right: resolveInlineDisplay(null) }]; + : [{ left: "", right: resolveDisplay(null, context) }]; const label = getObjectLabel(title, idLabel); const leftWidth = Math.max( 52, @@ -538,10 +628,12 @@ function renderKeyValueObjectBox( ); const rightWidth = Math.max( 58, - ...bodyRows.map((row) => measureText(row.right.label, 14) + 14) + ...bodyRows.map((row) => + measureText(getRenderedDisplayLabel(row.right, context), 14) + 14 + ) ); const width = Math.max( - renderMode === "palette" ? 170 : 144, + context.renderMode === "palette" ? 170 : 144, leftWidth + rightWidth + PADDING_X * 2, measureText(label, LABEL_FONT_SIZE) + 8 ); @@ -564,27 +656,19 @@ function renderKeyValueObjectBox( fill: COLORS.bodyText, fontSize: 14, }); - appendText( - svg, - row.right.label, - dividerX + 8, - rowTop + ROW_HEIGHT / 2, - { - fill: getReferenceColor(row.right), - fontSize: 14, - } - ); - - if (isClickableReference(row.right)) { - appendReferenceHitRect( - svg, - dividerX, - rowTop, - width - dividerX, - ROW_HEIGHT, - row.right.targetId - ); - } + appendDisplayValue(svg, row.right, context, { + x: dividerX + 8, + y: rowTop + ROW_HEIGHT / 2, + fontSize: 14, + markerX: dividerX + (width - dividerX) / 2, + markerY: rowTop + ROW_HEIGHT / 2, + hitRect: { + x: dividerX, + y: rowTop, + width: width - dividerX, + height: ROW_HEIGHT, + }, + }); }); return svg; @@ -596,18 +680,20 @@ function renderClassBox( ): SVGSVGElement { const rows = (element.kind.classVariables || []).map((variable) => ({ left: variable.name, - right: resolveInlineDisplay(variable.targetId, context.elementsById), + right: resolveDisplay(variable.targetId, context), })); return renderKeyValueObjectBox( element.kind.className || "object", getIdLabel(element), rows, - context.renderMode + context ); } -function getFunctionSignature(element: CanvasElement & { kind: FunctionKind }): string { +function getFunctionSignature( + element: CanvasElement & { kind: FunctionKind } +): string { const name = element.kind.functionName || "function"; const params = (element.kind.params || []) .map((param) => param.name || "_") @@ -655,7 +741,10 @@ export function createPythonTutorBoxRenderer( element as CanvasElement & { kind: FunctionKind }, context ) - : renderFrameBox(element as CanvasElement & { kind: FunctionKind }, context); + : renderFrameBox( + element as CanvasElement & { kind: FunctionKind }, + context + ); case "list": case "tuple": case "set": diff --git a/frontend/src/features/canvasControls/CanvasControls.module.css b/frontend/src/features/canvasControls/CanvasControls.module.css index 03bb9eb..8e5c6d0 100644 --- a/frontend/src/features/canvasControls/CanvasControls.module.css +++ b/frontend/src/features/canvasControls/CanvasControls.module.css @@ -135,6 +135,11 @@ min-width: 0; } +.nestedControlItem { + padding-left: 20px; + padding-top: 8px; +} + .controlLabel { font-size: 0.9rem; font-weight: 500; diff --git a/frontend/src/features/canvasControls/CanvasControls.test.tsx b/frontend/src/features/canvasControls/CanvasControls.test.tsx new file mode 100644 index 0000000..ab1e212 --- /dev/null +++ b/frontend/src/features/canvasControls/CanvasControls.test.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import CanvasControls from "./CanvasControls"; +import { ThemeProvider } from "../../contexts/ThemeContext"; + +function renderControls( + overrides: Partial> = {} +) { + return render( + + + + ); +} + +function openSettingsTab() { + fireEvent.click(screen.getByRole("button", { name: /settings/i })); +} + +describe("CanvasControls", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("shows the reference arrows toggle only in Python Tutor mode", () => { + const { rerender } = renderControls(); + openSettingsTab(); + + expect(screen.queryByText("Reference Arrows")).not.toBeInTheDocument(); + expect(screen.queryByText("Standalone Primitives")).not.toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByText("Reference Arrows")).toBeInTheDocument(); + expect(screen.getByText("Standalone Primitives")).toBeInTheDocument(); + }); + + it("toggles the standalone primitives switch when enabled in Python Tutor mode", () => { + const handleStandalonePrimitivesChange = jest.fn(); + + renderControls({ + visualStyle: "pythonTutor", + pythonTutorStandalonePrimitives: false, + onPythonTutorStandalonePrimitivesChange: + handleStandalonePrimitivesChange, + }); + openSettingsTab(); + + const controlRow = screen.getByText("Standalone Primitives").closest("div"); + expect(controlRow).not.toBeNull(); + + const toggle = within(controlRow as HTMLElement).getByRole("switch"); + fireEvent.click(toggle); + + expect(handleStandalonePrimitivesChange).toHaveBeenCalledWith(true); + }); + + it("toggles the reference arrows switch when enabled in Python Tutor mode", () => { + const handleReferenceArrowChange = jest.fn(); + + renderControls({ + visualStyle: "pythonTutor", + pythonTutorReferenceArrows: false, + onPythonTutorReferenceArrowsChange: handleReferenceArrowChange, + }); + openSettingsTab(); + + const controlRow = screen.getByText("Reference Arrows").closest("div"); + expect(controlRow).not.toBeNull(); + + const toggle = within(controlRow as HTMLElement).getByRole("switch"); + fireEvent.click(toggle); + + expect(handleReferenceArrowChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/frontend/src/features/canvasControls/CanvasControls.tsx b/frontend/src/features/canvasControls/CanvasControls.tsx index 3dec321..f56d592 100644 --- a/frontend/src/features/canvasControls/CanvasControls.tsx +++ b/frontend/src/features/canvasControls/CanvasControls.tsx @@ -24,6 +24,10 @@ interface CanvasControlsProps { onEditorScaleChange?: (scale: number) => void; visualStyle?: VisualStyle; onVisualStyleChange?: (style: VisualStyle) => void; + pythonTutorReferenceArrows?: boolean; + onPythonTutorReferenceArrowsChange?: (value: boolean) => void; + pythonTutorStandalonePrimitives?: boolean; + onPythonTutorStandalonePrimitivesChange?: (value: boolean) => void; } type ControlTab = "actions" | "view" | "settings"; @@ -65,6 +69,10 @@ export default function CanvasControls({ onEditorScaleChange, visualStyle = "memoryviz", onVisualStyleChange, + pythonTutorReferenceArrows = false, + onPythonTutorReferenceArrowsChange, + pythonTutorStandalonePrimitives = false, + onPythonTutorStandalonePrimitivesChange, }: CanvasControlsProps) { const { theme, toggleTheme } = useTheme(); const isDarkMode = theme === 'dark'; @@ -205,6 +213,68 @@ export default function CanvasControls({ )} + {visualStyle === "pythonTutor" && + onPythonTutorStandalonePrimitivesChange && ( +
+ + +
+ )} + + {visualStyle === "pythonTutor" && + onPythonTutorReferenceArrowsChange && ( +
+ + +
+ )} +
diff --git a/frontend/src/features/editors/boxEditor/BoxEditor.tsx b/frontend/src/features/editors/boxEditor/BoxEditor.tsx index fbf6ecd..d53e3ba 100644 --- a/frontend/src/features/editors/boxEditor/BoxEditor.tsx +++ b/frontend/src/features/editors/boxEditor/BoxEditor.tsx @@ -42,6 +42,7 @@ const BoxEditorModule = ({ elements = [], questionFunctionNames, visualStyle = "memoryviz", + pythonTutorStandalonePrimitives = false, onElementsChange, }: BoxEditorType) => { // Shared hover state for remove button @@ -156,6 +157,7 @@ const BoxEditorModule = ({ sandbox={sandbox} elements={elements} visualStyle={visualStyle} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onCommitKind={commitElementKind} onElementsChange={onElementsChange} /> diff --git a/frontend/src/features/editors/boxEditor/Content.tsx b/frontend/src/features/editors/boxEditor/Content.tsx index 500077c..3af9e01 100644 --- a/frontend/src/features/editors/boxEditor/Content.tsx +++ b/frontend/src/features/editors/boxEditor/Content.tsx @@ -29,6 +29,7 @@ interface Props { sandbox: boolean; elements?: any[]; // All canvas elements for ID usage tracking visualStyle?: VisualStyle; + pythonTutorStandalonePrimitives?: boolean; onCommitKind?: (kind: BoxType) => void; onElementsChange?: Dispatch>; } @@ -63,6 +64,7 @@ const Content = ({ sandbox, elements = [], visualStyle = "memoryviz", + pythonTutorStandalonePrimitives = false, onCommitKind, onElementsChange, }: Props) => { @@ -88,6 +90,7 @@ const Content = ({ elements={elements} ownerElement={metadata} visualStyle={visualStyle} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onCommitKind={onCommitKind} onElementsChange={onElementsChange} /> @@ -108,6 +111,7 @@ const Content = ({ elements={elements} ownerElement={metadata} visualStyle={visualStyle} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onCommitKind={onCommitKind} onElementsChange={onElementsChange} /> @@ -128,6 +132,7 @@ const Content = ({ elements={elements} ownerElement={metadata} visualStyle={visualStyle} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onCommitKind={onCommitKind} onElementsChange={onElementsChange} /> @@ -148,6 +153,7 @@ const Content = ({ elements={elements} ownerElement={metadata} visualStyle={visualStyle} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onCommitKind={onCommitKind} onElementsChange={onElementsChange} /> diff --git a/frontend/src/features/editors/boxEditor/PythonTutorPrimitiveMode.test.tsx b/frontend/src/features/editors/boxEditor/PythonTutorPrimitiveMode.test.tsx new file mode 100644 index 0000000..b0a8795 --- /dev/null +++ b/frontend/src/features/editors/boxEditor/PythonTutorPrimitiveMode.test.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import FunctionContent from "./functionBoxes/FunctionContent"; +import ClassContent from "./classBoxes/ClassContent"; +import CollectionItem from "./collectionBoxes/CollectionItem"; +import { CanvasElement } from "../../shared/types"; + +jest.mock("../idEditor/IdEditor", () => ({ + __esModule: true, + default: ({ currentId }: { currentId: number | string | null }) => ( +
{String(currentId)}
+ ), +})); + +jest.mock("./InlineTargetEditor", () => ({ + __esModule: true, + default: () =>
, +})); + +const baseOwnerElement: CanvasElement = { + boxId: 1, + id: "_", + x: 0, + y: 0, + kind: { + name: "function", + type: "function", + value: null, + functionName: "__main__", + params: [], + }, +}; + +describe("Python Tutor standalone primitives editor mode", () => { + it("uses the inline target editor for function params in inline mode", () => { + render( + + ); + + expect(screen.getByTestId("inline-target-editor")).toBeInTheDocument(); + expect(screen.queryByTestId("id-selector")).toBeNull(); + }); + + it("uses the normal ID selector for class variables in standalone mode", () => { + render( + + ); + + expect(screen.getByTestId("id-selector")).toBeInTheDocument(); + expect(screen.queryByTestId("inline-target-editor")).toBeNull(); + }); + + it("uses the normal ID selector for collection items in standalone mode", () => { + render( + + ); + + expect(screen.getByTestId("id-selector")).toBeInTheDocument(); + expect(screen.queryByTestId("inline-target-editor")).toBeNull(); + }); + + it("keeps the inline target editor for collection items in inline mode", () => { + render( + + ); + + expect(screen.getByTestId("inline-target-editor")).toBeInTheDocument(); + expect(screen.queryByTestId("id-selector")).toBeNull(); + }); +}); diff --git a/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx b/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx index 5fd3312..1014063 100644 --- a/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx +++ b/frontend/src/features/editors/boxEditor/classBoxes/ClassContent.tsx @@ -27,6 +27,7 @@ interface Props { elements?: any[]; // All canvas elements for ID usage tracking ownerElement: CanvasElement; visualStyle?: VisualStyle; + pythonTutorStandalonePrimitives?: boolean; onCommitKind?: (kind: BoxType) => void; onElementsChange?: Dispatch>; } @@ -53,9 +54,13 @@ const ClassContent = ({ elements = [], ownerElement, visualStyle = "memoryviz", + pythonTutorStandalonePrimitives = false, onCommitKind, onElementsChange, }: Props) => { + const useInlineTargetEditor = + visualStyle === "pythonTutor" && !pythonTutorStandalonePrimitives; + // Add a new empty variable to the list const addVariable = () => setVariables([...classVariables, { name: "", targetId: "_" }]); @@ -104,7 +109,7 @@ const ClassContent = ({ className={styles.variableNameBox} />
- {visualStyle === "pythonTutor" ? ( + {useInlineTargetEditor ? ( void; onElementsChange?: Dispatch>; } @@ -49,6 +50,7 @@ const CollectionContent = ({ elements = [], ownerElement, visualStyle = "memoryviz", + pythonTutorStandalonePrimitives = false, onCommitKind, onElementsChange, }: Props) => { @@ -66,6 +68,7 @@ const CollectionContent = ({ elements={elements} ownerElement={ownerElement} visualStyle={visualStyle} + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} onCommitKind={onCommitKind} onElementsChange={onElementsChange} /> diff --git a/frontend/src/features/editors/boxEditor/collectionBoxes/CollectionItem.tsx b/frontend/src/features/editors/boxEditor/collectionBoxes/CollectionItem.tsx index d79966b..389b5d3 100644 --- a/frontend/src/features/editors/boxEditor/collectionBoxes/CollectionItem.tsx +++ b/frontend/src/features/editors/boxEditor/collectionBoxes/CollectionItem.tsx @@ -27,6 +27,7 @@ interface Props { elements?: any[]; // All canvas elements for ID usage tracking ownerElement: CanvasElement; visualStyle?: VisualStyle; + pythonTutorStandalonePrimitives?: boolean; onCommitKind?: (kind: BoxType) => void; onElementsChange?: Dispatch>; } @@ -47,9 +48,13 @@ const CollectionItem = ({ elements = [], ownerElement, visualStyle = "memoryviz", + pythonTutorStandalonePrimitives = false, onCommitKind, onElementsChange, }: Props) => { + const useInlineTargetEditor = + visualStyle === "pythonTutor" && !pythonTutorStandalonePrimitives; + const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx)); @@ -80,7 +85,7 @@ const CollectionItem = ({ const fieldErrors = getErrorsForId(validationErrors, itemId); return (
- {visualStyle === "pythonTutor" ? ( + {useInlineTargetEditor ? ( {/* KEY ID */}
- {visualStyle === "pythonTutor" ? ( + {useInlineTargetEditor ? ( - {visualStyle === "pythonTutor" ? ( + {useInlineTargetEditor ? ( void; onElementsChange?: Dispatch>; } @@ -46,9 +47,13 @@ const FunctionContent = ({ elements = [], ownerElement, visualStyle = "memoryviz", + pythonTutorStandalonePrimitives = false, onCommitKind, onElementsChange, }: Props) => { + const useInlineTargetEditor = + visualStyle === "pythonTutor" && !pythonTutorStandalonePrimitives; + // Add a new empty parameter to the list const addParam = () => setParams([...functionParams, { name: "", targetId: "_" }]); @@ -97,7 +102,7 @@ const FunctionContent = ({ className={styles.variableNameBox} />
- {visualStyle === "pythonTutor" ? ( + {useInlineTargetEditor ? (
@@ -518,6 +530,10 @@ export default function MemoryModelEditor({ onScaleChange={setCanvasScale} editorScale={editorScale} visualStyle={state.visualStyle} + pythonTutorReferenceArrows={state.pythonTutorReferenceArrows} + pythonTutorStandalonePrimitives={ + state.pythonTutorStandalonePrimitives + } questionFunctionNames={ state.isSandboxMode && currentQuestionData ? getQuestionFunctionNames(currentQuestionData) diff --git a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts index b499921..5117fc5 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts @@ -20,6 +20,8 @@ export function useUILocalStorage(state: UIState): void { state.submissionResults, state.sandboxMode, state.visualStyle, + state.pythonTutorReferenceArrows, + state.pythonTutorStandalonePrimitives, state.questionView, state.isInfoPanelOpen, state.canvasScale, diff --git a/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts b/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts index b95f8dc..b5f25e6 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts @@ -53,6 +53,10 @@ export function useMemoryModelEditorState(sandbox: boolean) { const [visualStyle, setVisualStyle] = useState( initialUIData.visualStyle ?? "memoryviz" ); + const [pythonTutorReferenceArrows, setPythonTutorReferenceArrows] = + useState(initialUIData.pythonTutorReferenceArrows ?? false); + const [pythonTutorStandalonePrimitives, setPythonTutorStandalonePrimitives] = + useState(initialUIData.pythonTutorStandalonePrimitives ?? false); const [questionView, setQuestionView] = useState( initialUIData.questionView ?? "root" ); @@ -107,6 +111,10 @@ export function useMemoryModelEditorState(sandbox: boolean) { setIsSandboxMode, visualStyle, setVisualStyle, + pythonTutorReferenceArrows, + setPythonTutorReferenceArrows, + pythonTutorStandalonePrimitives, + setPythonTutorStandalonePrimitives, questionView, setQuestionView, tabScrollPositions, diff --git a/frontend/src/features/memoryModelEditor/utils/localStorage.test.ts b/frontend/src/features/memoryModelEditor/utils/localStorage.test.ts new file mode 100644 index 0000000..7a95a72 --- /dev/null +++ b/frontend/src/features/memoryModelEditor/utils/localStorage.test.ts @@ -0,0 +1,67 @@ +import { loadInitialUIData, saveUIState } from "./localStorage"; + +describe("localStorage UI state", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults pythonTutorReferenceArrows to false when missing", () => { + localStorage.setItem( + "canvas_ui_state_v3", + JSON.stringify({ + activeTab: "question", + questionIndex: null, + questionType: null, + submissionResults: null, + sandboxMode: false, + visualStyle: "pythonTutor", + }) + ); + + expect(loadInitialUIData().pythonTutorReferenceArrows).toBe(false); + }); + + it("defaults pythonTutorStandalonePrimitives to false when missing", () => { + localStorage.setItem( + "canvas_ui_state_v3", + JSON.stringify({ + activeTab: "question", + questionIndex: null, + questionType: null, + submissionResults: null, + sandboxMode: false, + visualStyle: "pythonTutor", + }) + ); + + expect(loadInitialUIData().pythonTutorStandalonePrimitives).toBe(false); + }); + + it("round-trips pythonTutorReferenceArrows when persisted", () => { + saveUIState({ + activeTab: "question", + questionIndex: null, + questionType: null, + submissionResults: null, + sandboxMode: false, + visualStyle: "pythonTutor", + pythonTutorReferenceArrows: true, + }); + + expect(loadInitialUIData().pythonTutorReferenceArrows).toBe(true); + }); + + it("round-trips pythonTutorStandalonePrimitives when persisted", () => { + saveUIState({ + activeTab: "question", + questionIndex: null, + questionType: null, + submissionResults: null, + sandboxMode: false, + visualStyle: "pythonTutor", + pythonTutorStandalonePrimitives: true, + }); + + expect(loadInitialUIData().pythonTutorStandalonePrimitives).toBe(true); + }); +}); diff --git a/frontend/src/features/memoryModelEditor/utils/localStorage.ts b/frontend/src/features/memoryModelEditor/utils/localStorage.ts index a4a43c5..f93231e 100644 --- a/frontend/src/features/memoryModelEditor/utils/localStorage.ts +++ b/frontend/src/features/memoryModelEditor/utils/localStorage.ts @@ -28,6 +28,8 @@ const DEFAULT_UI_STATE = { submissionResults: null as SubmissionResult | null, sandboxMode: null as boolean | null, visualStyle: "memoryviz" as VisualStyle, + pythonTutorReferenceArrows: false, + pythonTutorStandalonePrimitives: false, }; export interface CanvasData { @@ -61,6 +63,8 @@ export interface UIState { submissionResults: SubmissionResult | null; sandboxMode: boolean | null; visualStyle?: VisualStyle; + pythonTutorReferenceArrows?: boolean; + pythonTutorStandalonePrimitives?: boolean; questionView?: QuestionView; isInfoPanelOpen?: boolean; canvasScale?: number; @@ -121,6 +125,10 @@ export function loadInitialUIData(): UIState { typeof parsed?.sandboxMode === "boolean" ? parsed.sandboxMode : null; const visualStyle = normalizeVisualStyle(parsed?.visualStyle); + const pythonTutorReferenceArrows = + parsed?.pythonTutorReferenceArrows === true; + const pythonTutorStandalonePrimitives = + parsed?.pythonTutorStandalonePrimitives === true; const validViews = ["root", "loading", "test", "list", "question", "practice", "prep"]; const questionView = @@ -152,6 +160,8 @@ export function loadInitialUIData(): UIState { submissionResults, sandboxMode, visualStyle, + pythonTutorReferenceArrows, + pythonTutorStandalonePrimitives, questionView, isInfoPanelOpen, canvasScale, diff --git a/frontend/src/features/palette/Palette.test.tsx b/frontend/src/features/palette/Palette.test.tsx new file mode 100644 index 0000000..b8aae92 --- /dev/null +++ b/frontend/src/features/palette/Palette.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import Palette from "./Palette"; + +jest.mock("./components/PaletteBox", () => ({ + __esModule: true, + default: ({ boxType }: { boxType: string }) => ( +
{boxType}
+ ), +})); + +jest.mock("../canvasControls/CanvasControls", () => ({ + __esModule: true, + default: () =>
, +})); + +class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} +} + +describe("Palette Python Tutor primitive mode", () => { + beforeAll(() => { + (global as typeof globalThis).ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + }); + + it("hides primitive palette boxes in inline mode and shows them in standalone mode", () => { + const { rerender } = render( + + ); + + expect(screen.queryAllByTestId("palette-box")).toHaveLength(0); + expect( + screen.getByText("Primitive values are created inline in Python Tutor mode.") + ).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByText(/created inline/i)).toBeNull(); + expect(screen.getAllByTestId("palette-box")).toHaveLength(5); + }); +}); diff --git a/frontend/src/features/palette/Palette.tsx b/frontend/src/features/palette/Palette.tsx index 20cb7df..1b9a77c 100644 --- a/frontend/src/features/palette/Palette.tsx +++ b/frontend/src/features/palette/Palette.tsx @@ -74,6 +74,10 @@ interface PaletteProps { onEditorScaleChange?: (scale: number) => void; visualStyle?: VisualStyle; onVisualStyleChange?: (style: VisualStyle) => void; + pythonTutorReferenceArrows?: boolean; + onPythonTutorReferenceArrowsChange?: (value: boolean) => void; + pythonTutorStandalonePrimitives?: boolean; + onPythonTutorStandalonePrimitivesChange?: (value: boolean) => void; } // Extract TabButton component inline @@ -106,9 +110,10 @@ function filterBoxesByRequired( function filterBoxesByVisualStyle( boxes: readonly BoxTypeName[], - visualStyle: VisualStyle + visualStyle: VisualStyle, + pythonTutorStandalonePrimitives: boolean ): BoxTypeName[] { - if (visualStyle !== "pythonTutor") { + if (visualStyle !== "pythonTutor" || pythonTutorStandalonePrimitives) { return [...boxes]; } @@ -134,6 +139,10 @@ export default function Palette({ onEditorScaleChange, visualStyle = "memoryviz", onVisualStyleChange, + pythonTutorReferenceArrows = false, + onPythonTutorReferenceArrowsChange, + pythonTutorStandalonePrimitives = false, + onPythonTutorStandalonePrimitivesChange, }: PaletteProps) { const allBoxes = TAB_BOX_MAPPING[activeTab]; @@ -141,7 +150,11 @@ export default function Palette({ isPracticeMode && requiredBoxes ? filterBoxesByRequired(allBoxes, requiredBoxes) : allBoxes; - const visibleBoxes = filterBoxesByVisualStyle(boxes, visualStyle); + const visibleBoxes = filterBoxesByVisualStyle( + boxes, + visualStyle, + pythonTutorStandalonePrimitives + ); const { topHeight, handleMouseDown, containerRef } = useResizable({ initialTopPercent: 60, @@ -215,7 +228,8 @@ export default function Palette({ )) ) : (

- {visualStyle === "pythonTutor" + {visualStyle === "pythonTutor" && + !pythonTutorStandalonePrimitives ? "Primitive values are created inline in Python Tutor mode." : "No boxes available in this tab."}

@@ -253,6 +267,14 @@ export default function Palette({ onEditorScaleChange={onEditorScaleChange} visualStyle={visualStyle} onVisualStyleChange={onVisualStyleChange} + pythonTutorReferenceArrows={pythonTutorReferenceArrows} + onPythonTutorReferenceArrowsChange={ + onPythonTutorReferenceArrowsChange + } + pythonTutorStandalonePrimitives={pythonTutorStandalonePrimitives} + onPythonTutorStandalonePrimitivesChange={ + onPythonTutorStandalonePrimitivesChange + } />
diff --git a/frontend/src/features/shared/types.ts b/frontend/src/features/shared/types.ts index 254f2ec..f222ee8 100644 --- a/frontend/src/features/shared/types.ts +++ b/frontend/src/features/shared/types.ts @@ -173,6 +173,7 @@ export interface BoxEditorType { elements?: any[]; questionFunctionNames?: string[]; visualStyle?: VisualStyle; + pythonTutorStandalonePrimitives?: boolean; onElementsChange?: Dispatch>; }