diff --git a/backend/src/features/validationServices/errorMessages.ts b/backend/src/features/validationServices/errorMessages.ts index d289e40..00e0b72 100644 --- a/backend/src/features/validationServices/errorMessages.ts +++ b/backend/src/features/validationServices/errorMessages.ts @@ -13,6 +13,13 @@ export const ERROR_MESSAGES = { // Pattern: "head.next and second should point to the same object" reference_should_match: (pathA: string, pathB: string) => `${pathA} and ${pathB} should point to the same object`, + + // Pattern: "third.next should point to the same None object as head.next" + reference_should_match_with_expected: ( + targetPath: string, + sourcePath: string, + expectedLabel: string + ) => `${targetPath} should point to the same ${expectedLabel} as ${sourcePath}`, // Pattern: "head.next and second should not point to the same object" reference_should_differ: (pathA: string, pathB: string) => diff --git a/backend/src/features/validationServices/validateAnswer.ts b/backend/src/features/validationServices/validateAnswer.ts index b0d9847..8ccf910 100644 --- a/backend/src/features/validationServices/validateAnswer.ts +++ b/backend/src/features/validationServices/validateAnswer.ts @@ -114,6 +114,16 @@ function extractPathElementIds( const idMap = (m: MemoryBox[]) => new Map(m.filter((e) => e.id !== null).map((e) => [e.id as number, e])); +function describeExpectedReference(answerID: number, answerMap: Map): string { + const answerBox = answerMap.get(answerID); + if (!answerBox) return "object"; + if (isNoneType(answerBox.type) || isNoneValue(answerBox.value)) return "None object"; + if (isClassInstanceType(answerBox.type)) { + return answerBox.name ? `${answerBox.name} object` : "object"; + } + return `${formatTypeForUser(answerBox.type)} object`; +} + // Ensure a bijection between answer and input IDs function ensureBijection( answerID: number, @@ -139,9 +149,12 @@ function ensureBijection( if (prev.target !== inputID) { if (!isVar) { const previousPath = cleanPathForUser(prev.path); - const answerBox = answerMap.get(answerID); - - const message = ERROR_MESSAGES.reference_should_match(previousPath, currentPath); + const expectedLabel = describeExpectedReference(answerID, answerMap); + const message = ERROR_MESSAGES.reference_should_match_with_expected( + currentPath, + previousPath, + expectedLabel + ); errors.push( makeFeedbackError(ErrorType.REFERENCE_MISMATCH, message, { diff --git a/frontend/src/features/canvas/Canvas.module.css b/frontend/src/features/canvas/Canvas.module.css index 811f745..805f3dd 100644 --- a/frontend/src/features/canvas/Canvas.module.css +++ b/frontend/src/features/canvas/Canvas.module.css @@ -3,11 +3,12 @@ display: flex; justify-content: center; align-items: flex-start; - padding: 12px 0px; - height: 100vh; + padding: 12px 0 0; + height: 100%; background-color: var(--bg-tertiary); box-sizing: border-box; transition: background-color 200ms ease; + position: relative; } /* Main SVG canvas */ @@ -16,10 +17,7 @@ height: 100%; display: block; background: var(--canvas-bg); - border: 1px solid var(--border-primary); - border-radius: 10px; - box-shadow: var(--shadow-sm); - transition: background-color 200ms ease, border-color 200ms ease; + transition: background-color 200ms ease; } /* Override memory-viz box background fills for light mode */ diff --git a/frontend/src/features/canvas/Canvas.tsx b/frontend/src/features/canvas/Canvas.tsx index a3723c2..c0eec36 100644 --- a/frontend/src/features/canvas/Canvas.tsx +++ b/frontend/src/features/canvas/Canvas.tsx @@ -189,6 +189,8 @@ interface CanvasProps { pythonTutorReferenceArrows?: boolean; pythonTutorStandalonePrimitives?: boolean; isQuestionMode?: boolean; + preserveCollapsedWorkspace?: boolean; + workspaceHeightOffset?: number; } function Canvas({ @@ -211,6 +213,8 @@ function Canvas({ pythonTutorReferenceArrows = false, pythonTutorStandalonePrimitives = false, isQuestionMode = false, + preserveCollapsedWorkspace = false, + workspaceHeightOffset = 0, }: CanvasProps) { const [openEditors, setOpenEditors] = useState([]); const [selectedElement, setSelectedElement] = useState( @@ -291,53 +295,74 @@ function Canvas({ [svgRef] ); + 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 !( + useInlinePythonTutorPrimitives && el.kind.name === "primitive" + ); + }), + [elements, useInlinePythonTutorPrimitives] + ); + // Canvas sizing and viewBox management (pre-paint to avoid flicker) useLayoutEffect(() => { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const height = Math.max(1, rect.height); + const wrapper = wrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); const width = Math.max(1, rect.width); - setCanvasHeight(height); - baseDims.current = { width, height }; - applyViewBox(width, height, scale); - }, [svgRef, scale, applyViewBox]); - - // Handle canvas width changes while preserving height + const viewportHeight = Math.max(1, rect.height); + const renderedHeight = preserveCollapsedWorkspace + ? Math.max(viewportHeight + workspaceHeightOffset, viewportHeight) + : viewportHeight; + setCanvasHeight(viewportHeight); + baseDims.current = { width, height: renderedHeight }; + applyViewBox(width, renderedHeight, scale); + }, [scale, applyViewBox, preserveCollapsedWorkspace, workspaceHeightOffset]); + + // Handle canvas size changes so vertical split panels can safely resize + // the workspace without leaving the SVG on a stale height. useEffect(() => { - if (!canvasHeight) return; - - const svg = svgRef.current; - if (!svg) return; + const wrapper = wrapperRef.current; + if (!wrapper) return; let animationFrame = 0; let previousWidth = -1; + let previousHeight = -1; - const updateWidth = () => { - const width = Math.max( - 1, - svg.clientWidth || svg.getBoundingClientRect().width - ); + const updateSize = () => { + const rect = wrapper.getBoundingClientRect(); + const width = Math.max(1, rect.width); + const viewportHeight = Math.max(1, rect.height); + const renderedHeight = preserveCollapsedWorkspace + ? Math.max(viewportHeight + workspaceHeightOffset, viewportHeight) + : viewportHeight; - if (width !== previousWidth) { + if (width !== previousWidth || renderedHeight !== previousHeight) { previousWidth = width; - baseDims.current = { width, height: canvasHeight }; - applyViewBox(width, canvasHeight, scale); + previousHeight = renderedHeight; + setCanvasHeight(viewportHeight); + baseDims.current = { width, height: renderedHeight }; + applyViewBox(width, renderedHeight, scale); } }; const resizeObserver = new ResizeObserver(() => { cancelAnimationFrame(animationFrame); - animationFrame = requestAnimationFrame(updateWidth); + animationFrame = requestAnimationFrame(updateSize); }); - resizeObserver.observe(svg); + resizeObserver.observe(wrapper); + updateSize(); return () => { cancelAnimationFrame(animationFrame); resizeObserver.disconnect(); }; - }, [canvasHeight, svgRef, scale, applyViewBox]); + }, [scale, applyViewBox, preserveCollapsedWorkspace, workspaceHeightOffset]); // Re-apply viewBox when scale changes useEffect(() => { @@ -585,19 +610,6 @@ function Canvas({ return () => window.removeEventListener("keydown", handleKeyDown); }, [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 !( - useInlinePythonTutorPrimitives && el.kind.name === "primitive" - ); - }), - [elements, useInlinePythonTutorPrimitives] - ); - useEffect(() => { if (!useInlinePythonTutorPrimitives) { return; @@ -665,7 +677,10 @@ function Canvas({
void; onReorder: (fromIndex: number, toIndex: number) => void; onWidthChange?: (width: number) => void; + onBoundsChange?: (bounds: { top: number; bottom: number }) => void; x?: number; y?: number; width?: number; @@ -73,6 +74,7 @@ const CallStack: React.FC = ({ onSelect, onReorder, onWidthChange, + onBoundsChange, x = 20, y = 73, width = 205, @@ -83,7 +85,7 @@ const CallStack: React.FC = ({ elementsById, }) => { const clipPathId = useId(); - + const [viewportHeight] = useState(() => window.innerHeight); const yPosition = y; @@ -211,6 +213,33 @@ const CallStack: React.FC = ({ [layout, scrollPosition] ); + useEffect(() => { + const clipTop = yPosition + HEADER_HEIGHT; + const clipBottom = + yPosition + HEADER_HEIGHT + TOP_PADDING + visibleHeight + BOTTOM_PADDING; + + const visibleFrameBottoms = layout + .map(({ yLocal, h }) => { + const frameTop = yLocal + scrollPosition + VERTICAL_OFFSET - h / 2; + const frameBottom = yLocal + scrollPosition + VERTICAL_OFFSET + h / 2; + + if (frameBottom < clipTop || frameTop > clipBottom) { + return null; + } + + return Math.min(frameBottom, clipBottom); + }) + .filter((value): value is number => value !== null); + + onBoundsChange?.({ + top: yPosition, + bottom: + visibleFrameBottoms.length > 0 + ? Math.max(...visibleFrameBottoms) + : yPosition, + }); + }, [layout, onBoundsChange, scrollPosition, visibleHeight, yPosition]); + const handlePointerDown = useCallback( (index: number) => (event: React.PointerEvent) => { if (isLockedMainFrame(orderedFrames[index])) { diff --git a/frontend/src/features/canvas/components/LinkedListPreview.module.css b/frontend/src/features/canvas/components/LinkedListPreview.module.css new file mode 100644 index 0000000..a78fe5a --- /dev/null +++ b/frontend/src/features/canvas/components/LinkedListPreview.module.css @@ -0,0 +1,105 @@ +.arrowFill { + fill: var(--text-primary); +} + +.diagramScroller { + overflow-x: auto; + overflow-y: hidden; +} + +.diagram { + display: block; + width: auto; + min-width: 100%; + height: auto; + max-width: none; +} + +.label { + fill: var(--text-primary); + font-size: 13px; + font-weight: 600; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.labelArrow { + stroke: var(--text-primary); + stroke-width: 1; +} + +.nextLabel { + fill: var(--text-primary); + font-size: 11px; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.brokenLabel { + fill: var(--text-tertiary); + font-size: 10px; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.nodeRect { + fill: var(--bg-primary); + stroke: var(--text-primary); + stroke-width: 1.5; +} + +.divider { + stroke: var(--text-primary); + stroke-width: 1.5; +} + +.dot { + fill: var(--text-primary); +} + +.nodeValue { + fill: var(--text-primary); + font-size: 15px; + font-weight: 500; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.linkLine { + stroke: var(--text-primary); + stroke-width: 1.5; +} + +.brokenLine { + stroke: var(--text-primary); + stroke-width: 1.75; + stroke-linecap: round; +} + +.cycleLine { + stroke: var(--text-primary); + stroke-width: 1.75; +} + +.selfLoopLine { + stroke: var(--text-primary); + stroke-width: 1.75; +} + +.arrowHead { + fill: var(--text-primary); +} + +.cyclePath { + fill: none; + stroke: var(--accent-blue); + stroke-width: 2; + stroke-dasharray: 5 4; +} + +.footerText { + fill: var(--text-tertiary); + font-size: 12px; + font-style: italic; +} + +.list { + display: block; + width: 100%; +} diff --git a/frontend/src/features/canvas/components/LinkedListPreview.tsx b/frontend/src/features/canvas/components/LinkedListPreview.tsx new file mode 100644 index 0000000..9762efb --- /dev/null +++ b/frontend/src/features/canvas/components/LinkedListPreview.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useId, useRef, useState } from "react"; +import styles from "./LinkedListPreview.module.css"; +import { LinkedListGraph } from "../utils/linkedListDetector"; +import { + buildLinkedListGraphLayout, + getRectPerimeterPoint, + createEdgePath, + createReturnEdgePath, + createSelfLoopPath, +} from "../utils/linkedListRenderer"; + +interface LinkedListPreviewProps { + graph: LinkedListGraph; +} + +export default function LinkedListPreview({ graph }: LinkedListPreviewProps) { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(600); + const rawId = useId(); + const markerId = `ll-arrow-${rawId.replace(/:/g, "")}`; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + const w = entry.contentRect.width; + if (w > 0) setContainerWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + if (graph.nodes.length === 0) { + return null; + } + + const layout = buildLinkedListGraphLayout(graph, containerWidth); + + return ( +
+ + + + + + + + {layout.edges.map((edge) => { + const from = layout.byId[edge.fromId]; + const to = layout.byId[edge.toId]; + if (!from || !to) return null; + + let d: string; + let className = styles.linkLine; + + if (edge.kind === "self") { + d = createSelfLoopPath(from); + className = styles.selfLoopLine; + } else { + const start = { x: from.dotX, y: from.dotY }; + const end = getRectPerimeterPoint( + { x: to.x, y: to.y, width: to.width, height: to.height }, + start + ); + d = + edge.kind === "backward" + ? createReturnEdgePath(from, to) + : createEdgePath(start, end.point, end.side); + + if (edge.kind === "backward") { + className = styles.cycleLine; + } + } + + return ( + + ); + })} + + {layout.nodes.map((node) => ( + + {node.labels.map((label, labelIndex) => { + const lx = node.x + node.width / 2; + const labelY = + node.y - 16 - (node.labels.length - 1 - labelIndex) * 14; + const isBottomLabel = labelIndex === node.labels.length - 1; + + return ( + + + {label} + + {isBottomLabel && ( + + )} + + ); + })} + + + + + {node.value} + + + {node.nextKind === "missing" || node.nextKind === "invalid" || node.nextText === "•" ? ( + + ) : node.nextText === "None" ? ( + <> + + + None + + + ) : ( + + {node.nextText} + + )} + + ))} + +
+ ); +} diff --git a/frontend/src/features/canvas/components/StructurePanel.module.css b/frontend/src/features/canvas/components/StructurePanel.module.css new file mode 100644 index 0000000..6725ae0 --- /dev/null +++ b/frontend/src/features/canvas/components/StructurePanel.module.css @@ -0,0 +1,96 @@ +.dock { + flex: 0 0 auto; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 10px; + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: background-color 200ms ease, border-color 200ms ease; +} + +.resizeHandle { + height: var(--resize-handle-width, 8px); + flex: 0 0 auto; + cursor: ns-resize; + user-select: none; + -webkit-user-select: none; + position: relative; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.15s ease; +} +.resizeHandle:hover { + background: var(--bg-hover); +} +.resizeHandle::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: var(--resize-handle-hover-width, 4px); + background: transparent; + border-radius: 2px; + transition: background 0.15s ease; +} +.resizeHandle:hover::before { + background: var(--accent-blue); +} +.resizeHandle:active { + background: var(--bg-tertiary); +} +.resizeHandle:active::before { + background: var(--accent-blue-hover); +} + +.header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + border-bottom: 1px solid var(--border-secondary); +} + +.label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tabs { display: flex; gap: 4px; } +.tab { + all: unset; + cursor: pointer; + font-size: 0.78rem; + padding: 3px 10px; + border-radius: 6px; + color: var(--text-tertiary); +} +.tabActive { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.collapseBtn { + all: unset; + cursor: pointer; + padding: 2px 8px; + color: var(--text-tertiary); + font-size: 0.9rem; +} +.collapseBtn:hover { color: var(--text-primary); } + +.body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px; +} \ No newline at end of file diff --git a/frontend/src/features/canvas/components/StructurePanel.tsx b/frontend/src/features/canvas/components/StructurePanel.tsx new file mode 100644 index 0000000..9b3f500 --- /dev/null +++ b/frontend/src/features/canvas/components/StructurePanel.tsx @@ -0,0 +1,98 @@ +import React, { useMemo, useRef } from "react"; +import { CanvasElement } from "../../shared/types"; +import { createElementsByIdMap } from "../utils/pythonTutorReferences"; +import { detectLinkedListGraph } from "../utils/linkedListDetector"; +import LinkedListPreview from "./LinkedListPreview"; +import styles from "./StructurePanel.module.css"; + +interface StructurePanelProps { + enabled: boolean; + elements: CanvasElement[]; + collapsed: boolean; + height: number; + onCollapsedChange: (collapsed: boolean) => void; + onHeightChange: (height: number) => void; +} + +export default function StructurePanel({ + enabled, + elements, + collapsed, + height, + onCollapsedChange, + onHeightChange, +}: StructurePanelProps) { + const dragRef = useRef<{ y: number; h: number } | null>(null); + + const elementsById = useMemo(() => createElementsByIdMap(elements), [elements]); + + const graph = useMemo(() => { + if (!enabled) { + return { nodes: [], edges: [] }; + } + + return detectLinkedListGraph(elements, elementsById); + }, [enabled, elements, elementsById]); + + if (!enabled || graph.nodes.length === 0) return null; + + const onHandleDown = (e: React.MouseEvent) => { + e.preventDefault(); + dragRef.current = { y: e.clientY, h: height }; + document.body.style.userSelect = "none"; + + const onMove = (ev: MouseEvent) => { + if (!dragRef.current) return; + + const delta = dragRef.current.y - ev.clientY; + const next = Math.max( + 110, + Math.min(window.innerHeight * 0.45, dragRef.current.h + delta) + ); + + onHeightChange(next); + }; + + const onUp = () => { + dragRef.current = null; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + return ( + <> + {!collapsed && ( +
+ )} + +
+
+ Linked list visualization + + +
+ + {!collapsed && ( +
+ +
+ )} +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/features/canvas/utils/box.helpers.ts b/frontend/src/features/canvas/utils/box.helpers.ts index cfb037b..d2693cd 100644 --- a/frontend/src/features/canvas/utils/box.helpers.ts +++ b/frontend/src/features/canvas/utils/box.helpers.ts @@ -98,6 +98,28 @@ export function getSequenceHeight( return values.length > 0 ? filledHeight : emptyHeight; } +/** + * Calculates the height for class-like boxes whose body consists of named rows. + * This is used for frame and class boxes when we need a layout estimate before + * the SVG has been measured. + * + * @param rowCount - Number of visible rows in the box body + * @param emptyHeight - Height used when there are no rows + * @param rowHeight - Additional height contributed by each row + * @returns Estimated height in SVG units + */ +export function getNamedRowBoxHeight( + rowCount: number, + emptyHeight: number = 78, + rowHeight: number = 56 +): number { + if (rowCount <= 0) { + return emptyHeight; + } + + return emptyHeight + rowCount * rowHeight; +} + /** * Transforms function parameter data into MemoryViz-compatible format. * Handles duplicate parameter names using zero-width spaces for uniqueness. diff --git a/frontend/src/features/canvas/utils/linkedListDetector.ts b/frontend/src/features/canvas/utils/linkedListDetector.ts new file mode 100644 index 0000000..1d8c49a --- /dev/null +++ b/frontend/src/features/canvas/utils/linkedListDetector.ts @@ -0,0 +1,502 @@ +import { CanvasElement, ClassKind } from "../../shared/types"; +import { + createElementsByIdMap, + formatPrimitiveValue, + isPrimitiveElement, +} from "./pythonTutorReferences"; + +const PREFERRED_ROOT_LABELS = ["head", "first", "_first"]; +const VALUE_ATTRIBUTE_NAMES = ["item", "value", "data", "val", "_value"]; + +export type LinkedListNextKind = + | "node" + | "none" + | "missing" + | "invalid" + | "cycle"; + +export interface LinkedListNodeSnapshot { + nodeId: number; + className: string; + value: string; + labels: string[]; + nextTargetId: number | null; + nextKind: LinkedListNextKind; +} + +export interface LinkedListStructure { + key: string; + rootNodeId: number; + className: string; + nodes: LinkedListNodeSnapshot[]; + cycleTargetId: number | null; +} + +export interface LinkedListDetectionResult { + structures: LinkedListStructure[]; +} + +export interface LinkedListGraphNode { + nodeId: number; + className: string; + value: string; + labels: string[]; + nextKind: LinkedListNextKind; + nextTargetId: number | null; + group: number; +} + +export interface LinkedListGraphEdge { + fromId: number; + toId: number; +} + +export interface LinkedListGraph { + nodes: LinkedListGraphNode[]; + edges: LinkedListGraphEdge[]; +} + +interface CandidateNode { + nodeId: number; + className: string; + value: string; + labels: string[]; + nextTargetId: number | null; + nextKind: Exclude; +} + +function isClassElement( + element: CanvasElement | undefined +): element is CanvasElement & { kind: ClassKind } { + return element?.kind.name === "class"; +} + +function hasNextAttribute(element: CanvasElement | undefined): boolean { + return Boolean( + isClassElement(element) && + element.kind.classVariables.some((attribute) => attribute.name === "next") + ); +} + +function addLabel( + labelMap: Map>, + targetId: number | null, + label: string +): void { + if (targetId === null || !label.trim()) { + return; + } + + const existing = labelMap.get(targetId) ?? new Set(); + existing.add(label); + labelMap.set(targetId, existing); +} + +function buildNodeLabelMap(elements: CanvasElement[]): Map> { + const labelMap = new Map>(); + + elements.forEach((element) => { + if (element.kind.name === "function") { + element.kind.params.forEach((param) => { + addLabel(labelMap, param.targetId, param.name); + }); + return; + } + + if (element.kind.name === "class" && !hasNextAttribute(element)) { + element.kind.classVariables.forEach((attribute) => { + addLabel(labelMap, attribute.targetId, attribute.name); + }); + } + }); + + return labelMap; +} + +function formatReferenceValue( + targetId: number | null, + elementsById: Map +): string { + if (targetId === null) { + return ""; + } + + const target = elementsById.get(targetId); + if (!target) { + return "?"; + } + + if (isPrimitiveElement(target)) { + return formatPrimitiveValue(target.kind); + } + + if (typeof target.id === "number") { + return `id${target.id}`; + } + + return "?"; +} + +function resolveNodeValue( + element: CanvasElement & { kind: ClassKind }, + elementsById: Map +): string { + const attributes = element.kind.classVariables.filter( + (attribute) => attribute.name !== "next" + ); + + for (const attributeName of VALUE_ATTRIBUTE_NAMES) { + const match = attributes.find((attribute) => attribute.name === attributeName); + if (match) { + return formatReferenceValue(match.targetId, elementsById); + } + } + + for (const attribute of attributes) { + const value = formatReferenceValue(attribute.targetId, elementsById); + if (value) { + return value; + } + } + + return element.kind.className || "value"; +} + +function resolveNextPointer( + element: CanvasElement & { kind: ClassKind }, + elementsById: Map +): Pick | null { + const nextAttribute = element.kind.classVariables.find( + (attribute) => attribute.name === "next" + ); + + if (!nextAttribute) { + return null; + } + + if (nextAttribute.targetId === null) { + return { + nextTargetId: null, + nextKind: "missing", + }; + } + + const target = elementsById.get(nextAttribute.targetId); + if (!target) { + return { + nextTargetId: nextAttribute.targetId, + nextKind: "missing", + }; + } + + if (isPrimitiveElement(target) && target.kind.type === "NoneType") { + return { + nextTargetId: null, + nextKind: "none", + }; + } + + if ( + isClassElement(target) && + hasNextAttribute(target) && + typeof target.id === "number" + ) { + return { + nextTargetId: target.id, + nextKind: "node", + }; + } + + return { + nextTargetId: typeof target.id === "number" ? target.id : nextAttribute.targetId, + nextKind: "invalid", + }; +} + +function buildCandidateMap( + elements: CanvasElement[], + elementsById: Map, + labelMap: Map> +): Map { + const candidateMap = new Map(); + + elements.forEach((element) => { + if (!isClassElement(element) || typeof element.id !== "number") { + return; + } + + const nextPointer = resolveNextPointer(element, elementsById); + if (!nextPointer) { + return; + } + + if (nextPointer.nextKind === "invalid") { + return; + } + + candidateMap.set(element.id, { + nodeId: element.id, + className: element.kind.className || "Node", + value: resolveNodeValue(element, elementsById), + labels: Array.from(labelMap.get(element.id) ?? []), + nextTargetId: nextPointer.nextTargetId, + nextKind: nextPointer.nextKind, + }); + }); + + return candidateMap; +} + +function getRootPriority(labels: string[]): number { + const normalized = labels.map((label) => label.trim().toLowerCase()); + const priorityIndex = PREFERRED_ROOT_LABELS.findIndex((preferred) => + normalized.includes(preferred) + ); + + return priorityIndex === -1 ? Number.MAX_SAFE_INTEGER : priorityIndex; +} + +function compareStartNodes( + leftId: number, + rightId: number, + candidateMap: Map +): number { + const left = candidateMap.get(leftId); + const right = candidateMap.get(rightId); + + if (!left || !right) { + return leftId - rightId; + } + + const rootPriorityDifference = + getRootPriority(left.labels) - getRootPriority(right.labels); + if (rootPriorityDifference !== 0) { + return rootPriorityDifference; + } + + const labelCountDifference = right.labels.length - left.labels.length; + if (labelCountDifference !== 0) { + return labelCountDifference; + } + + return leftId - rightId; +} + +function traverseStructure( + startId: number, + candidateMap: Map +): LinkedListStructure | null { + const start = candidateMap.get(startId); + if (!start) { + return null; + } + + const visited = new Set(); + const nodes: LinkedListNodeSnapshot[] = []; + let currentId: number | null = startId; + let cycleTargetId: number | null = null; + + while (currentId !== null) { + const current = candidateMap.get(currentId); + if (!current) { + break; + } + + visited.add(currentId); + + let nextKind: LinkedListNextKind = current.nextKind; + const nextTargetId = current.nextTargetId; + + if ( + current.nextKind === "node" && + nextTargetId !== null && + visited.has(nextTargetId) + ) { + nextKind = "cycle"; + cycleTargetId = nextTargetId; + } + + nodes.push({ + nodeId: current.nodeId, + className: current.className, + value: current.value, + labels: current.labels, + nextTargetId, + nextKind, + }); + + if (nextKind !== "node" || nextTargetId === null) { + break; + } + + currentId = nextTargetId; + } + + if (nodes.length === 0) { + return null; + } + + return { + key: `${start.className}-${startId}`, + rootNodeId: startId, + className: start.className, + nodes, + cycleTargetId, + }; +} + +export function detectLinkedLists( + elements: CanvasElement[], + elementsById: Map = createElementsByIdMap(elements) +): LinkedListDetectionResult { + const labelMap = buildNodeLabelMap(elements); + const candidateMap = buildCandidateMap(elements, elementsById, labelMap); + + if (candidateMap.size === 0) { + return { structures: [] }; + } + + const incomingNextCounts = new Map(); + candidateMap.forEach((candidate) => { + incomingNextCounts.set(candidate.nodeId, 0); + }); + + candidateMap.forEach((candidate) => { + if (candidate.nextKind !== "node" || candidate.nextTargetId === null) { + return; + } + + if (candidateMap.has(candidate.nextTargetId)) { + incomingNextCounts.set( + candidate.nextTargetId, + (incomingNextCounts.get(candidate.nextTargetId) ?? 0) + 1 + ); + } + }); + + const orderedRoots = Array.from(candidateMap.keys()) + .filter((nodeId) => (incomingNextCounts.get(nodeId) ?? 0) === 0) + .sort((left, right) => compareStartNodes(left, right, candidateMap)); + + const coveredNodeIds = new Set(); + const structures: LinkedListStructure[] = []; + + for (const rootId of orderedRoots) { + if (coveredNodeIds.has(rootId)) { + continue; + } + + const structure = traverseStructure(rootId, candidateMap); + if (!structure) { + continue; + } + + structure.nodes.forEach((node) => coveredNodeIds.add(node.nodeId)); + structures.push(structure); + } + + const remainingStarts = Array.from(candidateMap.keys()) + .filter((nodeId) => !coveredNodeIds.has(nodeId)) + .sort((left, right) => compareStartNodes(left, right, candidateMap)); + + for (const startId of remainingStarts) { + const structure = traverseStructure(startId, candidateMap); + if (!structure) { + continue; + } + + structure.nodes.forEach((node) => coveredNodeIds.add(node.nodeId)); + structures.push(structure); + } + + return { structures }; +} + +export function detectLinkedListGraph( + elements: CanvasElement[], + elementsById: Map = createElementsByIdMap(elements) +): LinkedListGraph { + const labelMap = buildNodeLabelMap(elements); + const candidateMap = buildCandidateMap(elements, elementsById, labelMap); + + if (candidateMap.size === 0) { + return { nodes: [], edges: [] }; + } + + const incoming = new Map(); + + candidateMap.forEach((candidate) => { + incoming.set(candidate.nodeId, 0); + }); + + candidateMap.forEach((candidate) => { + if ( + candidate.nextKind === "node" && + candidate.nextTargetId !== null && + candidateMap.has(candidate.nextTargetId) + ) { + incoming.set( + candidate.nextTargetId, + (incoming.get(candidate.nextTargetId) ?? 0) + 1 + ); + } + }); + + const ordered: number[] = []; + const groupOf = new Map(); + const seen = new Set(); + let group = 0; + const walk = (startId: number) => { + let id: number | null = startId; + let advanced = false; + while (id !== null && candidateMap.has(id) && !seen.has(id)) { + seen.add(id); + ordered.push(id); + groupOf.set(id, group); + advanced = true; + const c: CandidateNode = candidateMap.get(id)!; + id = c.nextKind === "node" ? c.nextTargetId : null; + } + if (advanced) group += 1; + }; + Array.from(candidateMap.keys()) + .filter((id) => (incoming.get(id) ?? 0) === 0) + .sort((a, b) => compareStartNodes(a, b, candidateMap)) + .forEach(walk); + Array.from(candidateMap.keys()) + .sort((a, b) => compareStartNodes(a, b, candidateMap)) + .forEach(walk); + + const nodes: LinkedListGraphNode[] = ordered.map((id) => { + const candidate = candidateMap.get(id)!; + return { + nodeId: candidate.nodeId, + className: candidate.className, + value: candidate.value, + labels: candidate.labels, + nextKind: candidate.nextKind, + nextTargetId: candidate.nextTargetId, + group: groupOf.get(id) ?? 0, + }; + }); + + const edges: LinkedListGraphEdge[] = []; + + candidateMap.forEach((candidate) => { + if ( + candidate.nextKind === "node" && + candidate.nextTargetId !== null && + candidateMap.has(candidate.nextTargetId) + ) { + edges.push({ + fromId: candidate.nodeId, + toId: candidate.nextTargetId, + }); + } + }); + + return { + nodes, + edges, + }; +} \ No newline at end of file diff --git a/frontend/src/features/canvas/utils/linkedListRenderer.test.ts b/frontend/src/features/canvas/utils/linkedListRenderer.test.ts new file mode 100644 index 0000000..d40ddb4 --- /dev/null +++ b/frontend/src/features/canvas/utils/linkedListRenderer.test.ts @@ -0,0 +1,91 @@ +import { + buildLinkedListGraphLayout, + createReturnEdgePath, + createSelfLoopPath, +} from "./linkedListRenderer"; +import { LinkedListGraph } from "./linkedListDetector"; + +describe("linkedListRenderer edge cases", () => { + it("marks a node that points to itself as a self edge", () => { + const graph: LinkedListGraph = { + nodes: [ + { + nodeId: 1, + className: "Node", + value: "10", + labels: ["head"], + nextKind: "node", + nextTargetId: 1, + group: 0, + }, + ], + edges: [{ fromId: 1, toId: 1 }], + }; + + const layout = buildLinkedListGraphLayout(graph, 500); + + expect(layout.edges).toEqual([{ fromId: 1, toId: 1, kind: "self" }]); + expect(createSelfLoopPath(layout.nodes[0])).toContain("A"); + }); + + it("marks an edge to an earlier node in the same row as backward", () => { + const graph: LinkedListGraph = { + nodes: [ + { + nodeId: 1, + className: "Node", + value: "10", + labels: ["head"], + nextKind: "none", + nextTargetId: null, + group: 0, + }, + { + nodeId: 2, + className: "Node", + value: "20", + labels: ["second"], + nextKind: "node", + nextTargetId: 1, + group: 0, + }, + ], + edges: [{ fromId: 2, toId: 1 }], + }; + + const layout = buildLinkedListGraphLayout(graph, 500); + + expect(layout.edges).toEqual([{ fromId: 2, toId: 1, kind: "backward" }]); + expect(createReturnEdgePath(layout.nodes[1], layout.nodes[0])).toContain("A"); + }); + + it("keeps ordinary left-to-right next pointers as forward edges", () => { + const graph: LinkedListGraph = { + nodes: [ + { + nodeId: 1, + className: "Node", + value: "10", + labels: ["head"], + nextKind: "node", + nextTargetId: 2, + group: 0, + }, + { + nodeId: 2, + className: "Node", + value: "20", + labels: ["second"], + nextKind: "none", + nextTargetId: null, + group: 0, + }, + ], + edges: [{ fromId: 1, toId: 2 }], + }; + + const layout = buildLinkedListGraphLayout(graph, 500); + + expect(layout.edges).toEqual([{ fromId: 1, toId: 2, kind: "forward" }]); + }); +}); diff --git a/frontend/src/features/canvas/utils/linkedListRenderer.ts b/frontend/src/features/canvas/utils/linkedListRenderer.ts new file mode 100644 index 0000000..7ab920d --- /dev/null +++ b/frontend/src/features/canvas/utils/linkedListRenderer.ts @@ -0,0 +1,305 @@ +import { + LinkedListNextKind, + LinkedListStructure, + LinkedListGraph, +} from "./linkedListDetector"; + +const CARD_PADDING_X = 12; +const CARD_PADDING_Y = 8; +const NODE_WIDTH = 110; +const NODE_HEIGHT = 45; +const NODE_GAP = 35; +const VALUE_RATIO = 0.6; +const LABEL_LINE_HEIGHT = 16; +const LABEL_TO_NODE_GAP = 14; +const FOOTER_HEIGHT = 24; +const ROW_GAP = 44; + +export interface LinkedListNodeLayout { + nodeId: number; + x: number; + y: number; + width: number; + height: number; + dividerX: number; + dotX: number; + dotY: number; + nextCellWidth: number; + value: string; + nextText: string; + labels: string[]; + nextKind: LinkedListNextKind; +} + +export interface LinkedListStructureLayout { + width: number; + height: number; + connectorY: number; + cyclePath: string | null; + footerText: string | null; + nodes: LinkedListNodeLayout[]; +} + +export interface LinkedListGraphLayout { + width: number; + height: number; + connectorY: number; + nodes: LinkedListNodeLayout[]; + byId: Record; + edges: { + fromId: number; + toId: number; + kind: "forward" | "backward" | "self"; + }[]; +} + +function getNextCellText(nextKind: LinkedListNextKind): string { + switch (nextKind) { + case "none": + return "None"; + case "missing": + return "?"; + case "invalid": + return "?"; + case "cycle": + return "•"; + case "node": + default: + return "•"; + } +} + +function createCyclePath( + nodes: LinkedListNodeLayout[], + targetNodeId: number +): string | null { + const source = nodes[nodes.length - 1]; + const target = nodes.find((node) => node.nodeId === targetNodeId); + + if (!source || !target) { + return null; + } + + const startX = source.x + source.width - 8; + const startY = source.y + source.height / 2; + const endX = target.x + target.width / 2; + const endY = target.y - 6; + const controlY = target.y - 40; + + return `M ${startX} ${startY} C ${startX + 30} ${controlY}, ${endX + 20} ${controlY}, ${endX} ${endY}`; +} + +function getFooterText(nextKind: LinkedListNextKind, hasCycle: boolean): string | null { + if (hasCycle) { + return "Cycle detected"; + } + + if (nextKind === "missing") { + return "Chain stopped at a missing reference"; + } + + if (nextKind === "invalid") { + return "Chain stopped at a non-node next reference"; + } + + return null; +} + +export function buildLinkedListGraphLayout( + graph: LinkedListGraph, + containerWidth: number +): LinkedListGraphLayout { + const maxLabelLines = Math.max( + 1, + ...graph.nodes.map((node) => Math.max(1, node.labels.length)) + ); + + const labelBand = maxLabelLines * LABEL_LINE_HEIGHT + LABEL_TO_NODE_GAP; + const cellW = NODE_WIDTH + NODE_GAP; + const rowH = labelBand + NODE_HEIGHT + ROW_GAP; + + const usable = Math.max(containerWidth - CARD_PADDING_X * 2, NODE_WIDTH); + const perRow = Math.max(1, Math.floor((usable + NODE_GAP) / cellW)); + + let col = 0; + let row = 0; + let prevGroup = graph.nodes.length > 0 ? graph.nodes[0].group : 0; + let maxCol = 0; + + const nodes: LinkedListNodeLayout[] = graph.nodes.map((node) => { + if (node.group !== prevGroup || col >= perRow) { + col = 0; + row += 1; + prevGroup = node.group; + } + + const x = CARD_PADDING_X + col * cellW; + const y = CARD_PADDING_Y + row * rowH + labelBand; + const dividerX = x + NODE_WIDTH * VALUE_RATIO; + + if (col + 1 > maxCol) { + maxCol = col + 1; + } + col += 1; + + return { + nodeId: node.nodeId, + x, + y, + width: NODE_WIDTH, + height: NODE_HEIGHT, + dividerX, + dotX: dividerX + (NODE_WIDTH * (1 - VALUE_RATIO)) / 2, + dotY: y + NODE_HEIGHT / 2, + nextCellWidth: NODE_WIDTH * (1 - VALUE_RATIO), + value: node.value, + nextText: getNextCellText(node.nextKind), + labels: node.labels, + nextKind: node.nextKind, + }; + }); + + const byId: Record = {}; + nodes.forEach((node) => { + byId[node.nodeId] = node; + }); + + const rowsUsed = graph.nodes.length > 0 ? row + 1 : 1; + const colsUsed = Math.max(1, maxCol); + + const width = Math.max( + containerWidth, + CARD_PADDING_X * 2 + colsUsed * cellW - NODE_GAP + ); + const height = CARD_PADDING_Y * 2 + rowsUsed * rowH; + + return { + width, + height, + connectorY: 0, + nodes, + byId, + edges: graph.edges.map((edge) => { + const from = byId[edge.fromId]; + const to = byId[edge.toId]; + + if (!from || !to) { + return { ...edge, kind: "forward" as const }; + } + + if (edge.fromId === edge.toId) { + return { ...edge, kind: "self" as const }; + } + + const isBackward = to.y > from.y || to.x <= from.x; + + return { + ...edge, + kind: isBackward ? "backward" : "forward", + }; + }), + }; +} + +type RectSide = "left" | "right" | "top" | "bottom"; + +export function getRectPerimeterPoint( + rect: { x: number; y: number; width: number; height: number }, + toward: { x: number; y: number } +): { point: { x: number; y: number }; side: RectSide } { + const cx = rect.x + rect.width / 2; + const cy = rect.y + rect.height / 2; + const dx = toward.x - cx; + const dy = toward.y - cy; + + if (dx === 0 && dy === 0) { + return { point: { x: cx, y: cy }, side: "top" }; + } + + const scaleX = dx === 0 ? Infinity : rect.width / 2 / Math.abs(dx); + const scaleY = dy === 0 ? Infinity : rect.height / 2 / Math.abs(dy); + const useHorizontalEdge = scaleY < scaleX; + const side: RectSide = useHorizontalEdge + ? dy < 0 ? "top" : "bottom" + : dx < 0 ? "left" : "right"; + const scale = useHorizontalEdge ? scaleY : scaleX; + + return { + point: { x: cx + dx * scale, y: cy + dy * scale }, + side, + }; +} + +export function createEdgePath( + start: { x: number; y: number }, + end: { x: number; y: number }, + entrySide: RectSide +): string { + const dx = end.x - start.x; + const dy = end.y - start.y; + const departVertically = Math.abs(dy) > Math.abs(dx); + const startOff = Math.max(20, (departVertically ? Math.abs(dy) : Math.abs(dx)) * 0.35); + const endOff = Math.max( + 20, + (entrySide === "top" || entrySide === "bottom" ? Math.abs(dy) : Math.abs(dx)) * 0.35 + ); + + const c1 = departVertically + ? { x: start.x, y: start.y + (dy >= 0 ? 1 : -1) * startOff } + : { x: start.x + (dx >= 0 ? 1 : -1) * startOff, y: start.y }; + + const c2 = + entrySide === "left" ? { x: end.x - endOff, y: end.y } + : entrySide === "right" ? { x: end.x + endOff, y: end.y } + : entrySide === "top" ? { x: end.x, y: end.y - endOff } + : { x: end.x, y: end.y + endOff }; + + return `M ${start.x} ${start.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${end.x} ${end.y}`; +} + +export function createReturnEdgePath( + from: LinkedListNodeLayout, + to: LinkedListNodeLayout +): string { + const startX = from.dotX; + const startY = from.dotY; + const endX = to.x + to.width / 2; + const endY = to.y + to.height + 2; + const sameRow = Math.abs(from.y - to.y) < 1; + const horizontalGap = from.x - to.x; + + if (sameRow && horizontalGap > 0) { + const radiusX = Math.max(24, horizontalGap * 0.55); + const radiusY = + horizontalGap <= NODE_WIDTH + NODE_GAP + 10 ? 22 : 34; + + return [ + `M ${startX} ${startY}`, + `A ${radiusX} ${radiusY} 0 0 1 ${endX} ${endY}`, + ].join(" "); + } + + const routeY = Math.max(from.y + from.height, to.y + to.height) + 26; + const c1 = { x: startX + 18, y: routeY }; + const c2 = { x: endX + 10, y: routeY }; + + return [ + `M ${startX} ${startY}`, + `C ${c1.x} ${startY}, ${c1.x} ${routeY}, ${c2.x} ${routeY}`, + `S ${endX} ${routeY}, ${endX} ${endY}`, + ].join(" "); +} + +export function createSelfLoopPath(node: LinkedListNodeLayout): string { + const startX = node.dotX; + const startY = node.dotY; + const radiusX = 24; + const radiusY = 28; + const endX = node.dotX + 2; + const endY = node.y + 4; + + return [ + `M ${startX} ${startY}`, + `A ${radiusX} ${radiusY} 0 1 1 ${endX} ${endY}`, + ].join(" "); +} diff --git a/frontend/src/features/canvasControls/CanvasControls.module.css b/frontend/src/features/canvasControls/CanvasControls.module.css index 723ece9..4808cb4 100644 --- a/frontend/src/features/canvasControls/CanvasControls.module.css +++ b/frontend/src/features/canvasControls/CanvasControls.module.css @@ -140,6 +140,17 @@ padding-top: 8px; } +.sectionHeading { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-secondary); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); +} + .controlLabel { font-size: 0.9rem; font-weight: 500; diff --git a/frontend/src/features/canvasControls/CanvasControls.tsx b/frontend/src/features/canvasControls/CanvasControls.tsx index 00b3e5d..06dfb61 100644 --- a/frontend/src/features/canvasControls/CanvasControls.tsx +++ b/frontend/src/features/canvasControls/CanvasControls.tsx @@ -30,6 +30,8 @@ interface CanvasControlsProps { onPythonTutorStandalonePrimitivesChange?: (value: boolean) => void; fontScale?: number; onFontScaleChange?: (delta: number) => void; + showLinkedListView?: boolean; + onShowLinkedListViewChange?: (value: boolean) => void; } type ControlTab = "actions" | "view" | "settings"; @@ -77,6 +79,8 @@ export default function CanvasControls({ onPythonTutorStandalonePrimitivesChange, fontScale = 1, onFontScaleChange, + showLinkedListView = false, + onShowLinkedListViewChange, }: CanvasControlsProps) { const { theme, toggleTheme } = useTheme(); const isDarkMode = theme === 'dark'; @@ -312,6 +316,37 @@ export default function CanvasControls({
)} + {onShowLinkedListViewChange && ( + <> +
+ Structure Visualizations +
+ +
+ + +
+ + )} +
diff --git a/frontend/src/features/informationTabs/components/ErrorListDisplay.tsx b/frontend/src/features/informationTabs/components/ErrorListDisplay.tsx index 16e089e..c8e89b9 100644 --- a/frontend/src/features/informationTabs/components/ErrorListDisplay.tsx +++ b/frontend/src/features/informationTabs/components/ErrorListDisplay.tsx @@ -61,12 +61,15 @@ export default function ErrorListDisplay({ message: string, isSandboxMode: boolean ): string => { + let processed = message.replace( + /\bAt ([A-Za-z_]\w*): [A-Za-z_]\w*\.([A-Za-z_]\w*):/g, + "At $1.$2:" + ); + if (!isSandboxMode) { - return message; + return processed; } - let processed = message; - // Do not edit messages that have "unexpected" in it if (!message.toLowerCase().includes("unexpected")) { processed = processed.replace(/expected\s+[^,]+,\s+got\s+/gi, "got "); diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css index 15398ce..ab663dd 100644 --- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css +++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.module.css @@ -87,15 +87,25 @@ .canvasColumn { flex: 1; min-width: 0; + min-height: 0; position: relative; display: flex; flex-direction: column; + padding-bottom: 12px; + box-sizing: border-box; } .canvasArea { flex: 1; position: relative; + min-height: 0; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 10px; + box-shadow: var(--shadow-sm); overflow: hidden; + box-sizing: border-box; + transition: background-color 200ms ease, border-color 200ms ease; } .infoPanel { diff --git a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx index ebf7505..544ee9d 100644 --- a/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx +++ b/frontend/src/features/memoryModelEditor/MemoryModelEditor.tsx @@ -1,4 +1,5 @@ import Canvas from "../canvas/Canvas"; +import StructurePanel from "../canvas/components/StructurePanel"; import Palette from "../palette/Palette"; import ConfirmationModal from "./components/ConfirmationModal"; import InformationTabs from "../informationTabs/InformationTabs"; @@ -33,6 +34,7 @@ const MIN_PALETTE_WIDTH = 200; const MAX_PALETTE_WIDTH = 400; const DEFAULT_PALETTE_WIDTH = 280; const SNAP_CLOSE_THRESHOLD = 100; +const STRUCTURE_PANEL_RESIZE_HANDLE_HEIGHT = 8; interface MemoryModelEditorProps { sandbox?: boolean; @@ -347,6 +349,9 @@ export default function MemoryModelEditor({ visualStyle: state.visualStyle, pythonTutorReferenceArrows: state.pythonTutorReferenceArrows, pythonTutorStandalonePrimitives: state.pythonTutorStandalonePrimitives, + showLinkedListView: state.showLinkedListView, + structurePanelCollapsed: state.structurePanelCollapsed, + structurePanelHeight: state.structurePanelHeight, questionView: state.questionView, isInfoPanelOpen: state.isInfoPanelOpen, canvasScale, @@ -549,6 +554,8 @@ export default function MemoryModelEditor({ onPythonTutorStandalonePrimitivesChange={ state.setPythonTutorStandalonePrimitives } + showLinkedListView={state.showLinkedListView} + onShowLinkedListViewChange={state.setShowLinkedListView} fontScale={fontScale} onFontScaleChange={adjustFontScale} /> @@ -594,9 +601,25 @@ export default function MemoryModelEditor({ : undefined } isQuestionMode={state.selectedQuestionIndex !== null} + preserveCollapsedWorkspace={state.showLinkedListView} + workspaceHeightOffset={ + state.showLinkedListView && !state.structurePanelCollapsed + ? state.structurePanelHeight + + STRUCTURE_PANEL_RESIZE_HANDLE_HEIGHT + : 0 + } />
+ + {state.jsonOutput && (
{state.jsonOutput}
)} diff --git a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts index 5117fc5..a30fec9 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useLocalStorage.ts @@ -22,6 +22,9 @@ export function useUILocalStorage(state: UIState): void { state.visualStyle, state.pythonTutorReferenceArrows, state.pythonTutorStandalonePrimitives, + state.showLinkedListView, + state.structurePanelCollapsed, + state.structurePanelHeight, 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 0dc546c..dbbd3e7 100644 --- a/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts +++ b/frontend/src/features/memoryModelEditor/hooks/useMemoryModelEditorState.ts @@ -57,6 +57,15 @@ export function useMemoryModelEditorState(sandbox: boolean) { useState(initialUIData.pythonTutorReferenceArrows ?? false); const [pythonTutorStandalonePrimitives, setPythonTutorStandalonePrimitives] = useState(initialUIData.pythonTutorStandalonePrimitives ?? false); + const [showLinkedListView, setShowLinkedListView] = useState( + initialUIData.showLinkedListView ?? false + ); + const [structurePanelCollapsed, setStructurePanelCollapsed] = useState( + initialUIData.structurePanelCollapsed ?? false + ); + const [structurePanelHeight, setStructurePanelHeight] = useState( + initialUIData.structurePanelHeight ?? 180 + ); const [questionView, setQuestionView] = useState( initialUIData.questionView ?? "root" ); @@ -114,6 +123,12 @@ export function useMemoryModelEditorState(sandbox: boolean) { setPythonTutorReferenceArrows, pythonTutorStandalonePrimitives, setPythonTutorStandalonePrimitives, + showLinkedListView, + setShowLinkedListView, + structurePanelCollapsed, + setStructurePanelCollapsed, + structurePanelHeight, + setStructurePanelHeight, questionView, setQuestionView, tabScrollPositions, diff --git a/frontend/src/features/memoryModelEditor/utils/localStorage.ts b/frontend/src/features/memoryModelEditor/utils/localStorage.ts index ea4247a..1e0a691 100644 --- a/frontend/src/features/memoryModelEditor/utils/localStorage.ts +++ b/frontend/src/features/memoryModelEditor/utils/localStorage.ts @@ -30,6 +30,9 @@ const DEFAULT_UI_STATE = { visualStyle: "memoryviz" as VisualStyle, pythonTutorReferenceArrows: false, pythonTutorStandalonePrimitives: false, + showLinkedListView: false, + structurePanelCollapsed: false, + structurePanelHeight: 200, }; export interface CanvasData { @@ -73,6 +76,9 @@ export interface UIState { visualStyle?: VisualStyle; pythonTutorReferenceArrows?: boolean; pythonTutorStandalonePrimitives?: boolean; + showLinkedListView?: boolean; + structurePanelCollapsed?: boolean; + structurePanelHeight?: number; questionView?: QuestionView; isInfoPanelOpen?: boolean; canvasScale?: number; @@ -138,6 +144,15 @@ export function loadInitialUIData(): UIState { const pythonTutorStandalonePrimitives = parsed?.pythonTutorStandalonePrimitives === true; + const showLinkedListView = parsed?.showLinkedListView === true; + const structurePanelCollapsed = parsed?.structurePanelCollapsed === true; + const structurePanelHeight = + typeof parsed?.structurePanelHeight === "number" && + parsed.structurePanelHeight >= 140 && + parsed.structurePanelHeight <= 800 + ? parsed.structurePanelHeight + : undefined; + const validViews = ["root", "loading", "test", "list", "question", "practice", "prep"]; const questionView = typeof parsed?.questionView === "string" && validViews.includes(parsed.questionView) && parsed.questionView !== "loading" @@ -170,6 +185,7 @@ export function loadInitialUIData(): UIState { visualStyle, pythonTutorReferenceArrows, pythonTutorStandalonePrimitives, + showLinkedListView, questionView, isInfoPanelOpen, canvasScale, diff --git a/frontend/src/features/palette/Palette.tsx b/frontend/src/features/palette/Palette.tsx index 16056e1..4ac7c05 100644 --- a/frontend/src/features/palette/Palette.tsx +++ b/frontend/src/features/palette/Palette.tsx @@ -78,6 +78,8 @@ interface PaletteProps { onPythonTutorReferenceArrowsChange?: (value: boolean) => void; pythonTutorStandalonePrimitives?: boolean; onPythonTutorStandalonePrimitivesChange?: (value: boolean) => void; + showLinkedListView?: boolean; + onShowLinkedListViewChange?: (value: boolean) => void; fontScale?: number; onFontScaleChange?: (delta: number) => void; } @@ -145,6 +147,8 @@ export default function Palette({ onPythonTutorReferenceArrowsChange, pythonTutorStandalonePrimitives = false, onPythonTutorStandalonePrimitivesChange, + showLinkedListView = false, + onShowLinkedListViewChange, fontScale, onFontScaleChange, }: PaletteProps) { @@ -279,6 +283,8 @@ export default function Palette({ onPythonTutorStandalonePrimitivesChange={ onPythonTutorStandalonePrimitivesChange } + showLinkedListView={showLinkedListView} + onShowLinkedListViewChange={onShowLinkedListViewChange} fontScale={fontScale} onFontScaleChange={onFontScaleChange} /> diff --git a/frontend/src/features/shared/boxConfig/index.ts b/frontend/src/features/shared/boxConfig/index.ts index 69c8428..2a68530 100644 --- a/frontend/src/features/shared/boxConfig/index.ts +++ b/frontend/src/features/shared/boxConfig/index.ts @@ -13,6 +13,7 @@ import { ID } from "../types"; import { extractValues, getSequenceHeight, + getNamedRowBoxHeight, processFunctionParams, processClassVariables, extractDictionary, @@ -139,6 +140,7 @@ export const DEFAULT_PALETTE_STYLE: BoxStyle = { export { extractValues, getSequenceHeight, + getNamedRowBoxHeight, processFunctionParams, processClassVariables, extractDictionary, @@ -184,7 +186,8 @@ export const BOX_TYPE_CONFIGS: Record = { style ); }, - getHeight: () => 78, + getHeight: (kind: any) => + getNamedRowBoxHeight(Array.isArray(kind?.params) ? kind.params.length : 0), getMinWidth: () => DEFAULT_DIMENSIONS.PRIMITIVE_WIDTH, paletteMinHeight: 81, paletteMinWidth: 153, @@ -205,7 +208,10 @@ export const BOX_TYPE_CONFIGS: Record = { style ); }, - getHeight: () => 78, + getHeight: (kind: any) => + getNamedRowBoxHeight( + Array.isArray(kind?.classVariables) ? kind.classVariables.length : 0 + ), getMinWidth: () => DEFAULT_DIMENSIONS.PRIMITIVE_WIDTH, paletteMinHeight: 81, paletteMinWidth: 153,