diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..034d4a5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,30 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - run: cd recursive_blocks && npm install + - run: cd recursive_blocks && npm run build + + - uses: actions/upload-pages-artifact@v3 + with: + path: recursive_blocks/dist + + - uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/recursive_blocks/public/CNAME b/recursive_blocks/public/CNAME new file mode 100644 index 0000000..752f193 --- /dev/null +++ b/recursive_blocks/public/CNAME @@ -0,0 +1 @@ +sunflower.bram-hub.com \ No newline at end of file diff --git a/recursive_blocks/src/Block.css b/recursive_blocks/src/Block.css index 7116551..90c00ed 100644 --- a/recursive_blocks/src/Block.css +++ b/recursive_blocks/src/Block.css @@ -96,12 +96,31 @@ .block-action-buttons { display: flex; - align-items: center; - gap: 0.1rem; + align-items: flex-start; + gap: 0.5rem; flex-shrink: 0; margin-left: auto; } +/* ===================== IO SPACING FIX START ===================== */ + +.block-io-container { + display: flex; + flex-direction: column; + align-items: flex-start; + + /* Force consistent spacing */ + gap: 6px; /* matches Successor visually */ +} + +/* Remove any accidental spacing inconsistencies */ +.block-io-in, +.block-io-out { + margin: 0; +} + +/* ====================== IO SPACING FIX END ====================== */ + .block-type { font-weight: 700; color: rgba(0,0,0,0.88); @@ -111,13 +130,13 @@ .block-math-notation { font-size: 0.72rem; font-style: italic; - color: rgba(0,0,0,0.5); + color: rgba(0,0,0,0.75); margin-top: 1px; font-family: Georgia, 'Times New Roman', serif; letter-spacing: 0.01em; } -.block-highlighted .block-math-notation { +.block-highlighted > .block-header .block-math-notation { color: rgba(255,255,255,0.45); } @@ -125,43 +144,56 @@ .block-type { color: rgba(255,255,255,0.92); } } -/* IO Display */ -.block-io-in { - display: flex; - align-items: center; - gap: 0.35rem; - font-family: inherit; - font-size: 0.75rem; - padding-left: 0.2rem; -} +/* ===================== IO DISPLAY MINIMAL CLEAN START ===================== */ +.block-io-in, .block-io-out { display: flex; align-items: center; - gap: 0.35rem; + gap: 0.4rem; + width: max-content; + font-family: inherit; font-size: 0.75rem; - margin-top: 0.5rem; - padding-top: 0.4rem; - border-top: 1px solid rgba(0,0,0,0.12); + + padding: 0.2rem 0.6rem; + + background: rgba(255, 255, 255, 0.65); + + /* Uniform border on all sides */ + border: 1px solid rgba(0, 0, 0, 0.2); + + border-radius: 6px; + + /* No shadow, no effects */ + box-shadow: none; } +/* ====================== IO DISPLAY MINIMAL CLEAN END ====================== */ + +/* ===================== IO TEXT CLEAN START ===================== */ + .block-io-label { font-weight: 600; - color: rgba(255,255,255,0.95) !important; + color: #444; font-size: 0.75rem; - letter-spacing: 0.02em; + letter-spacing: 0.03em; + min-width: 1.5rem; } .block-io-value { font-weight: 700; - color: rgba(255,255,255,0.95) !important; + color: #000; min-width: 1.5rem; text-align: center; } +/* ====================== IO TEXT CLEAN END ====================== */ + +/* ====================== IO TEXT NEUTRAL UPGRADE END ====================== */ + .block-io-separator { - color: rgba(255,255,255,0.6) !important; + color: rgba(0,0,0,0.6) !important; font-weight: 400; } @@ -175,7 +207,8 @@ /* Buttons */ .collapse-button, .info-button, -.remove-button { +.remove-button, +.pr-trace-toggle-button { font-size: 0.75rem; font-family: inherit; font-weight: 600; @@ -188,14 +221,14 @@ letter-spacing: 0.03em; } -.collapse-button { color: rgba(255,255,255,0.5); } +.collapse-button { color: rgba(0,0,0,0.4); } .collapse-button:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.85); } .info-button { - color: rgba(255,255,255,0.5); + color: rgba(0,0,0,0.4); font-family: Georgia, 'Times New Roman', serif; font-style: italic; font-size: 0.85rem; @@ -230,6 +263,7 @@ margin-top: 0.375rem; } + .block-slot { border-radius: 1.75rem 0 0 1.75rem; margin-bottom: 0.5rem; @@ -282,9 +316,28 @@ .block-slot.empty { border-color: rgba(255,255,255,0.1); } } +@media (prefers-color-scheme: dark) { + .block-io-in, + .block-io-out { + background: #f4f4f4; + border: 1px solid rgba(0, 0, 0, 0.4); + } + + .block-io-label { + color: #333; + } + + .block-io-value { + color: #000; + } +} + + + /* Value Editor */ .value-editor { margin-top: 0.75rem; + padding-left: 0.2rem; padding-right: 1rem; } @@ -302,6 +355,7 @@ align-items: center; gap: 0.75rem; margin-bottom: 0.625rem; + color: rgba(0,0,0,0.95); } .value-label { @@ -319,7 +373,7 @@ font-size: 0.8125rem; font-family: ui-monospace, monospace; width: 100%; - background: rgba(255,255,255,0.55); + background: rgba(255,255,255,0.65); transition: border-color 0.15s ease, box-shadow 0.15s ease; } @@ -339,12 +393,12 @@ .value-static-name { font-weight: 500; - color: rgba(255,255,255,0.55); + color: rgba(0,0,0,0.6); } .value-static-val { - font-weight: 700; - color: rgba(255,255,255,0.85); + font-weight: 500; + color: rgba(0,0,0,0.6); } /* Palette Blocks */ @@ -581,7 +635,7 @@ label { color: var(--block-text-color); } /* Highlighted Block */ .block-highlighted { - background-color: rgba(0,0,0,0.88) !important; + background-color: rgba(128,128,128,0.88) !important; animation: pulse 0.8s ease-in-out infinite; } @@ -590,6 +644,56 @@ label { color: var(--block-text-color); } 50% { box-shadow: 0 0 20px 6px rgba(255,215,0,0.9); } } +/* Visual styles for Custom/Immutable blocks */ + +/* In the Editor */ +.block-custom-style { + opacity: 0.92; +} + +.block-custom-style .block-type { + color: #555 !important; +} + +.block-custom-style .block-math-notation { + color: rgba(60, 60, 60, 0.78); +} + +/* In the Sidebar Palette */ +.palette-block-custom { + background-color: #d1d1d1 !important; + border-left: 4px solid #888 !important; + color: #666 !important; + filter: grayscale(1); +} + +@media (prefers-color-scheme: dark) { + .block-custom-style { + opacity: 0.9; + } + + .palette-block-custom { + background-color: #2d2d2d !important; + border-left: 4px solid #555 !important; + color: #999 !important; + } +} + +/* Highlight for updated IO values */ +.value-changed { + color: var(--contrast-col) !important; + font-weight: 800; + transition: color 0.2s ease; /* Returns to black over 0.2s */ + display: inline-block; + animation: valuePop 0.3s ease-out; +} + +@keyframes valuePop { + 0% { transform: scale(1); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + /* Mobile */ @media (max-width: 768px) { #root { height: 100vh; overflow: hidden; } @@ -615,4 +719,130 @@ label { color: var(--block-text-color); } .block-io-label { font-size: 0.68rem; } .block-io-value { min-width: 1rem; } .sideP { padding: 1rem 0.5rem; } -} \ No newline at end of file +} + +/* PR Trace Toggle Button */ +.pr-trace-toggle-button { + color: rgba(0,0,0,0.4); + font-weight: 700; + font-size: 0.8rem; +} +.pr-trace-toggle-button:hover { + background: rgba(255,255,255,0.08); + color: rgba(255,255,255,0.85); +} +.pr-trace-toggle-button.pr-trace-active { + color: var(--contrast-col); + background: rgba(223,109,38,0.12); +} + +/* Slot labels (primitive recursion, composition, etc) */ +.slot-label { + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(0,0,0,0.5); + margin-top: 0.85rem; + margin-bottom: -0.4rem; + padding-left: 0.875rem; +} + +@media (prefers-color-scheme: dark) { + .slot-label { color: rgba(255,255,255,0.4); } +} + +/* PR Trace Panel */ +.pr-trace-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + margin: 0.875rem 0.75rem 1.5rem; + border: 1px solid rgba(0,0,0,0.15); + border-radius: 6px; + background: rgba(255,255,255,0.15); +} + +.pr-trace-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.pr-trace-cell { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.pr-trace-label { + font-style: italic; + font-size: 0.8rem; + min-width: 3rem; + opacity: 0.7; +} + +.pr-trace-value { + font-family: 'Courier New', monospace; + font-size: 0.8rem; + padding: 0.15rem 0.5rem; + border: 1px solid rgba(0,0,0,0.2); + border-radius: 3px; + min-width: 2rem; + text-align: center; + background: rgba(255,255,255,0.25); +} + +/* Notation popup */ +.notation-popup-overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} + +.notation-popup-panel { + width: min(680px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.2); + background: #ffffff; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); +} + +.notation-popup-title { + font-size: 1rem; + font-weight: 700; +} + +.notation-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.notation-popup-body { + margin: 0; + padding: 0.75rem; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 6px; + background: #f7f7f7; + overflow: auto; + font-size: 0.9rem; + white-space: pre-wrap; + word-break: break-word; +} + +.notation-popup-close { + flex-shrink: 0; +} diff --git a/recursive_blocks/src/Block.tsx b/recursive_blocks/src/Block.tsx index 8ab17e2..f09d0b2 100644 --- a/recursive_blocks/src/Block.tsx +++ b/recursive_blocks/src/Block.tsx @@ -2,9 +2,12 @@ import React from "react"; import { useDrag } from "react-dnd"; import { BlockData } from "./BlockUtil"; import './Block.css'; -import { blockConfig, BlockSlot, BlockType } from "./BlockConfig"; +import { blockConfig, BlockSlot, BlockType } from "./BlockConfig"; import { ValueEditor } from "./ValueEditor"; import { BlockSlotDisplay } from "./BlockSlot"; +import { useBlockEditor } from "./BlockEditorContext"; +import { PRTracePanel } from "./PRTracePanel"; +import { getHeaderNotation } from "./Notation"; interface Props { block: BlockData | null; @@ -15,55 +18,58 @@ interface Props { isRunning?: boolean; } -const getDepthColor = (depth: number) => { - const colors = ['#ff9999', '#99ff99', '#9999ff', '#ffff99', '#ff99ff', '#99ffff']; - return colors[depth % colors.length]; +const DEPTH_COLORS = ['#ff9999', '#99ff99', '#9999ff', '#ffff99', '#ff99ff', '#99ffff']; +const CUSTOM_BLOCK_TINT = '#9a9a9a'; + +const getDepthColor = (depth: number) => DEPTH_COLORS[depth % DEPTH_COLORS.length]; + +const hexToRgb = (hex: string) => { + const normalized = hex.replace('#', ''); + const value = normalized.length === 3 + ? normalized.split('').map((char) => char + char).join('') + : normalized; + + const parsed = Number.parseInt(value, 16); + + return { + r: (parsed >> 16) & 255, + g: (parsed >> 8) & 255, + b: parsed & 255, + }; +}; + +const blendHexColors = (base: string, tint: string, tintWeight: number) => { + const baseRgb = hexToRgb(base); + const tintRgb = hexToRgb(tint); + const baseWeight = 1 - tintWeight; + const blendChannel = (baseChannel: number, tintChannel: number) => + Math.round(baseChannel * baseWeight + tintChannel * tintWeight); + + return `rgb(${blendChannel(baseRgb.r, tintRgb.r)}, ${blendChannel(baseRgb.g, tintRgb.g)}, ${blendChannel(baseRgb.b, tintRgb.b)})`; }; -// Generate formal mathematical notation for each block type -const getMathNotation = (block: BlockData): string => { - const n = block.inputCount ?? 0; +const getCustomBlockColors = (depth: number) => { + const baseColor = getDepthColor(depth); - // Build argument list like (x1, x2, ..., xn) - const args = (count: number, suffix?: string) => { - if (count === 0) return suffix ? `(${suffix})` : "()"; - const vars = Array.from({ length: count }, (_, i) => `x${i + 1}`).join(", "); - return suffix ? `(${vars}, ${suffix})` : `(${vars})`; + return { + background: blendHexColors(baseColor, CUSTOM_BLOCK_TINT, 0.6), + border: blendHexColors(baseColor, CUSTOM_BLOCK_TINT, 0.56), }; +}; - switch (block.type) { - case "Zero": - return `z${args(n)}`; - case "Successor": - return `s(x)`; - case "Projection": { - const i = block.num_values?.find(v => v.name === "i")?.value ?? 1; - return `id[${i},${n}]${args(n)}`; - } - case "Composition": { - const m = block.num_values?.find(v => v.name === "m")?.value ?? 1; - return `C${n}[${m}]${args(n)}`; - } - case "Primitive Recursion": - return `Pr[f,g]${args(n > 0 ? n - 1 : 0, "y")}`; - case "Minimization": - return `Mn[f]${args(n)}`; - default: - return ""; +const getSlotLabel = (blockType: BlockType, slotName: string): React.ReactNode => { + if (blockType === "Composition") { + return slotName.startsWith("g") + ? g{slotName.slice(1)} + : {slotName}; } + return slotName; }; -/* -A JSX element that represents a visual block element. -It is meant to be dragged into block slots. -Block is the BlockData of this block element. -onUpdate is a function that gets called when this block is modified -(meaning it is deleted, a value is modified, or an ancestor is modified) -and replaces the old block with the new block. -*/ export function Block({ block, onUpdate, highlightedBlockId, selectedBlockId, onSelectBlock, isRunning = false }: Props) { - const [collapsed, setCollapsed] = React.useState(block?.collapsed); + const [collapsed, setCollapsed] = React.useState(block?.collapsed ?? false); const [showInfo, setShowInfo] = React.useState(false); + const { blockExecutionStates, prTraceMode, setPRTraceMode, prTraceFrames, prOriginalInputs, prFinalOutputs } = useBlockEditor(); if (!block) { return Drop block here; @@ -85,7 +91,26 @@ export function Block({ block, onUpdate, highlightedBlockId, selectedBlockId, on const dragRef = React.useRef(null); drag(dragRef); - // Format input/output for display + const executionState = blockExecutionStates[block.id]; + const isMutedCustomBlock = block.type === "Custom" || block.immutable; + + // Link highlight strictly to the active step + // This ensures that as soon as the 'Step' moves the highlight elsewhere, the color reverts. + const isCurrentlyActive = highlightedBlockId === block.id; + const showInputChange = isCurrentlyActive && executionState?.inputs !== undefined; + const showOutputChange = isCurrentlyActive && executionState?.output !== undefined; + + const isPRTraceActive = block.type === "Primitive Recursion" && prTraceMode[block.id]; + + // When PR trace panel is on, freeze in at original inputs, hide out while running + const displayInput = isPRTraceActive && prOriginalInputs[block.id] + ? prOriginalInputs[block.id] + : executionState?.inputs ?? block.latestInput; + + const displayOutput = isPRTraceActive + ? (prFinalOutputs[block.id] !== undefined ? prFinalOutputs[block.id] : undefined) + : executionState?.output ?? block.latestOutput; + const formatInput = (input: number[] | undefined) => { if (!input || input.length === 0) return '—'; return `(${input.join(', ')})`; @@ -95,18 +120,21 @@ export function Block({ block, onUpdate, highlightedBlockId, selectedBlockId, on return output !== undefined ? output.toString() : '—'; }; + const depthColor = getDepthColor(block.depth || 0); + const customColors = isMutedCustomBlock ? getCustomBlockColors(block.depth || 0) : null; + return (
{ e.stopPropagation(); if (onSelectBlock && block) { @@ -128,51 +156,38 @@ export function Block({ block, onUpdate, highlightedBlockId, selectedBlockId, on {block.hasBreakpoint ? "\u25CF" : "\u25CB"}
-
{block.name || block.type.toUpperCase()}
+
{block.name || blockConfig[block.type]?.displayName || block.type.toUpperCase()}
{block.type !== "Custom" && ( -
{getMathNotation(block)}
+
{getHeaderNotation(block)}
)}
-
- In: - {formatInput(block.latestInput)} -
{block.errors.map((error, index) => ( -
- {error} -
+
{error}
))}
{blockConfig[block.type]?.description && ( - + )} - {block.children.length > 0 && ( + {block.type === "Primitive Recursion" && ( )} + {(block.children.length > 0 || (block.num_values && block.num_values.length > 0) || block.type === "Zero" || block.type === "Successor") && ( + + )} {!block.immutable && ( - + )}
@@ -183,17 +198,60 @@ export function Block({ block, onUpdate, highlightedBlockId, selectedBlockId, on )} -
- {block.children.map((slot) => ( -
- ))} +
+ In: + + {formatInput(displayInput)} +
- + {!collapsed && ( + <> +
+ {(block.type === "Primitive Recursion" ? [...block.children].reverse() : block.children).map((slot, i) => { + const slotDisplay = ( + + ); + + return ( + + {blockConfig[block.type].showSlotLabels && ( +
{getSlotLabel(block.type, slot.name)}
+ )} +
{slotDisplay}
+ {block.type === "Primitive Recursion" && i === 0 && prTraceMode[block.id] && ( + <> +
Trace Panel
+ + + )} +
+ ); + })} +
+ + + )}
Out: - {formatOutput(block.latestOutput)} + + {formatOutput(displayOutput)} +
); @@ -203,17 +261,14 @@ export function getDefaultChildren(type: BlockType, depth: number = 0): Array ({ ...child, - block: child.block ? { - ...child.block, - depth: depth + 1 - } : null + block: child.block ? { ...child.block, depth: depth + 1 } : null })) : []; } export function getDefaultValues(type: BlockType): Array<{ name: string; value: number }> { const blockDef = blockConfig[type]; if (blockDef?.num_values) { - return blockDef.num_values.map(v => ({ ...v })); // return a copy + return blockDef.num_values.map(v => ({ ...v })); } return []; -} \ No newline at end of file +} diff --git a/recursive_blocks/src/BlockConfig.ts b/recursive_blocks/src/BlockConfig.ts index 729d258..b689f7a 100644 --- a/recursive_blocks/src/BlockConfig.ts +++ b/recursive_blocks/src/BlockConfig.ts @@ -3,10 +3,15 @@ import { BlockData } from "./BlockUtil"; // Custom enum type to represent all the block types (each type has their own config) export type BlockType = "Zero" | "Successor" | "Projection" | "Composition" | "Primitive Recursion" | "Minimization" | "Custom"; +// Execution context for passing runtime options and callbacks deeply through the tree +export interface ExecutionContext { + onClearSubtree?: (blockToClear: BlockData) => void; +} + // Function signature for an evaluation function. // It takes in the block to evaluate, the inputs, and the evaluateBlock function, and returns an output number. -// onStepCallback is optional and used for stepping through evaluation. -export type BlockEvaluator = (block: BlockData, inputs: number[], evaluate: BlockEvaluator, onStepCallback?: (block: BlockData, result: number) => Promise) => number | Promise; +// context is optional and used for stepping through evaluation or handling lifecycle events. +export type BlockEvaluator = (block: BlockData, inputs: number[], evaluate: BlockEvaluator, context?: ExecutionContext) => number | Promise; // Function signature for a function that takes in a number, and returns a string to be displayed in a blockslot wanting that many inputs. export type InputDescriptorGenerator = (inputCount: number) => string; @@ -70,12 +75,14 @@ export const blockConfig: Record string[]; + displayName?: string; description?: string; + showSlotLabels?: boolean; }> = { "Zero": { type: "Zero" as BlockType, children: [], - evaluate: (_block, _inputs, _evaluate, onStepCallback) => { + evaluate: (_block, _inputs, _evaluate, _context) => { // Zero block always returns 0 const result = 0; return result; @@ -91,7 +98,7 @@ export const blockConfig: Record { + evaluate: (_block, inputs, _evaluate, _context) => { // Successor block returns the input incremented by 1 if (inputs.length !== 1) { throw new Error("Successor block requires exactly one input."); @@ -109,11 +116,12 @@ export const blockConfig: Record { + evaluate: (block, inputs, _evaluate, _context) => { // Projection block returns the i-th input if (inputs.length <= 0) { throw new Error("Projection block requires at least one input."); @@ -146,7 +154,7 @@ export const blockConfig: Record { + evaluate: async (block, inputs, evaluate, context) => { if (!block.num_values || block.num_values.length === 0) { throw new Error("Composition block requires num_values to specify 'm'."); } @@ -157,15 +165,18 @@ export const blockConfig: Record c.name === "f")?.block; + if (!f_block) { + throw new Error(`f block is missing in Composition.`); } - const result = await evaluate(block.children[0].block!, g_results, evaluate, onStepCallback); + const result = await evaluate(f_block, g_results, evaluate, context); return result; }, dynamicChildren: (block: BlockData) => { const m = block!.num_values!.find(v => v.name === "m")?.value ?? 1; return [ - { name: "f", block: block.children.find(c => c.name === "f")?.block ?? null, input_descriptor_index: 1, input_set: m }, ...Array.from({ length: m }, (_, i) => { const name = `g${i + 1}`; return { @@ -173,7 +184,8 @@ export const blockConfig: Record c.name === name)?.block ?? null, input_descriptor_index: 0, }; - }) + }), + { name: "f", block: block.children.find(c => c.name === "f")?.block ?? null, input_descriptor_index: 1, input_set: m } ]; }, checkForErrors: (block) => { @@ -184,7 +196,8 @@ export const blockConfig: Record { + evaluate: async (block, inputs, evaluate, context) => { // Primitive Recursion block evaluates based on the base case and recursive case if (inputs.length < 1) { throw new Error("Primitive Recursion block requires at least one inputs."); } if (inputs[inputs.length - 1] <= 0) { // Base case: if the last input is 0, evaluate the base case block - const result = await evaluate(block.children[0].block!, inputs.slice(0, -1), evaluate, onStepCallback); + const result = await evaluate(block.children[0].block!, inputs.slice(0, -1), evaluate, context); return result; } else { // Recursive case: evaluate the recursive case block with the inputs const inputs_decremented = inputs.slice(0, -1).concat(inputs[inputs.length - 1] - 1); // console.log("Inputs for recursive case:", inputs_decremented); - const intermediateResult = await evaluate(block, inputs_decremented, evaluate, onStepCallback); + const intermediateResult = await evaluate(block, inputs_decremented, evaluate, context); const inputs_combined_with_previous = inputs_decremented.concat(intermediateResult); - const result = await evaluate(block.children[1].block!, inputs_combined_with_previous, evaluate, onStepCallback); + + if (context?.onClearSubtree && block.children[1].block) { + context.onClearSubtree(block.children[1].block); + } + + const result = await evaluate(block.children[1].block!, inputs_combined_with_previous, evaluate, context); return result; } }, @@ -218,16 +236,17 @@ export const blockConfig: Record= 1 inputs. - If rightmost input is 0, returns the base case. - Otherwise, returns the recursive case evaluated where y = (rightmost input - 1), and z is the result of the previous recursive step (this block, evaluated with rightmost input decremented).` + description: `n >= 1 inputs. + If rightmost input is 0, returns the base case. + Otherwise, returns the recursive case evaluated where y = (rightmost input - 1), and z is the result of the previous recursive step (this block, evaluated with rightmost input decremented).`, + showSlotLabels: true, }, "Minimization": { type: "Minimization" as BlockType, children: [ { name: "f", block: null, input_descriptor_index: 2, input_mod: 1 }, ], - evaluate: async (block, inputs, evaluate, onStepCallback) => { + evaluate: async (block, inputs, evaluate, context) => { // Minimization block finds the smallest n such that f(..., n) = 0 const f_block = block.children[0].block; if (!f_block) { @@ -237,7 +256,7 @@ export const blockConfig: Record { + evaluate: async (block, inputs, evaluate, context) => { // Custom block evaluation logic if (block.children[0].block) { - const result = await evaluate(block.children[0].block, inputs, evaluate, onStepCallback); + const result = await evaluate(block.children[0].block, inputs, evaluate, context); return result; } throw new Error("Custom block is empty."); diff --git a/recursive_blocks/src/BlockEditor.tsx b/recursive_blocks/src/BlockEditor.tsx index f7c85f6..f93a23d 100644 --- a/recursive_blocks/src/BlockEditor.tsx +++ b/recursive_blocks/src/BlockEditor.tsx @@ -1,16 +1,13 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { BlockData, checkForErrors, evaluateBlock, setInputCountOfBlock, stepBlock } from "./BlockUtil"; +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { BlockData, checkForErrors, removeBlockById, setInputCountOfBlock } from "./BlockUtil"; import './Block.css'; import { Toolbar } from "./Toolbar"; import { BlockSave, deserializeBlock, serializeBlock } from "./BlockSave"; import { useBlockEditor } from "./BlockEditorContext"; import { BlockSlotDisplay } from "./BlockSlot"; import { BlockPalette } from "./BlockPalette"; -import { removeBlockById } from "./BlockUtil"; - -// TEMP: Import test helper functions -import { addGarbageTestData, clearExecutionData } from "./TEST_HELPER"; - +import { generateTrace, TraceEvent, TraceEventType, buildPRFrames, PRTraceFrame } from "./Trace"; +import { getFormalNotation } from "./Notation"; export interface EditorSaveState { fileType: string; @@ -23,27 +20,48 @@ export interface EditorSaveState { export const CURRENT_FILETYPE_VERSION = "BRAM_EDITOR_STATE_V2"; export const DEFAULT_INPUT_COUNT = 2; +export const MAX_INPUT_COUNT = 12; +export const MAX_CUSTOM_BLOCKS = 40; export const customBlocks: Record = {}; +const clampInputCount = (count: number) => { + if (!Number.isFinite(count)) return 0; + return Math.min(MAX_INPUT_COUNT, Math.max(0, count)); +}; + +enum PlaybackMode { + Run, + ForceRun, + Step, + Trace +} + // JSX element that represents the editor, containing a root block and the header. export function BlockEditor() { - const { inputCount, setInputCount, rootBlock, setRootBlock, customBlockCount: _customBlockCount, setCustomBlockCount } = useBlockEditor(); + const { inputCount, setInputCount, rootBlock, setRootBlock, setCustomBlockCount, setBlockExecutionStates, setEditMode, setPRTraceFrames, setPROriginalInputs, setPRFinalOutputs } = useBlockEditor(); const [inputs, setInputs] = useState(new Array(inputCount > 0 ? inputCount : 0).fill(0)); const [highlightedBlockId, setHighlightedBlockId] = useState(null); const [selectedBlockId, setSelectedBlockId] = useState(null); const [currentResult, setCurrentResult] = useState(null); + const [notationPopupText, setNotationPopupText] = useState(null); const [isEvaluating, setIsEvaluating] = useState(false); - const [hasRun, setHasRun] = useState(false); const [evaluationSpeed, setEvaluationSpeed] = useState(2); const [paused, setPaused] = useState(false); const loadInputRef = useRef(null); - const pauseResolver = useRef<((value: void | PromiseLike) => void) | null>(null); const isHaltedRef = useRef(false); - const pauseAtNextStepRef = useRef(false); const breakpointsRef = useRef>(new Set()); - const ignoreBreakpointsRef = useRef(false); + + // Trace playback state + const traceRef = useRef(null); + const cursorRef = useRef(0); + const finalResultRef = useRef(null); + const playbackDepthsRef = useRef>(new Map()); + + // PR trace panel state + const prFrameListRef = useRef>({}); + const prCursorRef = useRef>({}); useEffect(() => { const newBreakpoints = new Set(); @@ -89,7 +107,10 @@ export function BlockEditor() { }, [inputCount, rootBlock]); const handleInputCountChange = (count: number) => { - const clamped = Math.max(0, count); + if (Number.isFinite(count) && count > MAX_INPUT_COUNT) { + alert(`Input count is capped at ${MAX_INPUT_COUNT}.`); + } + const clamped = clampInputCount(count); setInputCount(clamped); }; @@ -103,6 +124,19 @@ export function BlockEditor() { createSaveFile(rootBlock ? serializeBlock(rootBlock) : undefined, inputs, inputCount > 0 ? inputCount : 0); }, [rootBlock, inputs, inputCount]); + const handleShowNotation = useCallback(() => { + if (!rootBlock) { + setNotationPopupText("No root block to format."); + return; + } + + setNotationPopupText(getFormalNotation(rootBlock)); + }, [rootBlock]); + + const handleCloseNotationPopup = useCallback(() => { + setNotationPopupText(null); + }, []); + const handleDeleteSelectedBlock = useCallback(() => { if (!rootBlock || !selectedBlockId) return; @@ -120,48 +154,31 @@ export function BlockEditor() { setHighlightedBlockId(null); }, [rootBlock, selectedBlockId]); - const handleAddTestData = useCallback(() => { - if (!rootBlock) { - alert("No blocks to add test data to. Create some blocks first!"); - return; - } - addGarbageTestData(rootBlock); - setRootBlock({ ...rootBlock }); - console.log("Test data added to all blocks"); - }, [rootBlock, setRootBlock]); - - const handleClearTestData = useCallback(() => { - if (!rootBlock) return; - clearExecutionData(rootBlock); - setRootBlock({ ...rootBlock }); - console.log("Test data cleared from all blocks"); - }, [rootBlock, setRootBlock]); - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - const isMac = navigator.platform.toUpperCase().includes("MAC"); - const ctrl = isMac ? e.metaKey : e.ctrlKey; + const handleKeyDown = (event: KeyboardEvent) => { + const isMac = navigator.userAgent.toUpperCase().includes("MAC"); + const ctrl = isMac ? event.metaKey : event.ctrlKey; if ( document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement ) return; - if ((e.key === "Backspace" || e.key === "Delete") && selectedBlockId) { - e.preventDefault(); + if ((event.key === "Backspace" || event.key === "Delete") && selectedBlockId) { + event.preventDefault(); handleDeleteSelectedBlock(); } - if (ctrl && e.shiftKey && e.key.toLowerCase() === "s") { - e.preventDefault(); + if (ctrl && event.shiftKey && event.key.toLowerCase() === "s") { + event.preventDefault(); handleSave(); } - if (ctrl && e.key.toLowerCase() === "o") { - e.preventDefault(); + if (ctrl && event.key.toLowerCase() === "o") { + event.preventDefault(); loadInputRef.current?.click(); } - } + }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); @@ -225,14 +242,35 @@ export function BlockEditor() { throw new Error("Invalid or incompatible .bramflower file."); } + const clampedInputCount = clampInputCount(loadedState.inputCount); + const normalizedInputs = Array.from( + { length: clampedInputCount }, + (_, i) => loadedState.inputs[i] ?? 0 + ); + setRootBlock(loadedState.rootBlock ? deserializeBlock(loadedState.rootBlock) : null); - setInputs(loadedState.inputs); - setInputCount(loadedState.inputCount); + setInputs(normalizedInputs); + setInputCount(clampedInputCount); - setCustomBlockCount(Object.keys(customBlocks).length); - for (const [name, customBlock] of Object.entries(loadedState.customBlocks)) { + const nextCustomBlocks = Object.entries(loadedState.customBlocks ?? {}); + const limitedCustomBlocks = nextCustomBlocks.slice(0, MAX_CUSTOM_BLOCKS); + + Object.keys(customBlocks).forEach((key) => { + delete customBlocks[key]; + }); + + for (const [name, customBlock] of limitedCustomBlocks) { customBlocks[customBlock.name ?? name] = customBlock; } + setCustomBlockCount(Object.keys(customBlocks).length); + + if (loadedState.inputCount !== clampedInputCount) { + alert(`Loaded file used ${loadedState.inputCount} inputs. The editor cap is ${MAX_INPUT_COUNT}, so it was reduced.`); + } + + if (nextCustomBlocks.length > MAX_CUSTOM_BLOCKS) { + alert(`Loaded file had ${nextCustomBlocks.length} custom blocks. Only the first ${MAX_CUSTOM_BLOCKS} were loaded.`); + } console.log("State loaded successfully."); } catch (error) { @@ -254,99 +292,220 @@ export function BlockEditor() { const handleHalt = () => { isHaltedRef.current = true; - pauseAtNextStepRef.current = false; - setHasRun(false); - - if (pauseResolver.current) { - pauseResolver.current(); - pauseResolver.current = null; - } + traceRef.current = null; + cursorRef.current = 0; + finalResultRef.current = null; + playbackDepthsRef.current.clear(); + prFrameListRef.current = {}; + prCursorRef.current = {}; + setBlockExecutionStates({}); + setPRTraceFrames({}); + setPROriginalInputs({}); + setPRFinalOutputs({}); + setCurrentResult(null); + setHighlightedBlockId(null); + setIsEvaluating(false); setPaused(false); }; - - const handleResume = () => { - if (pauseResolver.current) { - pauseResolver.current(); - pauseResolver.current = null; - setPaused(false); + + const advancePRPanel = (blockId: string) => { + if (!prFrameListRef.current[blockId]) return; + const cursor = prCursorRef.current[blockId] ?? 0; + const frames = prFrameListRef.current[blockId]; + if (cursor < frames.length) { + setPRTraceFrames(prev => ({ ...prev, [blockId]: frames[cursor] })); + prCursorRef.current[blockId] = cursor + 1; } }; - - const handleStep = async () => { - if (isEvaluating) { - if (paused) { - pauseAtNextStepRef.current = true; - handleResume(); - } else { - pauseAtNextStepRef.current = true; + + const advance = async (mode: PlaybackMode) => { + const trace = traceRef.current; + if (!trace) return; + + const speedMap: Record = { 0: 500, 1: 100, 2: 0 }; + const delay = speedMap[evaluationSpeed]; + const depths = playbackDepthsRef.current; + + while (cursorRef.current < trace.length) { + if (isHaltedRef.current) break; + + const event = trace[cursorRef.current]; + cursorRef.current++; + + if (event.type === TraceEventType.Enter) { + setHighlightedBlockId(event.blockId); + + const d = depths.get(event.blockId) || 0; + depths.set(event.blockId, d + 1); + + // Store original inputs on first entry for PR blocks + if (d === 0 && prFrameListRef.current[event.blockId]) { + setPROriginalInputs(prev => ({ ...prev, [event.blockId]: [...event.inputs] })); } - } else { - pauseAtNextStepRef.current = true; - handleRun(); + + setBlockExecutionStates(prev => ({ + ...prev, + [event.blockId]: { inputs: event.inputs, output: undefined } + })); + + if (prFrameListRef.current[event.blockId]) { + advancePRPanel(event.blockId); + } + + let shouldPause = false; + if (mode === PlaybackMode.Trace) shouldPause = true; + if (mode === PlaybackMode.Run && breakpointsRef.current.has(event.blockId) && d === 0) { + shouldPause = true; + } + + if (shouldPause) { + setPaused(true); + return; + } + + } else if (event.type === TraceEventType.Exit) { + setHighlightedBlockId(event.blockId); + setCurrentResult(event.output); + + const d = depths.get(event.blockId) || 0; + depths.set(event.blockId, Math.max(0, d - 1)); + + setBlockExecutionStates(prev => ({ + ...prev, + [event.blockId]: { inputs: event.inputs, output: event.output } + })); + + if (prFrameListRef.current[event.blockId]) { + advancePRPanel(event.blockId); + // Store final output when outermost call completes + if (d === 1) { + setPRFinalOutputs(prev => ({ ...prev, [event.blockId]: event.output })); + } + } + + let shouldPause = false; + if (mode === PlaybackMode.Trace) shouldPause = true; + if (mode === PlaybackMode.Step) shouldPause = true; + + if (shouldPause) { + setPaused(true); + return; + } + + if (delay > 0) { + await sleep(delay); + if (trace !== traceRef.current) return; // stale trace after halt+restart + } + + } else if (event.type === TraceEventType.ClearSubtree) { + setBlockExecutionStates(prev => { + const next = { ...prev }; + for (const id of event.blockIds) { + delete next[id]; + } + return next; + }); + for (const id of event.blockIds) { + depths.delete(id); + } + + // Clear PR state for any blocks in the cleared subtrees + + // Refactor these separate PR states into one later, a single type with { frame, originalInputs, finalOutput} + // Would be easier to manage and no repetition like this + const prIdsToDelete = event.blockIds.filter(id => id in prFrameListRef.current); + if (prIdsToDelete.length > 0) { + setPRTraceFrames(prev => { + const next = { ...prev }; + for (const id of prIdsToDelete) delete next[id]; + return next; + }); + setPROriginalInputs(prev => { + const next = { ...prev }; + for (const id of prIdsToDelete) delete next[id]; + return next; + }); + setPRFinalOutputs(prev => { + const next = { ...prev }; + for (const id of prIdsToDelete) delete next[id]; + return next; + }); + } + } } + + // Playback complete + if (!isHaltedRef.current && finalResultRef.current !== null) { + setCurrentResult(finalResultRef.current); + } + setHighlightedBlockId(null); + setIsEvaluating(false); + setPaused(false); }; - const handleRun = async (ignoreBreakpoints: boolean = false) => { - if (paused && ignoreBreakpoints) { - ignoreBreakpointsRef.current = true; - handleResume(); + const startOrResume = async (mode: PlaybackMode) => { + setEditMode(false); + + if (isEvaluating && paused) { + setPaused(false); + await advance(mode); return; } if (isEvaluating) return; - setIsEvaluating(true); - setHasRun(true); - isHaltedRef.current = false; - setCurrentResult(null); - ignoreBreakpointsRef.current = ignoreBreakpoints; if (!rootBlock) { alert("No root block to evaluate."); - setIsEvaluating(false); return; } - try { - const speedMap: Record = { - 0: 500, - 1: 100, - 2: 0, - }; - const delay = speedMap[evaluationSpeed]; - - const onStepCallback = async (block: BlockData, result: number) => { - if (isHaltedRef.current) throw new Error("Halted"); - - setHighlightedBlockId(block.id); - setCurrentResult(result); + setIsEvaluating(true); + isHaltedRef.current = false; + setCurrentResult(null); + setBlockExecutionStates({}); + playbackDepthsRef.current.clear(); - const shouldPause = (breakpointsRef.current.has(block.id) && !ignoreBreakpointsRef.current) || pauseAtNextStepRef.current; - if (shouldPause) { - setPaused(true); - pauseAtNextStepRef.current = false; - await new Promise(resolve => { - pauseResolver.current = resolve; - }); - setPaused(false); + try { + const { result, events } = await generateTrace(rootBlock, inputs); + traceRef.current = events; + cursorRef.current = 0; + finalResultRef.current = result; + + // Build PR trace frames for all PR blocks in the tree + const collectPRIds = (b: BlockData): string[] => { + const ids: string[] = []; + if (b.type === "Primitive Recursion") ids.push(b.id); + for (const slot of b.children) { + if (slot.block) ids.push(...collectPRIds(slot.block)); } - - if (delay > 0) await sleep(delay); + return ids; }; - await stepBlock(rootBlock, inputs, onStepCallback); + prFrameListRef.current = {}; + prCursorRef.current = {}; + for (const id of collectPRIds(rootBlock)) { + prFrameListRef.current[id] = buildPRFrames(events, id); + prCursorRef.current[id] = 0; + } + setPRTraceFrames({}); + setPROriginalInputs({}); + setPRFinalOutputs({}); + + await advance(mode); } catch (error) { if (error instanceof Error) { - if (error.message !== "Halted") { - alert(`Error: ${error.message}`); - } + alert(`Error: ${error.message}`); } - } finally { setHighlightedBlockId(null); setIsEvaluating(false); setPaused(false); - ignoreBreakpointsRef.current = false; } }; + const handleRun = () => startOrResume(PlaybackMode.Run); + const handleForceRun = () => startOrResume(PlaybackMode.ForceRun); + const handleStep = () => startOrResume(PlaybackMode.Step); + const handleTrace = () => startOrResume(PlaybackMode.Trace); + const speedToText = (speed: number) => { if (speed === 0) return "Slow"; if (speed === 1) return "Fast"; @@ -358,13 +517,15 @@ export function BlockEditor() { handleRun(false)} - onRunIgnoreBreakpoints={() => handleRun(true)} - onResume={handleResume} - paused={paused} + + onRun={handleRun} + onForceRun={handleForceRun} + //paused={paused} onHalt={handleHalt} onStep={handleStep} + onTrace={handleTrace} inputCount={inputCount} onInputCountChange={handleInputCountChange} inputs={inputs} @@ -373,8 +534,7 @@ export function BlockEditor() { onEvaluationSpeedChange={setEvaluationSpeed} speedToText={speedToText} currentResult={currentResult} - onAddTestData={handleAddTestData} - onClearTestData={handleClearTestData} + isEvaluating={isEvaluating} />
@@ -388,17 +548,30 @@ export function BlockEditor() { checkForErrors(block); } setRootBlock(block); - setHasRun(false); }} highlightedBlockId={highlightedBlockId} selectedBlockId={selectedBlockId} onSelectBlock={(id) => setSelectedBlockId(id)} - isRunning={isEvaluating || hasRun} + isRunning={isEvaluating} />

+ + {notationPopupText !== null && ( +
+
+
+
Formal Notation
+ +
+
{notationPopupText}
+
+
+ )} ); -} \ No newline at end of file +} diff --git a/recursive_blocks/src/BlockEditorContext.tsx b/recursive_blocks/src/BlockEditorContext.tsx index e53ba68..1fcb80d 100644 --- a/recursive_blocks/src/BlockEditorContext.tsx +++ b/recursive_blocks/src/BlockEditorContext.tsx @@ -1,26 +1,58 @@ import React, { createContext, useContext, useState } from "react"; import { BlockData } from "./BlockUtil"; import { DEFAULT_INPUT_COUNT } from "./BlockEditor"; +import { PRTraceFrame } from "./Trace"; /* Defines a React Context, which makes the variables inside accessable by any React element within the context. */ +export type ExecutionState = { inputs?: number[]; output?: number }; + const BlockEditorContext = createContext<{ rootBlock: BlockData | null; customBlockCount: number; inputCount: number; + blockExecutionStates: Record; setInputCount: React.Dispatch>; setRootBlock: React.Dispatch>; setCustomBlockCount: React.Dispatch>; + setBlockExecutionStates: React.Dispatch>>; + editMode: boolean; + setEditMode: React.Dispatch>; + prTraceMode: Record; + setPRTraceMode: React.Dispatch>>; + prTraceFrames: Record; + setPRTraceFrames: React.Dispatch>>; + prOriginalInputs: Record; + setPROriginalInputs: React.Dispatch>>; + prFinalOutputs: Record; + setPRFinalOutputs: React.Dispatch>>; } | null>(null); export function BlockEditorProvider({ children }: { children: React.ReactNode }) { const [rootBlock, setRootBlock] = useState(null); const [inputCount, setInputCount] = useState(DEFAULT_INPUT_COUNT); const [customBlockCount, setCustomBlockCount] = useState(0); + const [blockExecutionStates, setBlockExecutionStates] = useState>({}); + const [editMode, setEditMode] = useState(false); + const [prTraceMode, setPRTraceMode] = useState>({}); + const [prTraceFrames, setPRTraceFrames] = useState>({}); + const [prOriginalInputs, setPROriginalInputs] = useState>({}); + const [prFinalOutputs, setPRFinalOutputs] = useState>({}); + return ( - + {children} ); diff --git a/recursive_blocks/src/BlockPalette.tsx b/recursive_blocks/src/BlockPalette.tsx index dfbc4fb..7e60952 100644 --- a/recursive_blocks/src/BlockPalette.tsx +++ b/recursive_blocks/src/BlockPalette.tsx @@ -1,16 +1,16 @@ import { useRef, useEffect } from "react"; import { useDrag } from "react-dnd"; import { blockConfig, BlockType } from "./BlockConfig"; -import { CURRENT_FILETYPE_VERSION, customBlocks, EditorSaveState } from "./BlockEditor"; +import { CURRENT_FILETYPE_VERSION, customBlocks, EditorSaveState, MAX_CUSTOM_BLOCKS } from "./BlockEditor"; import { getDefaultChildren } from "./Block"; import { BlockSave, serializeBlock } from "./BlockSave"; import { useBlockEditor } from "./BlockEditorContext"; import React from "react"; -// JSX element to represent a draggable block type in the block pallete -// Type is the block type. custom_block_index and onRemove are undefined if type is not Custom. -// If type is Custom, custom_block_index is an index in the custom blocks array corresponding to this block, -// and onRemove is a method to remove this custom block from the custom blocks array. +function canAddCustomBlock() { + return Object.keys(customBlocks).length < MAX_CUSTOM_BLOCKS; +} + function DraggableBlock({ type, custom_block_name, onRemove }: { type: BlockType, custom_block_name?: string, onRemove?: (name: string) => void }) { const ref = useRef(null); const [showInfo, setShowInfo] = React.useState(false); @@ -32,9 +32,11 @@ function DraggableBlock({ type, custom_block_name, onRemove }: { type: BlockType return (
-
{custom_block_name !== undefined ? custom_block_name : type.toUpperCase()}
+
{custom_block_name !== undefined ? custom_block_name : (blockConfig[type]?.displayName ?? type.toUpperCase())}
{showInfo && blockConfig[type].description && (
@@ -53,11 +55,9 @@ function DraggableBlock({ type, custom_block_name, onRemove }: { type: BlockType ); } -// JSX element that represents the block palette. -// Contains function to load custom blocks. export function BlockPalette() { - const { customBlockCount: _customBlockCount, setCustomBlockCount } = useBlockEditor(); - const {rootBlock, setRootBlock: _setRootBlock} = useBlockEditor(); + const { setCustomBlockCount } = useBlockEditor(); + const {rootBlock} = useBlockEditor(); function turnRootToCustom() { if (!rootBlock) { @@ -65,6 +65,11 @@ export function BlockPalette() { return; } + if (!canAddCustomBlock()) { + alert(`Custom block limit reached. You can load up to ${MAX_CUSTOM_BLOCKS} custom blocks at once.`); + return; + } + const name = prompt("Enter a name for the new custom block:"); if (!name) { alert("Block creation cancelled."); @@ -77,8 +82,6 @@ export function BlockPalette() { num_values: [] }; - // If the root block is a custom block, we want to save its first child as the definition of the new custom block. - // Otherwise, we save the root block itself as the definition of the new custom block. newBlock.children[0].child = serializeBlock(rootBlock.type !== "Custom" ? rootBlock : rootBlock.children[0].block!); customBlocks[name] = newBlock; @@ -105,6 +108,11 @@ export function BlockPalette() { return; } + if (!canAddCustomBlock()) { + alert(`Custom block limit reached. You can load up to ${MAX_CUSTOM_BLOCKS} custom blocks at once.`); + return; + } + let newBlock: BlockSave; if (parsed.rootBlock.type == "Custom") { newBlock = parsed.rootBlock; @@ -168,4 +176,4 @@ export function BlockPalette() {
); -} \ No newline at end of file +} diff --git a/recursive_blocks/src/BlockUtil.tsx b/recursive_blocks/src/BlockUtil.tsx index 3b31a77..71bd79f 100644 --- a/recursive_blocks/src/BlockUtil.tsx +++ b/recursive_blocks/src/BlockUtil.tsx @@ -1,4 +1,4 @@ -import { blockConfig, BlockType, BlockEvaluator, BlockSlot } from "./BlockConfig"; +import { blockConfig, BlockType, BlockSlot } from "./BlockConfig"; /* A custom data type that contains all the data for a block placed into the block tree. */ export interface BlockData { @@ -57,22 +57,15 @@ export async function evaluateBlock( return ev; } -// Step through block evaluation with optional callback for visualization -export async function stepBlock( - block: BlockData, - inputs: number[], - onStepCallback?: (block: BlockData, result: number) => Promise -): Promise { - const evaluateWithCallback: BlockEvaluator = async (b, i, _eval, callback) => { - return await stepBlock(b, i, callback); - }; - - const config = blockConfig[block.type]; - const ev = await config.evaluate(block, inputs, evaluateWithCallback, onStepCallback); - if (onStepCallback) { - await onStepCallback(block, ev); +// Recursively collects all block IDs in a subtree (including the root). +export function collectDescendantIds(block: BlockData): string[] { + const ids = [block.id]; + for (const slot of block.children) { + if (slot.block) { + ids.push(...collectDescendantIds(slot.block)); + } } - return ev; + return ids; } // Takes in a defaultCount, and modifies it using the slot's input modifiers to get the actual input count the slot wants. diff --git a/recursive_blocks/src/Notation.ts b/recursive_blocks/src/Notation.ts new file mode 100644 index 0000000..68e0f2c --- /dev/null +++ b/recursive_blocks/src/Notation.ts @@ -0,0 +1,143 @@ +import { BlockData } from "./BlockUtil"; + +// Use unicode for sub and superscripts. Could be handled with react, but this is simpler +const SUPERSCRIPT_DIGITS: Record = { + "0": "\u2070", + "1": "\u00b9", + "2": "\u00b2", + "3": "\u00b3", + "4": "\u2074", + "5": "\u2075", + "6": "\u2076", + "7": "\u2077", + "8": "\u2078", + "9": "\u2079", +}; + +const SUBSCRIPT_DIGITS: Record = { + "0": "\u2080", + "1": "\u2081", + "2": "\u2082", + "3": "\u2083", + "4": "\u2084", + "5": "\u2085", + "6": "\u2086", + "7": "\u2087", + "8": "\u2088", + "9": "\u2089", +}; + +const toScriptNumber = (value: number, map: Record) => + value.toString().split("").map(ch => map[ch] ?? ch).join(""); + +const toSuperscript = (value: number) => toScriptNumber(value, SUPERSCRIPT_DIGITS); +const toSubscript = (value: number) => toScriptNumber(value, SUBSCRIPT_DIGITS); + +// Builds generic input arguments like (x1, x2, ..., xN) for a given arity +const inputArgs = (count: number) => { + if (count <= 0) return "()"; + return `(${Array.from({ length: count }, (_, i) => `x${i + 1}`).join(", ")})`; +}; + +// Returns a named child slot's formal notation (or "?" when missing) +const childFormal = (block: BlockData, slotName: string): string => { + const child = block.children.find(s => s.name === slotName)?.block; + return child ? getFormalNotation(child) : "?"; +}; + +// Returns composition's g function child slots (g1..gm) in their stored order +const compositionGSlots = (block: BlockData) => { + return block.children.filter(slot => slot.name.startsWith("g")); +}; + +export const getHeaderNotation = (block: BlockData): string => { + const n = block.inputCount ?? 0; + const args = inputArgs(n); + + switch (block.type) { + case "Zero": + return `z${args}`; + case "Successor": + return `s${args}`; + case "Projection": + return `id[n, m]${args}`; + case "Composition": { + const gSymbols = compositionGSlots(block).map((_, i) => `g${i + 1}`); + return `Cn[${["f", ...gSymbols].join(", ")}]${args}`; + } + case "Primitive Recursion": + return `Pr[f, g]${args}`; + case "Minimization": + return `Mn[f]${args}`; + case "Custom": + return `${block.name ?? "Custom"}[h]${args}`; + default: + return `?${args}`; + } +}; + +export function getFormalNotation(block: BlockData): string { + switch (block.type) { + case "Successor": + return "s"; + case "Zero": + return "z"; + case "Projection": { + const n = block.inputCount ?? 0; + const m = block.num_values?.find(v => v.name === "i")?.value ?? 1; + return `id${toSuperscript(n)}${toSubscript(m)}`; + } + case "Composition": { + const fExpr = childFormal(block, "f"); + const gExprs = compositionGSlots(block).map(slot => (slot.block ? getFormalNotation(slot.block) : "?")); + const suffix = gExprs.length > 0 ? `, ${gExprs.join(", ")}` : ""; + return `Cn[${fExpr}${suffix}]`; + } + case "Primitive Recursion": + return `Pr[${childFormal(block, "Base Case")}, ${childFormal(block, "Recursive Case")}]`; + case "Minimization": + return `Mn[${childFormal(block, "f")}]`; + case "Custom": + return childFormal(block, "Custom Function"); + default: + return "?"; + } +} + +export function getInformalNotation( + block: BlockData, + valueName?: string, + value?: number +): { label: string; value: string } | null { + if (valueName !== undefined && value !== undefined) { + if (block.type === "Projection" && valueName === "i") { + const inputCount = Math.max(block.inputCount, value, 1); + const args = Array.from({ length: inputCount }, (_, i) => `x${i + 1}`).join(", "); + + return { + label: `f(${args}) =`, + value: `x${value}`, + }; + } + + if (block.type === "Composition" && valueName === "m") { + return { + label: "arity \u2192", + value: `${value} inner block${value === 1 ? "" : "s"}`, + }; + } + + return { + label: `${valueName} \u2192`, + value: value.toString(), + }; + } + + const n = block.inputCount ?? 0; + const args = inputArgs(n); + + if (block.type === "Zero") return { label: `f${args} =`, value: "0" }; + if (block.type === "Successor") return { label: "f(x) =", value: "x + 1" }; + + return null; +} diff --git a/recursive_blocks/src/PRTracePanel.tsx b/recursive_blocks/src/PRTracePanel.tsx new file mode 100644 index 0000000..ddbd8e6 --- /dev/null +++ b/recursive_blocks/src/PRTracePanel.tsx @@ -0,0 +1,42 @@ +import { PRTraceFrame } from "./Trace"; +import './Block.css'; + +interface Props { + frame?: PRTraceFrame | null; +} + +export function PRTracePanel({ frame }: Props) { + const fmtVals = (v: number[] | null | undefined) => { + if (!v || v.length === 0) return '\u2014'; + return v.join(', '); + }; + + const fmtResult = (v: number | null | undefined) => { + return v !== null && v !== undefined ? String(v) : '\u2014'; + }; + + return ( +
+
+
+ x,y' + {fmtVals(frame?.xy_prime)} +
+
+ h(x,y') + {fmtResult(frame?.hxy_prime)} +
+
+
+
+ x,y + {fmtVals(frame?.xy)} +
+
+ h(x,y) + {fmtResult(frame?.hxy)} +
+
+
+ ); +} diff --git a/recursive_blocks/src/TEST_HELPER.tsx b/recursive_blocks/src/TEST_HELPER.tsx deleted file mode 100644 index 951ba3d..0000000 --- a/recursive_blocks/src/TEST_HELPER.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// TEST_HELPER.tsx -// This file shows how to add garbage test data to blocks for testing the In/Out display -import { BlockData } from "./BlockUtil"; - -// Adds random garbage input/output values to a block and all its children for testing the visualization -export function addGarbageTestData(block: BlockData): void { - - // Generate random garbage data - const inputCount = block.inputCount || 2; - const garbageInput = Array.from({ length: inputCount }, () => Math.floor(Math.random() * 10)); - const garbageOutput = Math.floor(Math.random() * 20); - - // Add to this block - block.latestInput = garbageInput; - block.latestOutput = garbageOutput; - - // Recursively add to children - for (const slot of block.children) { - if (slot.block) { - addGarbageTestData(slot.block); - } - } -} - -// Clears all input/output data from a block and its children -export function clearExecutionData(block: BlockData): void { - block.latestInput = undefined; - block.latestOutput = undefined; - - for (const slot of block.children) { - if (slot.block) { - clearExecutionData(slot.block); - } - } -} \ No newline at end of file diff --git a/recursive_blocks/src/Toolbar.css b/recursive_blocks/src/Toolbar.css index d51db82..1808f3c 100644 --- a/recursive_blocks/src/Toolbar.css +++ b/recursive_blocks/src/Toolbar.css @@ -115,6 +115,24 @@ box-shadow: none; } +/* disabled state for Edit button during execution */ +.toolbar-button-disabled, +.toolbar-button-disabled:hover { + opacity: 0.35; + cursor: not-allowed; + border-color: transparent !important; +} +.toolbar-button-active { + border-color: var(--contrast-col) !important; + color: var(--contrast-col) !important; + border-radius: 4px !important; +} + +.toolbar-button-active:hover { + border-color: var(--contrast-col) !important; + border-radius: 4px !important; +} + /* Icon Buttons */ .icon-button { width: 38px; diff --git a/recursive_blocks/src/Toolbar.tsx b/recursive_blocks/src/Toolbar.tsx index 4a12983..069067c 100644 --- a/recursive_blocks/src/Toolbar.tsx +++ b/recursive_blocks/src/Toolbar.tsx @@ -1,13 +1,18 @@ import React, { } from "react"; import './Toolbar.css'; +import { useBlockEditor } from "./BlockEditorContext"; +import { MAX_INPUT_COUNT } from "./BlockEditor"; interface ToolbarProps { onSave: () => void; onLoad: (event: React.ChangeEvent) => void; + onShowNotation: () => void; loadInputRef: React.RefObject; onRun: () => void; - onHalt: () => void; + onForceRun: () => void; onStep: () => void; + onTrace: () => void; + onHalt: () => void; inputCount: number; onInputCountChange: (count: number) => void; inputs: number[]; @@ -16,17 +21,19 @@ interface ToolbarProps { onEvaluationSpeedChange: (speed: number) => void; speedToText: (speed: number) => string; currentResult: number | null; - onAddTestData?: () => void; - onClearTestData?: () => void; + isEvaluating: boolean; } export function Toolbar({ onSave, onLoad, + onShowNotation, loadInputRef, onRun, - onHalt, + onForceRun, onStep, + onTrace, + onHalt, inputCount, onInputCountChange, inputs, @@ -35,9 +42,9 @@ export function Toolbar({ onEvaluationSpeedChange, speedToText, currentResult, - onAddTestData, - onClearTestData + isEvaluating }: ToolbarProps) { + const { editMode, setEditMode } = useBlockEditor(); return ( <> @@ -68,38 +75,47 @@ export function Toolbar({ + {/* Edit mode toggle button */}
- -
{/* Program execution controls */} -
+
+ {/* run buttons lock edit; halt unlocks it - no async race condition */} - + +
); -} \ No newline at end of file +} diff --git a/recursive_blocks/src/Trace.ts b/recursive_blocks/src/Trace.ts new file mode 100644 index 0000000..abd7d47 --- /dev/null +++ b/recursive_blocks/src/Trace.ts @@ -0,0 +1,116 @@ +import { BlockData, collectDescendantIds } from "./BlockUtil"; +import { blockConfig, BlockEvaluator, ExecutionContext } from "./BlockConfig"; + +export enum TraceEventType { + Enter, + Exit, + ClearSubtree +} + +export type TraceEvent = + | { type: TraceEventType.Enter; blockId: string; inputs: number[] } + | { type: TraceEventType.Exit; blockId: string; inputs: number[]; output: number } + | { type: TraceEventType.ClearSubtree; blockIds: string[] }; + +export async function generateTrace( + block: BlockData, + inputs: number[] +): Promise<{ result: number; events: TraceEvent[] }> { + const events: TraceEvent[] = []; + + const context: ExecutionContext = { + onClearSubtree: (blockToClear: BlockData) => { + events.push({ type: TraceEventType.ClearSubtree, blockIds: collectDescendantIds(blockToClear) }); + } + }; + + const tracingEvaluator: BlockEvaluator = async (b, i, _eval, _ctx) => { + events.push({ type: TraceEventType.Enter, blockId: b.id, inputs: [...i] }); + const config = blockConfig[b.type]; + const result = await config.evaluate(b, i, tracingEvaluator, context); + events.push({ type: TraceEventType.Exit, blockId: b.id, inputs: [...i], output: result }); + return result; + }; + + const result = await tracingEvaluator(block, inputs, tracingEvaluator, context); + return { result, events }; +} + +// Primitive Recursion Trace Panel +export type PRTraceFrame = { + xy_prime: number[]; // x,y' inputs at the current recursion level + xy: number[] | null; // x,y inputs at the connected level (null at base case) + hxy: number | null; // h(x,y) result from the connected level + hxy_prime: number | null; // h(x,y') result at the current level +}; + +/** + * Build the frame sequence for a PR trace panel from a completed trace. + * Each Enter/Exit event for the given PR block produces exactly one frame. + * Descent frames show inputs flowing down, ascent frames show outputs building up. + */ +export function buildPRFrames(events: TraceEvent[], prBlockId: string): PRTraceFrame[] { + const frames: PRTraceFrame[] = []; + let depth = 0; + let prevExitOutput: number | null = null; + let lastDescentFrame: PRTraceFrame | null = null; + + for (const e of events) { + if (e.type === TraceEventType.ClearSubtree) continue; + if (e.blockId !== prBlockId) continue; + + if (e.type === TraceEventType.Enter) { + if (depth === 0) { + // Reset state on new invocations + prevExitOutput = null; + lastDescentFrame = null; + } + depth++; + + const y = e.inputs[e.inputs.length - 1]; + if (y > 0) { + const frame: PRTraceFrame = { + xy_prime: e.inputs, + xy: [...e.inputs.slice(0, -1), y - 1], + hxy: null, + hxy_prime: null, + }; + lastDescentFrame = frame; + frames.push(frame); + } else if (lastDescentFrame) { + // Base case enter after descent, hold the last descent view + frames.push({ ...lastDescentFrame }); + } else { + // Direct call with y = 0 is an edge case, no descent to hold, so show as own level + frames.push({ xy_prime: e.inputs, xy: null, hxy: null, hxy_prime: null }); + } + } else if (e.type === TraceEventType.Exit) { + depth--; + + const y = e.inputs[e.inputs.length - 1]; + if (y === 0 && lastDescentFrame) { + // Base case exit after descent, stay at last descent view, fill in h(x,y) + frames.push({ + xy_prime: lastDescentFrame.xy_prime, + xy: lastDescentFrame.xy, + hxy: e.output, + hxy_prime: null, + }); + } else if (y === 0) { + // Direct call with y = 0, just show result + frames.push({ xy_prime: e.inputs, xy: null, hxy: null, hxy_prime: e.output }); + } else { + // Ascent: show current level with result from the level below + frames.push({ + xy_prime: e.inputs, + xy: [...e.inputs.slice(0, -1), y - 1], + hxy: prevExitOutput, + hxy_prime: e.output, + }); + } + prevExitOutput = e.output; + } + } + + return frames; +} diff --git a/recursive_blocks/src/ValueEditor.tsx b/recursive_blocks/src/ValueEditor.tsx index be6d16d..bb7bbd4 100644 --- a/recursive_blocks/src/ValueEditor.tsx +++ b/recursive_blocks/src/ValueEditor.tsx @@ -1,6 +1,8 @@ import { useState, useEffect } from "react"; import { BlockData } from "./BlockUtil"; import { blockConfig } from "./BlockConfig"; +import { useBlockEditor } from "./BlockEditorContext"; +import { getInformalNotation } from "./Notation"; interface ValueEditorProps { block: BlockData; @@ -13,6 +15,7 @@ interface ValueEditorProps { // Block is the block this value editor is on, and onUpdate is called when a value is updated. export function ValueEditor({ block, onUpdate, isRunning = false }: ValueEditorProps) { const [values, setValues] = useState(block.num_values ?? []); + const { editMode } = useBlockEditor(); useEffect(() => { if (JSON.stringify(values) !== JSON.stringify(block.num_values)) { @@ -27,33 +30,49 @@ export function ValueEditor({ block, onUpdate, isRunning = false }: ValueEditorP }; if (!values || values.length === 0) { - return; + const notation = getInformalNotation(block); + if (!notation) return; + return ( +
+
+ + {notation.label} + {notation.value} + +
+
+ ); } return ( -
- {values.map((val, index) => ( -
- {isRunning || block.immutable ? ( - - {val.name} = - {val.value} - - ) : ( - <> - - v.name === val.name)?.min || 0} - step="1" - value={val.value} - onChange={(e) => handleValueChange(index, e.target.value)} - className="value-input" - /> - - )} -
- ))} +
+ {values.map((val, index) => { + const staticValue = getInformalNotation(block, val.name, val.value); + if (!staticValue) return null; + + return ( +
+ {isRunning || !editMode ? ( + + {staticValue.label} + {staticValue.value} + + ) : ( + <> + + v.name === val.name)?.min || 0} + step="1" + value={val.value} + onChange={(e) => handleValueChange(index, e.target.value)} + className="value-input" + /> + + )} +
+ ); + })}
); -} \ No newline at end of file +} diff --git a/recursive_blocks/vite.config.ts b/recursive_blocks/vite.config.ts index 8b0f57b..d29a0e8 100644 --- a/recursive_blocks/vite.config.ts +++ b/recursive_blocks/vite.config.ts @@ -3,5 +3,6 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ + base: "/", plugins: [react()], })