Web-Ide mit aufgenommen

This commit is contained in:
Riwoldt
2026-04-09 14:14:56 +02:00
parent 64816c45cc
commit 15cfaf332d
489 changed files with 186891 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import {
COMMANDS_ALU,
COMMANDS_OP,
Flags,
} from "@nand2tetris/simulator/cpu/alu.js";
export const ALUComponent = ({
A,
op,
D,
out,
flag,
}: {
A: number;
op: COMMANDS_OP;
D: number;
out: number;
flag: keyof typeof Flags;
}) => (
<div className="alu">
<span>ALU</span>
<svg width="250" height="250" version="1.1">
<defs>
<rect
x="34.442518"
y="54.335354"
width="0.91770717"
height="20.780869"
/>
</defs>
<g>
<polygon
points="70,10 180,85 180,165 70,240 70,135 90,125 70,115"
stroke="#000"
fill="#6D97AB"
/>
<text
xmlSpace="preserve"
textAnchor="middle"
y="61"
x="35"
// fill="#000000" // use style from chip.scss
>
{A}
</text>
<text
xmlSpace="preserve"
textAnchor="middle"
y="176"
x="35"
// fill="#000000" // use style from chip.scss
>
{D}
</text>
<text
xmlSpace="preserve"
textAnchor="middle"
y="121"
x="207"
// fill="#000000" // use style from chip.scss
>
{out}
</text>
<text
xmlSpace="preserve"
y="130.50002"
x="110.393929"
fill="#ffffff"
fontSize={24}
>
{COMMANDS_ALU.op[op] ?? "(??)"}
</text>
<g>
<path /*stroke="black"*/ d="M 6,67.52217 H 68.675994" />
<path
/*stroke="black"*/ d="M 68.479388,67.746136 60.290279,61.90711"
/>
<path
/*stroke="black"*/ d="m 68.479388,67.40711 -8.189109,5.839026"
/>
</g>
<g transform="translate(0,115.5)">
<path /*stroke="black"*/ d="M 6,67.52217 H 68.675994" />
<path
d="M 68.479388,67.746136 60.290279,61.90711" /*stroke="black"*/
/>
<path
/*stroke="black"*/ d="m 68.479388,67.40711 -8.189109,5.839026"
/>
</g>
<g transform="translate(176,57.5)">
<path /*stroke="black"*/ d="M 6,67.52217 H 68.675994" />
<path
/*stroke="black"*/ d="M 68.479388,67.746136 60.290279,61.90711"
/>
<path
/*stroke="black"*/ d="m 68.479388,67.40711 -8.189109,5.839026"
/>
</g>
</g>
</svg>
</div>
);
@@ -0,0 +1,155 @@
import { KeyboardAdapter } from "@nand2tetris/simulator/cpu/memory.js";
import { useEffect, useRef, useState } from "react";
import { RegisterComponent } from "./register.js";
const KeyMap: Record<string, number | undefined> = {
// Delete: 127,
Enter: 128,
Backspace: 129,
ArrowLeft: 130,
ArrowUp: 131,
ArrowRight: 132,
ArrowDown: 133,
Home: 134,
End: 135,
PageUp: 136,
PageDown: 137,
Insert: 138,
Delete: 139,
Escape: 140,
F1: 141,
F2: 142,
F3: 143,
F4: 144,
F5: 145,
F6: 146,
F7: 147,
F8: 148,
F9: 149,
F10: 150,
F11: 151,
F12: 152,
};
const keyDisplays: Record<string, string> = {
ArrowLeft: "L-arrow",
ArrowUp: "U-arrow",
ArrowRight: "R-arrow",
ArrowDown: "D-arrow",
};
function getKeyDisplay(key: string) {
return keyDisplays[key] ?? key;
}
function keyPressToHackCharacter(keypress: KeyboardEvent): number {
const mapping = KeyMap[keypress.key];
if (mapping !== undefined) {
return mapping;
}
if (keypress.key.length === 1) {
const code = keypress.key.charCodeAt(0);
if (code >= 32 && code <= 126) {
return code;
}
}
return 0;
}
export const Keyboard = ({
keyboard,
update,
}: {
keyboard: KeyboardAdapter;
update?: () => void;
}) => {
const [enabled, setEnabled] = useState(false);
const [character, setCharacter] = useState("");
const [bits, setBits] = useState(keyboard.getKey());
let currentKey = 0;
const toggleRef = useRef<HTMLButtonElement>(null);
const toggleEnabled = () => {
setEnabled(!enabled);
};
const onKeyDown = (event: KeyboardEvent) => {
if (!enabled) {
return;
}
setCharacter(getKeyDisplay(event.key));
toggleRef.current?.blur();
const key = keyPressToHackCharacter(event);
if (key) {
event.preventDefault();
}
if (key === currentKey) {
return;
}
setKey(key);
update?.();
};
const onKeyUp = (event: KeyboardEvent) => {
toggleRef.current?.blur();
if (!enabled) {
return;
}
if (keyboard.getKey()) {
event.preventDefault();
}
currentKey = 0;
keyboard.clearKey();
update?.();
setBits(keyboard.getKey());
setCharacter("");
};
// note on setCharacter vs setKey:
// setCharacter sets the string value that will be displayed in the component,
// while setKey actually sets and tracks the value that will be stored in the keyboard memory
const setKey = (key: number) => {
if (key === 0) {
return;
}
keyboard.setKey(key);
setBits(keyboard.getKey());
currentKey = key;
};
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
});
return (
<article className="panel">
<div className="flex row align-baseline">
<button
onClick={toggleEnabled}
ref={toggleRef}
className="flex-0"
style={{ whiteSpace: "pre" }}
>
{`${enabled ? "Disable" : "Enable"} Keyboard`}
</button>
<div className="flex-1"></div> {/* padding */}
<div className="flex-4">Key: {character}</div>
<div className="flex-4">
<RegisterComponent name="Char code" bits={bits} />
</div>
</div>
</article>
);
};
@@ -0,0 +1,38 @@
import { Memory as MemoryChip } from "@nand2tetris/simulator/cpu/memory.js";
import { range } from "@davidsouther/jiffies/lib/esm/range.js";
import { render, screen } from "@testing-library/react";
import { MemoryBlock, MemoryCell } from "./memory.js";
describe("<Memory />", () => {
describe("<MemoryCell />", () => {
it("renders a read-only cell", () => {
render(<MemoryCell index={16} value={"34"} />);
const addr = screen.getByText("16");
expect(addr).toBeVisible();
const cell = screen.getByText("34");
expect(cell).toBeVisible();
});
});
describe("<MemoryBlock />", () => {
it.skip("renders a small amount of memory", () => {
const memory = new MemoryChip(
new Int16Array(
range(0, 16).map((i) => (Math.pow(i, 12) ^ 0x9753) & 0xffff)
).buffer
);
render(<MemoryBlock memory={memory} />);
const zero = screen.getByText("0x0000");
expect(zero).toBeVisible();
// const indexes = document.querySelectorAll("code:nth-of-type(even)");
// expect(indexes.length).toBe(16);
// const cells = document.querySelectorAll("code:nth-of-type(even)");
// expect(cells.length).toBe(16);
});
});
});
@@ -0,0 +1,396 @@
import { rounded } from "@davidsouther/jiffies/lib/esm/dom/css/border.js";
import {
forwardRef,
ReactNode,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useState,
} from "react";
import {
Format,
FORMATS,
MemoryAdapter,
} from "@nand2tetris/simulator/cpu/memory.js";
import { loadAsm, loadBlob, loadHack } from "@nand2tetris/simulator/loader.js";
import { asm } from "@nand2tetris/simulator/util/asm.js";
import { bin, dec, hex } from "@nand2tetris/simulator/util/twos.js";
import { useClockReset } from "../clockface.js";
import InlineEdit from "../inline_edit.js";
import { LOADING } from "../messages.js";
import { useStateInitializer } from "../react.js";
import { BaseContext } from "../stores/base.context.js";
import VirtualScroll, { VirtualScrollSettings } from "../virtual_scroll.js";
const ITEM_HEIGHT = 34;
export const MemoryBlock = ({
memory,
jmp = { value: 0 },
highlight = -1,
editable = false,
justifyLeft = false, // TODO: handle this in css in the future
count,
maxSize,
offset = 0,
cellLabels,
format = dec,
onChange = () => undefined,
onFocus = () => undefined,
}: {
jmp?: { value: number };
memory: MemoryAdapter;
highlight?: number;
editable?: boolean;
justifyLeft?: boolean;
count?: number;
offset?: number;
maxSize?: number;
cellLabels?: string[];
format?: (v: number) => string;
onChange?: (i: number, value: string, previous: number) => void;
onFocus?: (i: number) => void;
}) => {
const settings = useMemo<Partial<VirtualScrollSettings>>(
() => ({
count: Math.min(memory.size, count ?? 25),
maxIndex: maxSize ?? memory.size,
itemHeight: ITEM_HEIGHT,
startIndex: jmp.value,
}),
[memory.size, jmp],
);
const get = useCallback(
(pos: number, count: number): [number, number][] =>
memory
.range(pos + offset, pos + offset + count)
.map((v, i) => [i + pos + offset, v]),
[memory],
);
const row = useCallback(
([i, v]: [number, number]) => (
<MemoryCell
index={i}
value={format(v)}
label={(cellLabels?.[i] ?? "").padStart(
cellLabels ? Math.max(...cellLabels.map((label) => label.length)) : 0,
)}
showLabel={cellLabels != undefined}
size={memory.size}
editable={editable}
justifyLeft={justifyLeft}
highlight={i === highlight}
onChange={onChange}
onFocus={onFocus}
/>
),
[format, editable, highlight, onChange],
);
return (
<VirtualScroll<[number, number], ReactNode>
settings={settings}
get={get}
row={row}
rowKey={([i]) => i}
/>
);
};
export const MemoryCell = ({
index,
value,
label,
showLabel = false,
size,
highlight = false,
editable = false,
justifyLeft = false,
onChange = () => undefined,
onFocus = () => undefined,
}: {
index: number;
value: string;
label?: string;
showLabel?: boolean;
size?: number;
highlight?: boolean;
editable?: boolean;
justifyLeft?: boolean;
onChange?: (i: number, value: string, previous: number) => void;
onFocus?: (i: number) => void;
}) => (
<div style={{ display: "flex", height: "100%" }}>
{showLabel && (
<code
style={{
...rounded("none"),
...(highlight ? { background: "var(--mark-background-color)" } : {}),
whiteSpace: "pre",
}}
>
{label ?? ""}
</code>
)}
<code
style={{
...rounded("none"),
...(highlight ? { background: "var(--mark-background-color)" } : {}),
whiteSpace: "pre",
}}
>
{size
? dec(index).padStart(Math.ceil(Math.log10(size)), " ")
: dec(index)}
</code>
<code
style={{
flex: "1",
textAlign: justifyLeft ? "left" : "right",
color: "var(--text-color)",
...rounded("none"),
...(highlight ? { background: "var(--mark-background-color)" } : {}),
}}
>
{editable ? (
<InlineEdit
value={value}
highlight={highlight}
onChange={(newValue: string) =>
onChange(index, newValue, Number(value))
}
onFocus={() => onFocus(index)}
/>
) : (
<span style={{ color: "var(--text-color)" }}>{value}</span>
)}
</code>
</div>
);
export const Memory = forwardRef(
(
{
name = "Memory",
className,
displayEnabled = true,
highlight = -1,
editable = true,
memory,
format = "dec",
onSetFormat,
excludedFormats = [],
count,
maxSize,
offset,
initialAddr,
cellLabels,
fileSelect,
showClear = true,
onChange = undefined,
onClear = undefined,
loadTooltip = undefined,
}: {
name?: string;
className?: string;
displayEnabled?: boolean;
editable?: boolean;
highlight?: number;
memory: MemoryAdapter;
count?: number;
maxSize?: number;
offset?: number;
initialAddr?: number;
format: Format;
onSetFormat?: (format: Format) => void;
excludedFormats?: Format[];
cellLabels?: string[];
fileSelect?: () => Promise<{ name: string; content: string }>;
showClear?: boolean;
onChange?: () => void;
onClear?: () => void;
loadTooltip?: { value: string; placement: string };
},
ref,
) => {
const [fmt, setFormat] = useStateInitializer(format);
const [jmp, setJmp] = useState("");
const [goto, setGoto] = useState({ value: initialAddr ?? 0 });
const [highlighted, setHighlighted] = useStateInitializer(highlight);
const [renderKey, setRenderKey] = useState(0);
const jumpTo = () => {
const value =
!isNaN(parseInt(jmp)) && isFinite(parseInt(jmp)) ? Number(jmp) : 0;
setHighlighted(value);
setGoto({
value: value,
});
rerenderMemoryBlock();
};
const doLoad = async () => {
onChange?.();
if (fileSelect) {
const { name, content } = await fileSelect();
setStatus(LOADING);
requestAnimationFrame(async () => {
const loader = name.endsWith("hack")
? loadHack
: name.endsWith("asm")
? loadAsm
: loadBlob;
requestAnimationFrame(async () => {
try {
const bytes = await loader(content);
memory.loadBytes(bytes);
setStatus("");
setFormat(
name.endsWith("hack")
? "bin"
: name.endsWith("asm")
? "asm"
: fmt,
);
jumpTo();
} catch (e) {
setStatus({
message: `Error loading memory: ${(e as Error).message}`,
severity: "ERROR",
});
return;
}
});
});
}
};
const { setStatus } = useContext(BaseContext);
const rerenderMemoryBlock = () => {
setRenderKey(renderKey + 1);
};
useImperativeHandle(ref, () => ({
rerender: rerenderMemoryBlock,
}));
const clear = () => {
memory.reset();
onChange?.();
onClear?.();
rerenderMemoryBlock();
};
const doUpdate = (i: number, v: string) => {
memory.update(i, v, fmt ?? "dec");
onChange?.();
rerenderMemoryBlock();
};
useClockReset(() => {
setJmp("");
setGoto({ value: 0 });
});
const doSetFormat = (format: Format) => {
setFormat(format);
onSetFormat?.(format);
};
return (
<article className={`panel memory ${className ?? name}`}>
<header>
<div style={{ whiteSpace: "nowrap" }}>{name}</div>
<fieldset role="group">
{fileSelect && (
<button
onClick={doLoad}
className="flex-0"
data-tooltip={loadTooltip?.value ?? "Load file"}
data-placement={loadTooltip?.placement ?? "bottom"}
>
{/* <Icon name="upload_file" /> */}
📂
</button>
)}
{showClear && (
<button
onClick={clear}
className="flex-0"
data-tooltip={"Clear"}
data-placement="bottom"
>
{/* <Icon name="upload_file" /> */}
🆑
</button>
)}
<input
style={{ width: "4em", height: "100%" }}
placeholder="Addr"
value={jmp}
onKeyDown={({ key }) => key === "Enter" && jumpTo()}
onChange={({ target: { value } }) => setJmp(value)}
/>
<button
onClick={jumpTo}
className="flex-0"
data-tooltip={"Scroll to address"}
data-placement="bottom"
>
{/* <Icon name="move_down" /> */}
</button>
<select value={fmt} onChange={(e) => doSetFormat(e.target.value)}>
{FORMATS.filter(
(option) => !excludedFormats.includes(option),
).map((option) => (
<option key={option}>{option}</option>
))}
</select>
</fieldset>
</header>
{displayEnabled ? (
<MemoryBlock
key={renderKey}
jmp={goto}
memory={memory}
highlight={highlighted}
editable={editable}
justifyLeft={fmt == "asm"}
count={count}
format={(v: number) => doFormat(fmt, v)}
cellLabels={cellLabels}
maxSize={maxSize}
offset={offset}
onChange={doUpdate}
onFocus={(i) => setHighlighted(i)}
/>
) : (
"Memory display is disabled"
)}
</article>
);
},
);
Memory.displayName = "Memory";
export default Memory;
function doFormat(format: Format, v: number): string {
switch (format) {
case "bin":
return bin(v);
case "hex":
return hex(v);
case "asm":
return asm(v);
case "dec":
default:
return dec(v);
}
}
@@ -0,0 +1,13 @@
import { dec } from "@nand2tetris/simulator/util/twos.js";
export const RegisterComponent = ({
name,
bits,
}: {
name: string;
bits: number;
}) => (
<div>
{name}: {dec(bits)}
</div>
);
@@ -0,0 +1,153 @@
import { assertExists } from "@davidsouther/jiffies/lib/esm/assert.js";
import { Memory } from "@nand2tetris/simulator/cpu/memory.js";
import { useCallback, useRef, useState } from "react";
import { useClockFrame, useClockReset } from "../clockface.js";
const WHITE = "white";
const BLACK = "black";
type COLOR = typeof WHITE | typeof BLACK;
export interface ScreenMemory {
get(idx: number): number;
}
export function reduceScreen(memory: Memory, offset = 0): ScreenMemory {
return {
get(idx: number): number {
return memory.get(offset + idx);
},
};
}
function get(mem: ScreenMemory, x: number, y: number): COLOR {
const byte = mem.get(32 * y + ((x / 16) | 0));
const bit = byte & (1 << x % 16);
return bit === 0 ? WHITE : BLACK;
}
function set(data: Uint8ClampedArray, x: number, y: number, value: COLOR) {
const pixel = (y * 512 + x) * 4;
const color = value === WHITE ? 255 : 0;
data[pixel] = color;
data[pixel + 1] = color;
data[pixel + 2] = color;
data[pixel + 3] = 255;
}
function drawImage(ctx: CanvasRenderingContext2D, memory: ScreenMemory) {
const image = assertExists(
ctx.getImageData(0, 0, 512, 256),
"Failed to create Context2d",
);
for (let col = 0; col < 512; col++) {
for (let row = 0; row < 256; row++) {
const color = get(memory, col, row);
set(image.data, col, row, color);
}
}
ctx.putImageData(image, 0, 0);
}
export type ScreenScales = 0 | 1 | 2;
export const Screen = ({
memory,
showScaleControls = false,
scale = 1,
onScale,
}: {
memory: ScreenMemory;
showScaleControls?: boolean;
scale?: ScreenScales;
onScale?: (scale: ScreenScales) => void;
}) => {
const canvas = useRef<HTMLCanvasElement>();
const [screenScale, setScreenScale] = useState<ScreenScales>(scale);
const onScaleCB = (scale: ScreenScales) => {
onScale?.(scale);
setScreenScale(scale);
};
const draw = useCallback(() => {
const ctx =
canvas.current?.getContext("2d", { willReadFrequently: true }) ??
undefined;
if (ctx) {
drawImage(ctx, memory);
}
}, [memory]);
const ctxRef = useCallback(
(ref: HTMLCanvasElement | null) => {
canvas.current = ref ?? undefined;
draw();
},
[canvas, draw],
);
useClockFrame(draw);
useClockReset(() => {
canvas.current
?.getContext("2d")
?.clearRect(0, 0, canvas.current.width, canvas.current.height);
});
return (
<article className="panel">
<header>
<div>Screen</div>
{showScaleControls && (
<fieldset role="group">
<button
aria-current={screenScale === 0}
onClick={() => onScaleCB(0)}
>
x0
</button>
<button
aria-current={screenScale === 1}
onClick={() => onScaleCB(1)}
>
x1
</button>
<button
aria-current={screenScale === 2}
onClick={() => onScaleCB(2)}
>
x2
</button>
</fieldset>
)}
</header>
{screenScale > 0 && (
<main style={{ backgroundColor: "var(--code-background-color)" }}>
<figure
style={{
width: `${512 * screenScale}px`,
height: `${256 * screenScale}px`,
boxSizing: "content-box",
marginInline: "auto",
margin: "auto",
borderTop: "2px solid gray",
borderLeft: "2px solid gray",
borderBottom: "2px solid lightgray",
borderRight: "2px solid lightgray",
}}
>
<canvas
ref={ctxRef}
width={512}
height={256}
style={{
transform: `translate(-50%, -50%) scale(${screenScale}) translate(50%, 50%)`,
imageRendering: "pixelated",
}}
></canvas>
</figure>
</main>
)}
</article>
);
};
@@ -0,0 +1,29 @@
import { ALU } from "@nand2tetris/simulator/chip/builtins/index.js";
import { Chip } from "@nand2tetris/simulator/chip/chip.js";
import { render, screen } from "@testing-library/react";
import { makeVisualization, makeVisualizationsWithId } from "./visualizations";
describe("visualizations", () => {
it("returns empty for chips with no parts", () => {
const chip = new Chip([], [], "test");
expect(makeVisualization(chip)).toBeUndefined();
expect(makeVisualizationsWithId({ parts: [chip] })).toEqual([]);
});
it("returns vis for builtins", async () => {
const alu = new ALU();
const vis = makeVisualizationsWithId({ parts: [alu] });
expect(vis.length).toBe(1);
render(
<>
{vis.map(([k, v]) => (
<div key={k}>{v}</div>
))}
</>,
);
const rendered = await screen.findAllByText(/ALU/);
expect(rendered).toBeDefined();
});
});
@@ -0,0 +1,156 @@
import {
CPU,
Computer,
Keyboard,
ROM32K,
Screen,
} from "@nand2tetris/simulator/chip/builtins/computer/computer.js";
import { ALU } from "@nand2tetris/simulator/chip/builtins/index.js";
import {
PC,
Register,
} from "@nand2tetris/simulator/chip/builtins/sequential/bit.js";
import {
RAM,
RAM8,
} from "@nand2tetris/simulator/chip/builtins/sequential/ram.js";
import { Chip, HIGH } from "@nand2tetris/simulator/chip/chip.js";
import { Flags } from "@nand2tetris/simulator/cpu/alu.js";
import { decode } from "@nand2tetris/simulator/cpu/cpu.js";
import { ReactElement } from "react";
import { NO_SCREEN } from "../stores/chip.store.js";
import { ALUComponent } from "./alu.js";
import { Keyboard as KeyboardComponent } from "./keyboard.js";
import { Memory as MemoryComponent } from "./memory.js";
import { RegisterComponent } from "./register.js";
import { Screen as ScreenComponent } from "./screen.js";
export function getBuiltinVisualization(part: Chip): ReactElement | undefined {
switch (part.name) {
case "Register":
case "ARegister":
case "DRegister":
case "PC":
case "KEYBOARD":
case "RAM8":
case "RAM64":
case "RAM512":
case "RAM4K":
case "RAM16K":
case "ROM32K":
case "Screen":
case "Memory":
default:
return undefined;
}
}
function makeMemoryVisualization(chip: RAM) {
return (
<MemoryComponent
name={chip.name}
memory={chip.memory}
format={chip instanceof ROM32K ? "asm" : "dec"}
highlight={chip.address}
count={5}
/>
);
}
export function makeVisualization(
chip: Chip,
updateAction?: () => void,
parameters?: Set<string>,
): ReactElement | undefined {
if (chip instanceof ALU) {
return (
<ALUComponent
A={chip.in("x").busVoltage}
op={chip.op()}
D={chip.in("y").busVoltage}
out={chip.out().busVoltage}
flag={
(chip.out("zr").voltage() === HIGH
? Flags.Zero
: chip.out("ng").voltage() === HIGH
? Flags.Negative
: Flags.Positive) as keyof typeof Flags
}
/>
);
}
if (chip instanceof Register) {
return (
<RegisterComponent
name={chip.name ?? `Chip ${chip.id}`}
bits={chip.bits}
/>
);
}
if (chip instanceof PC) {
return <RegisterComponent name="PC" bits={chip.bits} />;
}
if (chip instanceof Keyboard) {
return <KeyboardComponent keyboard={chip} update={updateAction} />;
}
if (chip instanceof Screen) {
return <ScreenComponent memory={chip.memory} />;
}
if (chip instanceof RAM) {
return makeMemoryVisualization(chip);
}
if (chip instanceof RAM8) {
return <span>RAM {chip.width}</span>;
}
if (chip instanceof CPU) {
const bits = decode(chip.in("instruction").busVoltage);
return (
<>
<RegisterComponent name={"A"} bits={chip.state.A} />
<RegisterComponent name={"D"} bits={chip.state.D} />
<RegisterComponent name={"PC"} bits={chip.state.PC} />
<ALUComponent
A={bits.am ? chip.in("inM").busVoltage : chip.state.A}
D={chip.state.D}
out={chip.state.ALU}
op={bits.op}
flag={chip.state.flag as keyof typeof Flags}
/>
</>
);
}
if (chip instanceof Computer) {
return (
<>
<RegisterComponent name={"A"} bits={chip.cpu.state.A} />
<RegisterComponent name={"D"} bits={chip.cpu.state.D} />
<RegisterComponent name={"PC"} bits={chip.cpu.state.PC} />
{!parameters?.has(NO_SCREEN) && (
<ScreenComponent memory={chip.ram.screen.memory} />
)}
{makeMemoryVisualization(chip.rom)}
{makeMemoryVisualization(chip.ram.ram)}
</>
);
}
const vis = [...chip.parts]
.map((chip) => makeVisualization(chip, updateAction))
.filter((v) => v !== undefined);
return vis.length > 0 ? <>{vis}</> : undefined;
}
export function makeVisualizationsWithId(
chip: {
parts: Chip[];
},
updateAction?: () => void,
parameters?: Set<string>,
): [string, ReactElement][] {
return [...chip.parts]
.map((part, i): [string, ReactElement | undefined] => [
`${part.id}_${i}`,
makeVisualization(part, updateAction, parameters),
])
.filter(([_, v]) => v !== undefined) as [string, ReactElement][];
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useMemo, useState } from "react";
import { display } from "@davidsouther/jiffies/lib/esm/display.js";
import { Clock } from "@nand2tetris/simulator/chip/clock.js";
export function useClock(actions: {
tick?: () => void;
toggle?: () => void;
reset?: () => void;
}) {
const clock = useMemo(() => Clock.get(), []);
useEffect(() => {
const subscription = clock.$.subscribe(() => {
actions.tick?.();
});
return () => subscription.unsubscribe();
}, [actions, clock.$]);
return {
toggle() {
clock.tick();
actions.toggle?.();
},
reset() {
clock.reset();
actions.reset?.();
},
};
}
export function useClockFrame(frameFinished: () => void) {
useEffect(() => {
const subscription = Clock.get().frame$.subscribe(() => {
frameFinished();
});
return () => subscription.unsubscribe();
}, [frameFinished]);
}
export function useClockReset(reset: () => void) {
useEffect(() => {
const subscription = Clock.get().reset$.subscribe(() => {
reset();
});
return () => subscription.unsubscribe();
}, [reset]);
}
export function displayClock() {
return display(Clock.get());
}
export function useClockface() {
const [clockface, setClockface] = useState(displayClock());
useEffect(() => {
const subscription = Clock.get().$.subscribe(() => {
setClockface(displayClock());
});
return () => subscription.unsubscribe();
}, []);
return clockface;
}
export const Clockface = () => {
const clockface = useClockface();
return <span style={{ whiteSpace: "nowrap" }}>{clockface}</span>;
};
+210
View File
@@ -0,0 +1,210 @@
import { isErr, Ok } from "@davidsouther/jiffies/lib/esm/result.js";
import { Span } from "@nand2tetris/simulator/languages/base";
import { CMP, Cmp } from "@nand2tetris/simulator/languages/cmp.js";
interface Diff {
row: number;
col: number;
expected: string;
given: string;
}
interface DiffLineDisplay {
expectedLine: string;
givenLine: string;
correctCellSpans: Span[];
incorrectCellSpans: Span[];
}
export type DecorationType =
| "correct-line"
| "error-line"
| "correct-cell"
| "error-cell";
interface Decoration {
span: Span;
type: DecorationType;
}
export interface DiffDisplay {
text: string;
failureNum: number;
decorations: Decoration[];
lineNumbers: string[];
}
function getDiffs(cmpData: Cmp, outData: Cmp): Diff[] {
const diffs: Diff[] = [];
for (let i = 0; i < Math.min(cmpData.length, outData.length); i++) {
const cmpI = cmpData[i] ?? [];
const outI = outData[i] ?? [];
for (let j = 0; j < Math.max(cmpI.length, outI.length); j++) {
const cmpJ = cmpI[j] ?? "";
const outJ = outI[j] ?? "";
if (!(cmpJ?.trim().match(/^\*+$/) !== null || outJ === cmpJ)) {
diffs.push({ row: i, col: j, expected: cmpJ, given: outJ });
}
}
}
return diffs;
}
export function compare(cmp: string, out: string) {
const cmpResult = CMP.parse(cmp);
const outResult = CMP.parse(out);
if (isErr(cmpResult) || isErr(outResult)) {
return false;
}
const cmpData = Ok(cmpResult);
const outData = Ok(outResult);
return getDiffs(cmpData, outData).length == 0;
}
export function generateDiffs(cmp: string, out: string): DiffDisplay {
const cmpResult = CMP.parse(cmp);
const outResult = CMP.parse(out);
if (isErr(cmpResult) || isErr(outResult)) {
return {
text: "",
failureNum: 0,
decorations: [],
lineNumbers: [],
};
}
const cmpData = Ok(cmpResult);
const outData = Ok(outResult);
const diffs = getDiffs(cmpData, outData);
const diffsByLine: Diff[][] = new Array<Diff[]>(cmpData.length);
for (const diff of diffs) {
const lineDiffs = diffsByLine[diff.row];
if (lineDiffs) {
lineDiffs.push(diff);
} else {
diffsByLine[diff.row] = [diff];
}
}
const lines = out.split("\n");
const diffLines: DiffLineDisplay[] = new Array(cmpData.length);
for (let i = 0; i < diffsByLine.length; i++) {
if (diffsByLine[i]) {
diffLines[i] = generateDiffLine(lines[i], diffsByLine[i]);
}
}
const finalLines: string[] = [];
let lineStart = 0;
const decorations: Decoration[] = [];
const lineNumbers: string[] = [];
for (let i = 0; i < lines.length; i++) {
const diffLine = diffLines[i];
lineNumbers.push((i + 1).toString());
if (diffLine) {
lineNumbers.push("");
finalLines.push(diffLine.givenLine);
decorations.push({
span: {
start: lineStart,
end: lineStart + diffLine.givenLine.length,
line: finalLines.length,
},
type: "error-line",
});
decorations.push(
...diffLine.incorrectCellSpans.map((span) => ({
span: {
start: span.start + lineStart,
end: span.end + lineStart,
line: span.line,
},
type: "error-cell" as DecorationType,
})),
);
lineStart += diffLine.expectedLine.length + 1; // +1 for the newline character
finalLines.push(diffLine.expectedLine);
decorations.push({
span: {
start: lineStart,
end: lineStart + diffLine.expectedLine.length,
line: i,
},
type: "correct-line",
});
decorations.push(
...diffLine.correctCellSpans.map((span) => ({
span: {
start: span.start + lineStart,
end: span.end + lineStart,
line: finalLines.length,
},
type: "correct-cell" as DecorationType,
})),
);
lineStart += diffLine.givenLine.length + 1;
} else {
finalLines.push(lines[i]);
lineStart += lines[i].length + 1;
}
}
let text = finalLines.join("\n");
if (text.endsWith("\n")) {
text = text.substring(0, text.length - 1);
}
return {
text: text,
failureNum: diffs.length,
decorations,
lineNumbers,
};
}
function generateDiffLine(original: string, diffs: Diff[]): DiffLineDisplay {
const cells = original.split("|").filter((cell) => cell != "");
const newCells = Array.from(cells);
const cellStarts: number[] = [];
let sum = 0;
for (let i = 0; i < cells.length; i++) {
cellStarts.push(sum + 1);
sum += cells[i].length + 1;
}
const correctCellSpans: Span[] = [];
const incorrectCellSpans: Span[] = [];
for (const diff of diffs) {
cells[diff.col] = diff.expected;
newCells[diff.col] = diff.given;
const span = {
start: cellStarts[diff.col],
end: cellStarts[diff.col] + diff.expected.length,
line: 0, // not used
};
correctCellSpans.push(span);
incorrectCellSpans.push(span);
}
return {
expectedLine: `|${cells.join("|")}|`,
givenLine: `|${newCells.join("|")}|`,
correctCellSpans,
incorrectCellSpans,
};
}
+14
View File
@@ -0,0 +1,14 @@
import { useState } from "react";
export function useDialog() {
const [open, setOpen] = useState(false);
return {
isOpen: open,
open() {
setOpen(true);
},
close() {
setOpen(false);
},
};
}
+120
View File
@@ -0,0 +1,120 @@
import { CMP } from "@nand2tetris/simulator/languages/cmp.js";
import { display } from "@davidsouther/jiffies/lib/esm/display.js";
import { range } from "@davidsouther/jiffies/lib/esm/range.js";
import { Err, isErr, Ok } from "@davidsouther/jiffies/lib/esm/result.js";
import { ReactElement } from "react";
export const DiffTable = ({
className = "",
out,
cmp,
zeroState,
}: {
out: string;
cmp: string;
className?: string;
zeroState?: ReactElement;
}) => {
const output = CMP.parse(out);
const compare = CMP.parse(cmp);
if (isErr(output)) {
return (
<details>
<summary>Failed to parse output</summary>
<pre>{display(Err(output))}</pre>
<code>
<pre>{out}</pre>
</code>
</details>
);
}
if (isErr(compare)) {
return (
<details>
<summary>Failed to parse compare</summary>
<code>
<pre>{display(Err(compare))}</pre>
<pre>{cmp}</pre>
</code>
</details>
);
}
const cmpData = Ok(compare);
const outData = Ok(output);
let failures = 0;
const table = range(0, Math.min(cmpData.length, outData.length)).map((i) => {
const cmpI = cmpData[i] ?? [];
const outI = outData[i] ?? [];
return range(0, Math.max(cmpI.length, outI.length))
.map((_, j) => [cmpI[j] ?? "", outI[j] ?? ""])
.map(([cmp, out]) => {
const cell = {
cmp: cmp ?? '"',
out: out ?? '"',
pass:
cmp?.trim().match(/^\*+$/) !== null || out?.trim() === cmp?.trim(),
};
if (!cell.pass) {
failures += 1;
}
return cell;
});
});
return (
<div className={"scroll-x " + className}>
{failures > 0 && (
<p>
{failures} failure{failures === 1 ? "" : "s"}
</p>
)}
{table.length > 0 ? (
<table
style={{
fontFamily: "var(--font-family-monospace)",
marginBottom: "none",
}}
>
<tbody>
{table.map((row, i) => (
<tr key={i}>
{row.map(({ cmp, out, pass }, i) => (
<DiffCell cmp={cmp} out={out} pass={pass} key={i} />
))}
</tr>
))}
</tbody>
</table>
) : (
(zeroState ?? <p>Execute test script to compare output.</p>)
)}
</div>
);
};
const DiffCell = ({
cmp,
out,
pass,
}: {
cmp: string;
out: string;
pass: boolean;
}) => {
return pass ? (
<>
<td>{cmp}</td>
</>
) : (
<>
<td>
<ins>{cmp}</ins>
<br />
<del>{out}</del>
</td>
</>
);
};
+81
View File
@@ -0,0 +1,81 @@
import { FileSystem, Stats } from "@davidsouther/jiffies/lib/esm/fs";
import { Err, Ok, Result } from "@davidsouther/jiffies/lib/esm/result.js";
interface TestFiles {
tst: string;
cmp?: string;
}
export async function loadTestFiles(
fs: FileSystem,
tstPath: string,
): Promise<Result<TestFiles>> {
try {
const tst = await fs.readFile(tstPath);
let cmp: string | undefined = undefined;
try {
const cpmPath = tstPath
.replace("VME.tst", ".tst")
.replace(".tst", ".cmp");
cmp = await fs.readFile(cpmPath);
} catch (_e) {
// There doesn't have to be a compare file
}
return Ok({ tst: tst, cmp: cmp });
} catch (e) {
return Err(e as Error);
}
}
export function sortFiles(files: Stats[]) {
return files.sort((a, b) => {
const aIsNum = /^\d+/.test(a.name);
const bIsNum = /^\d+/.test(b.name);
if (aIsNum && !bIsNum) {
return -1;
} else if (!aIsNum && bIsNum) {
return 1;
} else if (aIsNum && bIsNum) {
return parseInt(a.name, 10) - parseInt(b.name, 10);
} else {
return a.name.localeCompare(b.name);
}
});
}
export async function cloneTree(
sourceFs: FileSystem,
targetFs: FileSystem,
dir = "/",
pathTransform: (path: string) => string,
overwrite = false,
) {
const sourceDir = dir == "/" ? "" : dir;
const targetDir = pathTransform(sourceDir);
const sourceItems = await sourceFs.scandir(dir);
targetFs.mkdir(targetDir);
const targetItems = new Set(
(await targetFs.scandir(targetDir)).map((stat) => stat.name),
);
for (const item of sourceItems) {
if (item.isFile()) {
if (overwrite || !targetItems.has(item.name)) {
await targetFs.writeFile(
`${targetDir}/${item.name}`,
await sourceFs.readFile(`${sourceDir}/${item.name}`),
);
}
} else {
await cloneTree(
sourceFs,
targetFs,
`${sourceDir}/${item.name}`,
pathTransform,
overwrite,
);
}
}
}
+5
View File
@@ -0,0 +1,5 @@
// export { Trans } from "@lingui/macro";
import { PropsWithChildren } from "react";
export const Trans = (props: PropsWithChildren) => props.children ?? <></>;
@@ -0,0 +1,85 @@
import { width } from "@davidsouther/jiffies/lib/esm/dom/css/sizing.js";
import { useCallback, useState } from "react";
import { useStateInitializer } from "./react.js";
import { Action } from "@nand2tetris/simulator/types.js";
const Mode = { VIEW: 0, EDIT: 1 };
export const InlineEdit = (props: {
mode?: keyof typeof Mode;
value: string;
highlight: boolean;
onChange: Action<string>;
onFocus?: () => void;
}) => {
const [mode, setMode] = useState(props.mode ?? Mode.VIEW);
const [value, setValue] = useStateInitializer(props.value);
const render = () => {
switch (mode) {
case Mode.EDIT:
return edit();
case Mode.VIEW:
return view();
default:
return <span />;
}
};
const view = () => (
<div
style={{
cursor: "text",
...width("full", "inline"),
}}
onClick={() => {
setMode(Mode.EDIT);
}}
>
{value}&nbsp;
</div>
);
const doSelect = useCallback(
(ref: HTMLInputElement | null) => ref?.select(),
[],
);
const doChange = useCallback(
(target: HTMLInputElement) => {
setMode(Mode.VIEW);
setValue(target.value ?? "");
props.onChange(target.value ?? "");
},
[props, setMode, setValue],
);
const edit = () => {
const edit = (
<span style={{ display: "block", position: "relative" }}>
<input
ref={doSelect}
style={{
zIndex: "10",
position: "absolute",
left: "0",
marginTop: "-0.375rem",
color: "var(--text-color)",
}}
onFocus={props.onFocus}
onBlur={({ target }) => doChange(target)}
onKeyPress={({ key, target }) => {
if (key === "Enter") {
doChange(target as HTMLInputElement);
}
}}
type="text"
defaultValue={value}
/>
</span>
);
return edit;
};
return render();
};
export default InlineEdit;
+1
View File
@@ -0,0 +1 @@
export const LOADING = "Loading in progress...";
@@ -0,0 +1,43 @@
import { assertExists } from "@davidsouther/jiffies/lib/esm/assert.js";
import { REGISTRY as BUILTIN_REGISTRY } from "@nand2tetris/simulator/chip/builtins/index.js";
export class ChipDisplayInfo {
signBehaviors: Map<string, boolean> = new Map();
public constructor(chipName: string, unsigned?: string[]) {
if (BUILTIN_REGISTRY.has(chipName)) {
const chip = assertExists(BUILTIN_REGISTRY.get(chipName)?.());
const pins = [...chip.ins.entries(), ...chip.outs.entries()];
for (const pin of pins) {
this.signBehaviors.set(
pin.name,
!unsigned || !unsigned.includes(pin.name),
);
}
}
}
public isSigned(pin: string) {
return this.signBehaviors.get(pin);
}
}
const UNSIGNED_PINS = new Map<string, string[]>([
["Mux4Way16", ["sel"]],
["Mux8Way16", ["sel"]],
["DMux4Way", ["sel"]],
["DMux8Way", ["sel"]],
["RAM8", ["address"]],
["RAM64", ["address"]],
["RAM512", ["address"]],
["RAM4K", ["address"]],
["RAM16K", ["address"]],
["Screen", ["address"]],
["Memory", ["address"]],
["CPU", ["addressM", "pc"]],
]);
export const getDisplayInfo = (chipName: string) =>
new ChipDisplayInfo(chipName, UNSIGNED_PINS.get(chipName));
@@ -0,0 +1,60 @@
import { Bus, HIGH } from "@nand2tetris/simulator/chip/chip.js";
import { render, screen } from "@testing-library/react";
import { act, useState } from "react";
import { Pinout, reducePin } from "./pinout.js";
describe("<Pinout />", () => {
it("renders pins", () => {
const pin = new Bus("pin");
render(<Pinout pins={[reducePin(pin)]} />);
const pinOut = screen.getByText("0");
expect(pinOut).toBeVisible();
});
it("toggles bits", () => {
const pin = new Bus("pin");
const Wrapper = () => {
const [pins, setPins] = useState([reducePin(pin)]);
const toggle = () => {
pin.toggle();
setPins([reducePin(pin)]);
};
return <Pinout pins={pins} toggle={toggle} />;
};
render(<Wrapper />);
const pinOut = screen.getByText("0");
act(() => {
pinOut.click();
});
expect(pin.busVoltage).toBe(HIGH);
expect(screen.getByText("1")).toBeVisible();
});
it.skip("increments buses", () => {
const pin = new Bus("pin", 3);
const Wrapper = () => {
const [pins, setPins] = useState([reducePin(pin)]);
const toggle = () => {
pin.busVoltage += 1;
setPins([reducePin(pin)]);
};
return <Pinout pins={pins} toggle={toggle} />;
};
render(<Wrapper />);
const pinOut = screen.getByText("000");
act(() => {
pinOut.click();
});
expect(pin.busVoltage).toBe(1);
expect(screen.getByText("001")).toBeVisible();
});
});
+307
View File
@@ -0,0 +1,307 @@
import { range } from "@davidsouther/jiffies/lib/esm/range.js";
import {
Pin as ChipPin,
Pins,
Voltage,
} from "@nand2tetris/simulator/chip/chip.js";
import { createContext, useContext, useEffect, useState } from "react";
import { ChipDisplayInfo, getDisplayInfo } from "./pin_display.js";
import "./public/pin.css";
import { ChipSim } from "./stores/chip.store.js";
export const PinContext = createContext({});
export interface ImmPin {
bits: [number, Voltage][];
pin: ChipPin;
}
export function reducePin(pin: ChipPin) {
return {
pin,
bits: range(0, pin.width)
.map((i) => [i, pin.voltage(i)] as [number, Voltage])
.reverse(),
};
}
export function reducePins(pins: Pins): ImmPin[] {
return [...pins.entries()].map(reducePin);
}
export interface PinoutPins {
pins: ImmPin[];
toggle?: (pin: ChipPin, bit?: number) => void;
}
export const FullPinout = (props: {
sim: ChipSim;
toggle: (pin: ChipPin, i: number | undefined) => void;
setInputValid: (pending: boolean) => void;
hideInternal?: boolean;
}) => {
const { inPins, outPins, internalPins } = props.sim;
const displayInfo = getDisplayInfo(props.sim.chip[0].name ?? "");
return (
<>
<style>{`
table.pinout th {
font-weight: bold;
}
table.pinout tbody td:first-child {
text-align: right;
--font-size: 1rem;
width: 0;
white-space: nowrap;
border-right: var(--border-width) solid var(--table-border-color);
}
table.pinout tbody button {
--font-size: 0.875em;
font-family: var(--font-family-monospace);
max-width: 2em;
}
`}</style>
<table className="pinout">
<tbody>
<PinoutBlock
pins={inPins}
header="Input pins"
toggle={props.toggle}
setInputValid={props.setInputValid}
displayInfo={displayInfo}
/>
<PinoutBlock
pins={outPins}
header="Output pins"
disabled={props.sim.pending}
enableEdit={false}
displayInfo={displayInfo}
/>
{!props.hideInternal && (
<PinoutBlock
pins={internalPins}
header="Internal pins"
disabled={props.sim.pending}
enableEdit={false}
displayInfo={displayInfo}
/>
)}
</tbody>
</table>
</>
);
};
export const PinoutBlock = (
props: PinoutPins & {
header: string;
disabled?: boolean;
enableEdit?: boolean;
setInputValid?: (valid: boolean) => void;
displayInfo: ChipDisplayInfo;
},
) => (
<>
{props.pins.length > 0 && (
<tr>
<th colSpan={2}>{props.header}</th>
</tr>
)}
{[...props.pins].map((immPin) => (
<tr key={immPin.pin.name}>
<td>{immPin.pin.name}</td>
<td>
<Pin
pin={immPin}
toggle={props.toggle}
disabled={props.disabled}
enableEdit={props.enableEdit}
signed={props.displayInfo.isSigned(immPin.pin.name)}
setInputValid={props.setInputValid}
internal={props.header === "Internal pins" ? true : false}
/>
</td>
</tr>
))}
</>
);
export const Pinout = ({
pins,
toggle,
}: {
pins: ImmPin[];
toggle?: (pin: ChipPin, bit?: number) => void;
}) => {
if (pins.length === 0) {
return <>None</>;
}
return (
<table className="pinout">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{[...pins].map((immPin) => (
<tr key={immPin.pin.name}>
<td>{immPin.pin.name}</td>
<td>
<Pin pin={immPin} toggle={toggle} internal />
</td>
</tr>
))}
</tbody>
</table>
);
};
const Pin = ({
pin,
toggle,
disabled = false,
enableEdit = true,
signed = true,
setInputValid,
internal = false,
}: {
pin: ImmPin;
toggle: ((pin: ChipPin, bit?: number) => void) | undefined;
disabled?: boolean;
enableEdit?: boolean;
signed?: boolean;
setInputValid?: (valid: boolean) => void;
internal: boolean;
}) => {
const [isBin, setIsBin] = useState(true);
let inputValid = true;
const [decimal, setDecimal] = useState("");
const toggleBin = () => {
setIsBin(!isBin);
};
const resetDispatcher = useContext(PinContext);
if (resetDispatcher instanceof PinResetDispatcher) {
resetDispatcher.registerCallback(() => {
setIsBin(true);
});
}
const setInputValidity = (valid: boolean) => {
inputValid = valid;
setInputValid?.(valid);
};
const handleDecimalChange = (value: string) => {
const positive = value.replace(/[^\d]/g, "");
const numeric = signed && value[0] === "-" ? `-${positive}` : positive;
setDecimal(numeric);
if (isNaN(parseInt(numeric))) {
setInputValidity(false);
} else {
const newValue = parseInt(numeric);
if (
(!signed && newValue >= Math.pow(2, pin.bits.length)) ||
(signed &&
(newValue >= Math.pow(2, pin.bits.length - 1) ||
newValue < -Math.pow(2, pin.bits.length - 1)))
) {
setInputValidity(false);
} else {
updatePins(newValue);
setInputValidity(true);
}
}
};
const updatePins = (n: number) => {
for (let i = 0; i < pin.bits.length; i++) {
if (pin.bits[pin.bits.length - i - 1][1] !== ((n >> i) & 1)) {
toggle?.(pin.pin, i);
}
}
};
useEffect(() => {
if (!isBin && inputValid) {
let value = 0;
if (signed && pin.bits[0][1]) {
// negative
for (const [i, v] of pin.bits) {
if (i < pin.bits.length - 1 && !v) {
value += 2 ** i;
}
}
value = -value - 1;
} else {
// positive
const limit = signed ? pin.bits.length - 1 : pin.bits.length;
for (const [i, v] of pin.bits) {
if (i < limit && v) {
value += 2 ** i;
}
}
}
setDecimal(value.toString());
}
}, [pin, isBin]);
return (
<div
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
>
<fieldset role="group" style={{ width: `${pin.bits.length}rem` }}>
{isBin ? (
pin.bits.map(([i, v]) => (
<button
key={i}
disabled={disabled}
style={internal ? { backgroundColor: "grey" } : {}}
onClick={() => toggle?.(pin.pin, i)}
data-testid={`pin-${i}`}
>
{v}
</button>
))
) : (
<input
className="colored"
value={decimal}
onChange={(e) => {
handleDecimalChange(e.target.value);
}}
disabled={!enableEdit}
/>
)}
</fieldset>
{pin.bits.length > 1 && (
<>
<div style={{ width: "1em" }} />
<button className="pin-control" onClick={() => toggleBin()}>
{isBin ? "dec" : "bin"}
</button>
</>
)}
</div>
);
};
export class PinResetDispatcher {
private callbacks: (() => void)[] = [];
registerCallback(callback: () => void) {
this.callbacks.push(callback);
}
reset() {
for (const callback of this.callbacks) {
callback();
}
}
}
@@ -0,0 +1,3 @@
.alu {
font-size: 20;
}
@@ -0,0 +1,5 @@
.pin-control {
max-width: 3em !important;
background: var(--light-grey);
border-color: var(--light-grey);
}
+31
View File
@@ -0,0 +1,31 @@
import { produce } from "immer";
import { Dispatch, useEffect, useReducer, useState } from "react";
export function useImmerReducer<
T,
// biome-ignore lint/suspicious/noExplicitAny: reducer really doesn't care
Reducers extends Record<string, (state: T, action?: any) => T | void>,
>(reducers: Reducers, initialState: T) {
return useReducer(
(
state: T,
command: {
action: keyof Reducers;
// biome-ignore lint/suspicious/noExplicitAny: reducer doesn't care and covariants are hard
payload?: any;
},
): T =>
produce(state, (draft: T) => {
reducers[command.action](draft, command.payload);
}),
initialState,
);
}
export function useStateInitializer<T>(init: T): [T, Dispatch<T>] {
const [state, setState] = useState<T>(init);
useEffect(() => {
setState(init);
}, [init]);
return [state, setState];
}
+141
View File
@@ -0,0 +1,141 @@
import { Timer } from "@nand2tetris/simulator/timer.js";
import { ChangeEvent, ReactNode, useEffect, useRef } from "react";
import { useStateInitializer } from "./react.js";
import { useTimer } from "./timer.js";
interface RunbarTooltipOverrides {
step: string;
run: string;
pause: string;
reset: string;
}
export type RunSpeed = 0 | 1 | 2 | 3 | 4;
export const Runbar = (props: {
runner: Timer;
speed?: RunSpeed;
disabled?: boolean;
prefix?: ReactNode;
children?: ReactNode;
overrideTooltips?: Partial<RunbarTooltipOverrides>;
onSpeedChange?: (speed: RunSpeed) => void;
}) => {
const runner = useTimer(props.runner);
const [speedValue, setSpeed] = useStateInitializer(props.speed ?? 2);
const speedValues: Record<RunSpeed, [number, number]> = {
0: [1000, 1],
1: [500, 1],
2: [16, 1],
3: [16, 16666],
4: [16, 16666 * 30],
};
useEffect(() => {
updateSpeed();
}, [speedValue]);
const updateSpeed = () => {
const [speed, steps] = speedValues[speedValue];
runner.dispatch({ action: "setSpeed", payload: speed });
runner.dispatch({ action: "setSteps", payload: steps });
};
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const speed = Number(e.target.value) as RunSpeed;
setSpeed(speed);
props.onSpeedChange?.(speed);
};
const toggleRef = useRef<HTMLButtonElement>(null);
const onKeyPress = (event: KeyboardEvent) => {
toggleRef.current?.blur();
};
useEffect(() => {
window.addEventListener("keydown", onKeyPress);
window.addEventListener("keyup", onKeyPress);
return () => {
window.removeEventListener("keydown", onKeyPress);
window.removeEventListener("keyup", onKeyPress);
};
});
return (
<div className="flex row wrap">
<fieldset role="group">
{props.prefix}
<button
className="flex-0"
disabled={props.disabled}
onClick={() => runner.actions.frame()}
data-tooltip={props.overrideTooltips?.step ?? `Step`}
data-placement="bottom"
>
{/* <Icon name="play_arrow" /> */}
</button>
<button
className="flex-0"
ref={toggleRef}
disabled={props.disabled}
onClick={() =>
runner.state.running
? runner.actions.stop()
: runner.actions.start()
}
data-tooltip={
runner.state.running
? (props.overrideTooltips?.pause ?? `Pause`)
: (props.overrideTooltips?.run ?? `Run`)
}
data-placement="bottom"
>
{/* <Icon name={runner.state.running ? "pause" : "fast_forward"} /> */}
{runner.state.running ? "⏸" : "️⏩"}
</button>
<button
className="flex-0"
onClick={() => {
if (runner.state.running) {
runner.actions.stop();
}
runner.actions.reset();
}}
data-tooltip={props.overrideTooltips?.reset ?? `Reset`}
data-placement="bottom"
>
{/* <Icon name="fast_rewind" /> */}
</button>
</fieldset>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
fontWeight: "normal",
}}
>
<span style={{ padding: "0.2rem" }}>Slow</span>
<input
type="range"
min={0}
max={4}
step={1}
value={speedValue}
disabled={runner.state.running}
onChange={onChange}
style={{ width: "150px", padding: "0.2rem" }}
data-tooltip={"Execution speed"}
data-placement={"bottom"}
/>
<span style={{ padding: "0.2rem" }}>Fast</span>
</div>
{props.children}
</div>
);
};
@@ -0,0 +1,6 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
import "@nand2tetris/simulator/setupTests.js";
@@ -0,0 +1,490 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import {
Err,
isErr,
Ok,
Result,
} from "@davidsouther/jiffies/lib/esm/result.js";
import {
CompareResultLengths,
CompareResultLine,
compareLines,
} from "@nand2tetris/simulator/compare.js";
import {
KEYBOARD_OFFSET,
SCREEN_OFFSET,
} from "@nand2tetris/simulator/cpu/memory.js";
import {
ASM,
Asm,
fillLabel,
isAValueInstruction,
translateInstruction,
} from "@nand2tetris/simulator/languages/asm.js";
import {
CompilationError,
Span,
} from "@nand2tetris/simulator/languages/base.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { bin } from "@nand2tetris/simulator/util/twos.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { RunSpeed } from "src/runbar.js";
import { useImmerReducer } from "../react.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
export interface TranslatorSymbol {
name: string;
value: string;
}
function defaultSymbols(): TranslatorSymbol[] {
return [
{ name: "R0", value: "0" },
{ name: "R1", value: "1" },
{ name: "R2", value: "2" },
{ name: "...", value: "" }, // abbreviation of R3 - R14
{ name: "R15", value: "15" },
{ name: "SCREEN", value: SCREEN_OFFSET.toString() },
{ name: "KBD", value: KEYBOARD_OFFSET.toString() },
];
}
interface HighlightInfo {
resultHighlight: Span | undefined;
sourceHighlight: Span | undefined;
highlightMap: Map<Span, Span>;
}
interface AsmVariable {
name: string;
isHidden: boolean;
}
class Translator {
asm: Asm = { instructions: [] };
current = -1;
done = false;
symbols: TranslatorSymbol[] = [];
private variables: Map<number, AsmVariable> = new Map();
private lines: string[] = [];
lineNumbers: number[] = [];
getResult() {
return this.lines.join("\n");
}
load(asm: Asm, lineNum: number): Result<void, CompilationError> {
this.symbols = defaultSymbols();
this.variables.clear();
this.asm = asm;
const result = fillLabel(asm, (name, value, isVar) => {
if (isVar) {
this.variables.set(value, { name: name, isHidden: true });
} else {
this.symbols.push({ name: name, value: value.toString() });
}
});
if (isErr(result)) {
return result;
}
asm.instructions = asm.instructions.filter(({ type }) => type !== "L");
this.resolveLineNumbers(lineNum);
this.reset();
return Ok();
}
resolveLineNumbers(lineNum: number) {
this.lineNumbers = Array(lineNum);
let currentLine = 0;
for (const instruction of this.asm.instructions) {
if (
(instruction.type === "A" || instruction.type === "C") &&
instruction.span != undefined
) {
this.lineNumbers[instruction.span.line] = currentLine;
currentLine += 1;
}
}
}
step(highlightInfo: HighlightInfo) {
if (this.current >= this.asm.instructions.length - 1) {
return;
}
this.current += 1;
const instruction = this.asm.instructions[this.current];
if (instruction.type === "A" || instruction.type === "C") {
highlightInfo.sourceHighlight = instruction.span;
const result = translateInstruction(this.asm.instructions[this.current]);
if (result === undefined) {
return;
}
this.lines.push(`${bin(result)}`);
highlightInfo.resultHighlight = {
start: this.current * 17,
end: (this.current + 1) * 17,
line: -1,
};
if (highlightInfo.sourceHighlight) {
highlightInfo.highlightMap.set(
highlightInfo.sourceHighlight,
highlightInfo.resultHighlight,
);
}
if (isAValueInstruction(instruction)) {
const variable = this.variables.get(instruction.value);
if (variable != undefined && variable.isHidden) {
this.symbols.push({
name: variable.name,
value: instruction.value.toString(),
});
variable.isHidden = false;
}
}
if (this.current === this.asm.instructions.length - 1) {
this.done = true;
}
}
}
resetSymbols() {
for (const variable of this.variables.values()) {
variable.isHidden = true;
}
const variableNames = new Set(
Array.from(this.variables.values()).map((v) => v.name),
);
this.symbols = this.symbols.filter(
(symbol) => !variableNames.has(symbol.name),
);
}
reset() {
this.current = -1;
this.lines = [];
this.done = false;
this.resetSymbols();
}
}
export interface AsmPageState {
asm: string;
path: string | undefined;
translating: boolean;
current: number;
resultHighlight: Span | undefined;
sourceHighlight: Span | undefined;
symbols: TranslatorSymbol[];
result: string;
compare: string;
compareName: string | undefined;
lineNumbers: number[];
error?: CompilationError;
compareError: boolean;
title?: string;
config: AsmPageConfig;
}
export interface AsmPageConfig {
speed: RunSpeed;
}
export type AsmStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeAsmStore>["reducers"];
payload?: unknown;
}>;
export function makeAsmStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
dispatch: MutableRefObject<AsmStoreDispatch>,
upgraded: boolean,
) {
const translator = new Translator();
const highlightInfo: HighlightInfo = {
resultHighlight: undefined,
sourceHighlight: undefined,
highlightMap: new Map(),
};
let path: string | undefined;
let animate = true;
let compiled = false;
let translating = false;
let failure = false;
const reducers = {
setAsm(
state: AsmPageState,
{ asm, path }: { asm: string; path: string | undefined },
) {
state.asm = asm;
if (path) {
state.path = path;
}
},
setCmp(state: AsmPageState, { cmp, name }: { cmp: string; name: string }) {
state.compare = cmp;
state.compareName = name;
setStatus("Loaded compare file");
},
setError(state: AsmPageState, error?: CompilationError) {
if (error) {
setStatus({
message: error.message,
severity: "ERROR",
});
}
state.error = error;
},
update(state: AsmPageState) {
state.translating = translating;
state.current = translator.current;
state.result = translator.getResult();
state.symbols = Array.from(translator.symbols);
state.lineNumbers = Array.from(translator.lineNumbers);
state.sourceHighlight = highlightInfo.sourceHighlight;
state.resultHighlight = highlightInfo.resultHighlight;
state.compareError = failure;
},
compare(state: AsmPageState) {
const comparison = compareLines(state.result, state.compare);
if ((comparison as CompareResultLengths).lenA) {
failure = true;
setStatus({
message: "Comparison failed - different lengths",
severity: "ERROR",
});
return;
}
const { line } = comparison as CompareResultLine;
if (line) {
setStatus({
message: `Comparison failure: Line ${line}`,
severity: "ERROR",
});
failure = true;
highlightInfo.resultHighlight = {
start: line * 17,
end: (line + 1) * 17,
line: -1,
};
return;
}
setStatus({
message: "Comparison successful",
severity: "SUCCESS",
});
},
setTitle(state: AsmPageState, title: string) {
state.title = title;
},
updateConfig(state: AsmPageState, config: Partial<AsmPageConfig>) {
state.config = { ...state.config, ...config };
},
};
const actions = {
async loadAsm(_path: string) {
path = _path;
const source = await fs.readFile(path);
actions.setAsm(source, path);
},
setAsm(asm: string, path?: string) {
asm = asm.replace(/\r\n/g, "\n");
dispatch.current({
action: "setAsm",
payload: { asm, path },
});
translating = false;
this.saveAsm(asm);
requestAnimationFrame(() => {
this.compileAsm(asm);
});
},
saveAsm(asm: string) {
if (path) {
fs.writeFile(path, asm);
}
},
compileAsm(asm: string) {
this.reset();
const parseResult = ASM.parse(asm);
if (isErr(parseResult)) {
dispatch.current({
action: "setError",
payload: Err(parseResult),
});
compiled = false;
return;
}
const loadResult = translator.load(
Ok(parseResult),
asm.split("\n").length,
);
if (isErr(loadResult)) {
dispatch.current({
action: "setError",
payload: Err(loadResult),
});
compiled = false;
return;
}
compiled = translator.asm.instructions.length > 0;
setStatus("");
dispatch.current({ action: "setError" });
dispatch.current({ action: "update" });
},
setAnimate(value: boolean) {
animate = value;
},
async step(): Promise<boolean> {
if (compiled) {
translating = true;
}
translator.step(highlightInfo);
if (animate || translator.done) {
dispatch.current({ action: "update" });
if (path && upgraded) {
await fs.writeFile(
path.replace(/\.asm$/, ".hack"),
translator.getResult(),
);
}
}
if (translator.done) {
setStatus({
message: "Translation done.",
severity: "SUCCESS",
});
}
return translator.done;
},
compare() {
dispatch.current({ action: "compare" });
this.updateHighlight(highlightInfo.resultHighlight?.start ?? 0, false);
dispatch.current({ action: "update" });
},
updateHighlight(index: number, fromSource: boolean) {
if (failure) {
return;
}
for (const [sourceSpan, resultSpan] of highlightInfo.highlightMap) {
if (
(fromSource &&
sourceSpan.start <= index &&
index <= sourceSpan.end) ||
(!fromSource && resultSpan.start <= index && index <= resultSpan.end)
) {
highlightInfo.sourceHighlight = sourceSpan;
highlightInfo.resultHighlight = resultSpan;
}
}
dispatch.current({ action: "update" });
},
resetHighlightInfo() {
highlightInfo.sourceHighlight = undefined;
highlightInfo.resultHighlight = undefined;
highlightInfo.highlightMap.clear();
},
reset() {
failure = false;
translating = false;
setStatus("Reset");
translator.reset();
this.resetHighlightInfo();
dispatch.current({ action: "update" });
},
clear() {
this.setAsm("");
dispatch.current({ action: "setTitle", payload: undefined });
dispatch.current({ action: "setCmp", payload: "" });
this.reset();
},
overrideState(state: AsmPageState) {
this.resetHighlightInfo();
this.setAsm(state.asm, state.path);
dispatch.current({
action: "setCmp",
payload: { cmp: state.compare, name: state.compareName },
});
if (state.translating) {
for (let i = 0; i <= state.current; i++) {
this.step();
}
}
dispatch.current({ action: "update" });
},
};
const initialState: AsmPageState = {
asm: "",
path: undefined,
translating: false,
current: -1,
resultHighlight: undefined,
sourceHighlight: undefined,
symbols: [],
result: "",
compare: "",
compareName: undefined,
lineNumbers: [],
compareError: false,
config: {
speed: 2,
},
};
return { initialState, reducers, actions };
}
export function useAsmPageStore() {
const { setStatus, fs, localFsRoot } = useContext(BaseContext);
const dispatch = useRef<AsmStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeAsmStore(fs, setStatus, dispatch, localFsRoot != undefined),
[setStatus, dispatch, fs],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,186 @@
import {
FileSystem,
LocalStorageFileSystemAdapter,
} from "@davidsouther/jiffies/lib/esm/fs.js";
import { Action, AsyncAction } from "@nand2tetris/simulator/types.js";
import {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useDialog } from "../dialog.js";
import { cloneTree } from "../file_utils.js";
import {
FileSystemAccessFileSystemAdapter,
openNand2TetrisDirectory,
} from "./base/fs.js";
import {
attemptLoadAdapterFromIndexedDb,
createAndStoreLocalAdapterInIndexedDB,
removeLocalAdapterFromIndexedDB,
} from "./base/indexDb.js";
export type StatusSeverity = "SUCCESS" | "WARNING" | "ERROR" | "INFO";
export interface BaseContext {
fs: FileSystem;
localFsRoot?: string;
canUpgradeFs: boolean;
upgradeFs: (force?: boolean, createFiles?: boolean) => Promise<void>;
closeFs: () => void;
status: { message: string; severity: StatusSeverity };
setStatus: Action<string | { message: string; severity?: StatusSeverity }>;
storage: Record<string, string>;
permissionPrompt: ReturnType<typeof useDialog>;
requestPermission: AsyncAction<void>;
loadFs: Action<void>;
}
export function useBaseContext(): BaseContext {
const localAdapter = useMemo(() => new LocalStorageFileSystemAdapter(), []);
const [fs, setFs] = useState(new FileSystem(localAdapter));
const [root, setRoot] = useState<string>();
const permissionPrompt = useDialog();
const setLocalFs = useCallback(
async (handle: FileSystemDirectoryHandle, createFiles = false) => {
// We will not mirror the changes in localStorage, since they will be saved in the user's file system
const newFs = new FileSystem(
new FileSystemAccessFileSystemAdapter(handle),
);
if (createFiles) {
if (root) {
const loaders = await import("@nand2tetris/projects/loader.js");
await loaders.createFiles(newFs);
} else {
await cloneTree(fs, newFs, "/projects", (path: string) =>
path.replace("/projects", "/").replace(/\/0*(\d+)/, "$1"),
);
}
}
setFs(newFs);
setRoot(handle.name);
},
[setRoot, setFs],
);
const requestPermission = async () => {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
await adapter.requestPermission({ mode: "readwrite" });
});
};
const loadFs = () => {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
setLocalFs(adapter);
});
};
useEffect(() => {
if (root) return;
if ("showDirectoryPicker" in window) {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
const permissions = await adapter.queryPermission({
mode: "readwrite",
});
switch (permissions) {
case "granted":
setLocalFs(adapter);
break;
case "prompt":
permissionPrompt.open();
break;
case "denied":
setStatus({
message:
"Permission denied. Please allow access to your file system.",
severity: "ERROR",
});
break;
}
});
}
}, [root, setLocalFs]);
const canUpgradeFs = `showDirectoryPicker` in window;
const upgradeFs = useCallback(
async (force = false, createFiles = false) => {
if (!canUpgradeFs || (root && !force)) return;
const handler = await openNand2TetrisDirectory();
if (root) {
await removeLocalAdapterFromIndexedDB();
}
const adapter = await createAndStoreLocalAdapterInIndexedDB(handler);
await setLocalFs(adapter, createFiles);
},
[root, setLocalFs],
);
const closeFs = useCallback(async () => {
if (!root) return;
await removeLocalAdapterFromIndexedDB();
setRoot(undefined);
setFs(new FileSystem(localAdapter));
}, [root]);
const [status, setStatusInternal] = useState<{
message: string;
severity: StatusSeverity;
}>({ message: "", severity: "INFO" });
const setStatus = useCallback(
(input: string | { message: string; severity?: StatusSeverity }) => {
if (typeof input === "string") {
setStatusInternal({ message: input, severity: "INFO" });
} else {
setStatusInternal({
message: input.message,
severity: input.severity || "INFO",
});
}
},
[],
);
return {
fs,
localFsRoot: root,
status,
setStatus,
storage: localStorage,
canUpgradeFs,
permissionPrompt,
upgradeFs,
requestPermission,
closeFs,
loadFs,
};
}
export const BaseContext = createContext<BaseContext>({
fs: new FileSystem(new LocalStorageFileSystemAdapter()),
canUpgradeFs: false,
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
async upgradeFs() {},
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
closeFs() {},
status: { message: "", severity: "INFO" },
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
setStatus() {},
storage: {},
permissionPrompt: {} as ReturnType<typeof useDialog>,
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
async requestPermission() {},
// biome-ignore lint/suspicious/noEmptyBlockStatements: abstract base
loadFs() {},
});
@@ -0,0 +1,176 @@
import {
basename,
FileSystemAdapter,
SEP,
Stats,
} from "@davidsouther/jiffies/lib/esm/fs.js";
function dirname(path: string): string {
return path.split(SEP).slice(0, -1).join(SEP);
}
export function openNand2TetrisDirectory(): Promise<FileSystemDirectoryHandle> {
return window.showDirectoryPicker({
id: "nand2tetris",
mode: "readwrite",
startIn: "documents",
});
}
export class FileSystemAccessFileSystemAdapter implements FileSystemAdapter {
constructor(private baseDir: FileSystemDirectoryHandle) {}
async getFolder(
path: string,
create = false,
): Promise<FileSystemDirectoryHandle> {
let folder = this.baseDir;
const parts = path
.split(SEP)
.slice(1)
.filter((part) => part.trim() != "");
for (const part of parts) {
folder = await folder.getDirectoryHandle(part, { create });
}
return folder;
}
async copyFile(from: string, to: string): Promise<void> {
throw new Error(
"unimplemented: FileSystemAccessFileSystemAdapter::copyFile",
);
}
async mkdir(path: string): Promise<void> {
this.getFolder(path, true);
}
async readFile(path: string): Promise<string> {
const folder = await this.getFolder(dirname(path));
const file = await (await folder.getFileHandle(basename(path))).getFile();
return file.text();
}
async writeFile(path: string, contents: string): Promise<void> {
const folder = await this.getFolder(dirname(path), true);
const file = await (
await folder.getFileHandle(basename(path), { create: true })
).createWritable();
await file.write(contents);
await file.close();
}
async readdir(path: string): Promise<string[]> {
const folder = await this.getFolder(path);
const entries: string[] = [];
for await (const [entry, _] of folder.entries()) {
entries.push(entry);
}
return entries;
}
async scandir(path: string): Promise<Stats[]> {
const folder = await this.getFolder(path);
const entries: Stats[] = [];
for await (const [name, handle] of folder.entries()) {
entries.push({
name,
isDirectory() {
return handle.kind == "directory";
},
isFile() {
return handle.kind == "file";
},
});
}
return entries;
}
async stat(path: string): Promise<Stats> {
const folder = await this.getFolder(dirname(path));
for await (const [name, handle] of folder.entries()) {
if (name == basename(path)) {
return {
name,
isDirectory() {
return handle.kind == "directory";
},
isFile() {
return handle.kind == "file";
},
};
}
}
return {
name: basename(path),
isDirectory() {
return false;
},
isFile() {
return false;
},
};
}
async rm(path: string): Promise<void> {
const folder = await this.getFolder(dirname(path), true);
await folder.removeEntry(basename(path), { recursive: true });
}
}
export class ChainedFileSystemAdapter implements FileSystemAdapter {
constructor(
protected adapter: FileSystemAdapter,
private nextAdapter?: FileSystemAdapter | undefined,
) {}
stat(path: string): Promise<Stats> {
return this.adapter.stat(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.stat(path);
}
throw e;
});
}
readdir(path: string): Promise<string[]> {
return this.adapter.readdir(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.readdir(path);
}
throw e;
});
}
scandir(path: string): Promise<Stats[]> {
return this.adapter.scandir(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.scandir(path);
}
throw e;
});
}
async mkdir(path: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter.mkdir(path);
return this.adapter.mkdir(path);
}
async copyFile(from: string, to: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter.copyFile(from, to);
return this.adapter.copyFile(from, to);
}
readFile(path: string): Promise<string> {
return this.adapter.readFile(path).catch((e) => {
if (this.nextAdapter) {
return this.nextAdapter.readFile(path);
}
throw e;
});
}
async writeFile(path: string, contents: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter?.writeFile(path, contents);
return this.adapter.writeFile(path, contents);
}
async rm(path: string): Promise<void> {
if (this.nextAdapter) await this.nextAdapter.rm(path);
return this.adapter.rm(path);
}
}
@@ -0,0 +1,96 @@
import { assert } from "@davidsouther/jiffies/lib/esm/assert.js";
const IDB_NAME = "NAND2TetrisIndexedDB";
const IDB_VERSION = 1;
const IDB_FS_ADAPTER_OBJECT_STORE = "FileSystemAccess";
const IDB_FS_ADAPTER_KEY = "Handler";
function openIndexedDb(): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = window.indexedDB.open(IDB_NAME, IDB_VERSION);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
request.onupgradeneeded = (e) => {
request.result.createObjectStore(IDB_FS_ADAPTER_OBJECT_STORE);
};
});
}
export async function attemptLoadAdapterFromIndexedDb(): Promise<FileSystemDirectoryHandle | void> {
const db = await openIndexedDb();
return new Promise<FileSystemDirectoryHandle | void>((resolve, reject) => {
const transaction = db.transaction(
[IDB_FS_ADAPTER_OBJECT_STORE],
"readonly",
);
const objectStore = transaction.objectStore(IDB_FS_ADAPTER_OBJECT_STORE);
const handleRequest = objectStore.get(IDB_FS_ADAPTER_KEY);
handleRequest.onsuccess = () => {
const handle = handleRequest.result;
if (handle === undefined) {
resolve();
} else {
assert(
handle instanceof FileSystemDirectoryHandle,
`Retrieved ${IDB_FS_ADAPTER_KEY} in ${IDB_FS_ADAPTER_OBJECT_STORE} in ${IDB_NAME} is not a FileSystemDirectoryHandle`,
);
resolve(handle);
}
};
transaction.onerror = () => {
console.error("Error in loading FileSystemDirectoryHandle transaction", {
err: transaction.error,
});
reject(transaction.error);
};
handleRequest.onerror = () => {
console.error("Error in FileSystemDirectoryHandle handleRequest", {
err: handleRequest.error,
});
reject(handleRequest.error);
};
});
}
export async function createAndStoreLocalAdapterInIndexedDB(
handle: FileSystemDirectoryHandle,
): Promise<FileSystemDirectoryHandle> {
const db = await openIndexedDb();
const transaction = db.transaction(
[IDB_FS_ADAPTER_OBJECT_STORE],
"readwrite",
);
transaction
.objectStore(IDB_FS_ADAPTER_OBJECT_STORE)
.add(handle, IDB_FS_ADAPTER_KEY);
transaction.commit();
return new Promise<FileSystemDirectoryHandle>((resolve, reject) => {
transaction.oncomplete = () => {
resolve(handle);
};
transaction.onerror = () => {
reject(transaction.error);
};
});
}
export async function removeLocalAdapterFromIndexedDB() {
const db = await openIndexedDb();
const transaction = db.transaction(
[IDB_FS_ADAPTER_OBJECT_STORE],
"readwrite",
);
transaction
.objectStore(IDB_FS_ADAPTER_OBJECT_STORE)
.delete(IDB_FS_ADAPTER_KEY);
return new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject(transaction.error);
};
});
}
@@ -0,0 +1,164 @@
import {
FileSystem,
ObjectFileSystemAdapter,
} from "@davidsouther/jiffies/lib/esm/fs.js";
import { cleanState } from "@davidsouther/jiffies/lib/esm/scope/state.js";
import * as not from "@nand2tetris/projects/project_01/01_not.js";
import { produce } from "immer";
import { MutableRefObject } from "react";
import { ImmPin } from "src/pinout.js";
import { ChipStoreDispatch, makeChipStore } from "./chip.store.js";
function testChipStore(
fs: Record<string, string> = {
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
},
storage: Record<string, string> = {},
) {
const dispatch: MutableRefObject<ChipStoreDispatch> = { current: jest.fn() };
const setStatus = jest.fn();
const { initialState, actions, reducers } = makeChipStore(
new FileSystem(new ObjectFileSystemAdapter(fs)),
setStatus,
storage,
dispatch,
false,
);
const store = { state: initialState, actions, reducers, dispatch, setStatus };
dispatch.current = jest.fn().mockImplementation(
// biome-ignore lint/suspicious/noExplicitAny: covariants are hard
(command: { action: keyof typeof reducers; payload: any }) => {
store.state = produce(store.state, (draft: typeof initialState) => {
reducers[command.action](draft, command.payload);
});
},
);
return store;
}
describe("ChipStore", () => {
describe("initialization", () => {
it("loads chip", async () => {
const store = testChipStore({
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
});
await store.actions.initialize();
await store.actions.loadChip("projects/01/Not.hdl");
expect(store.state.controls.project).toBe("01");
expect(store.state.controls.chipName).toBe("Not");
expect(store.state.files.hdl).toBe(not.hdl);
expect(store.state.files.tst).toBe(not.tst);
expect(store.state.files.cmp).toBe("");
expect(store.state.files.out).toBe("");
});
});
describe("behavior", () => {
const state = cleanState(async () => {
const store = testChipStore({
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
});
await store.actions.initialize();
await store.actions.loadChip("projects/01/Not.hdl");
return { store };
}, beforeEach);
it.todo("loads projects and chips");
it("toggles bits", async () => {
state.store.actions.toggle(state.store.state.sim.chip[0].in(), 0);
expect(state.store.state.sim.chip[0].in().busVoltage).toBe(1);
expect(state.store.dispatch.current).toHaveBeenCalledWith({
action: "updateChip",
payload: { pending: true },
});
expect(state.store.state.sim.pending).toBe(true);
state.store.actions.eval();
expect(state.store.dispatch.current).toHaveBeenCalledWith({
action: "updateChip",
payload: { pending: false },
});
expect(state.store.state.sim.pending).toBe(false);
expect(state.store.state.sim.chip[0].out().busVoltage).toBe(0);
});
});
describe("execution", () => {
const state = cleanState(async () => {
const store = testChipStore({
"projects/01/Not.hdl": not.hdl,
"projects/01/Not.tst": not.tst,
"projects/01/Not.cmp": not.cmp,
});
await store.actions.initialize();
await store.actions.loadChip("projects/01/Not.hdl");
return { store };
}, beforeEach);
it.todo("compiles chips");
it("steps tests", async () => {
const bits = (pins: ImmPin[]) =>
pins.map((pin) => pin.bits.map((bit) => bit[1]));
expect(bits(state.store.state.sim.inPins)).toEqual([[0]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[0]]);
await state.store.actions.toggleBuiltin();
expect(bits(state.store.state.sim.inPins)).toEqual([[0]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[1]]);
await state.store.actions.stepTest(); // Load, Compare To and Output List
await state.store.actions.stepTest(); // Set in 0
expect(bits(state.store.state.sim.inPins)).toEqual([[0]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[1]]);
await state.store.actions.stepTest(); // Set in 1
expect(bits(state.store.state.sim.inPins)).toEqual([[1]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[0]]);
await state.store.actions.stepTest(); // No change (after end)
expect(bits(state.store.state.sim.inPins)).toEqual([[1]]);
expect(bits(state.store.state.sim.outPins)).toEqual([[0]]);
});
it("starts the cursor on the first instruction", () => {
expect(state.store.state.files.tst).toBe(not.tst);
expect(state.store.state.controls.span).toEqual({
start: 167,
end: 220,
line: 6,
});
});
it("leaves the cursor on the final character", async () => {
// Not.tst has 3 commands
await state.store.actions.stepTest();
await state.store.actions.stepTest();
await state.store.actions.stepTest();
// Past the end of the test
await state.store.actions.stepTest();
expect(state.store.state.controls.span).toEqual({
start: 269,
end: 270,
line: 16,
});
});
});
});
@@ -0,0 +1,647 @@
import { assertExists } from "@davidsouther/jiffies/lib/esm/assert.js";
import { display } from "@davidsouther/jiffies/lib/esm/display.js";
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { Err, isErr, Ok } from "@davidsouther/jiffies/lib/esm/result.js";
import {
BUILTIN_CHIP_PROJECTS,
CHIP_PROJECTS,
sortChips,
} from "@nand2tetris/projects/base.js";
import { parse as parseChip } from "@nand2tetris/simulator/chip/builder.js";
import { getBuiltinChip } from "@nand2tetris/simulator/chip/builtins/index.js";
import {
Chip,
Low,
Pin,
Chip as SimChip,
} from "@nand2tetris/simulator/chip/chip.js";
import { Clock } from "@nand2tetris/simulator/chip/clock.js";
import {
CompilationError,
Span,
} from "@nand2tetris/simulator/languages/base.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { ChipTest } from "@nand2tetris/simulator/test/chiptst.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { compare } from "../compare.js";
import { sortFiles } from "../file_utils.js";
import { ImmPin, reducePins } from "../pinout.js";
import { useImmerReducer } from "../react.js";
import { RunSpeed } from "../runbar.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
export const NO_SCREEN = "noScreen";
export const PROJECT_NAMES = [
["01", `Project 1`],
["02", `Project 2`],
["03", `Project 3`],
["05", `Project 5`],
];
const TEST_NAMES: Record<string, string[]> = {
CPU: ["CPU", "CPU-external"],
Computer: ["ComputerAdd", "ComputerMax", "ComputerRect"],
};
export function isBuiltinOnly(
project: keyof typeof CHIP_PROJECTS,
chipName: string,
) {
return BUILTIN_CHIP_PROJECTS[project].includes(chipName);
}
function convertToBuiltin(name: string, hdl: string) {
return hdl.replace(/PARTS:([\s\S]*?)\}/, `PARTS:\n\tBUILTIN ${name};`);
}
export interface ChipPageState {
title?: string;
files: Files;
sim: ChipSim;
controls: ControlsState;
config: ChipPageConfig;
dir?: string;
}
export interface ChipPageConfig {
speed: RunSpeed;
}
export interface ChipSim {
clocked: boolean;
inPins: ImmPin[];
outPins: ImmPin[];
internalPins: ImmPin[];
chip: [Chip];
pending: boolean;
invalid: boolean;
}
export interface Files {
hdl: string;
cmp: string;
tst: string;
out: string;
}
export interface ControlsState {
projects: string[];
project: string;
chips: string[];
chipName: string;
tests: string[];
testName: string;
usingBuiltin: boolean;
runningTest: boolean;
span?: Span;
error?: CompilationError;
visualizationParameters: Set<string>;
}
export interface HDLFile {
name: string;
content: string;
}
function reduceChip(chip: SimChip, pending = false, invalid = false): ChipSim {
return {
clocked: chip.clocked,
inPins: reducePins(chip.ins),
outPins: reducePins(chip.outs),
internalPins: reducePins(chip.pins),
chip: [chip],
pending,
invalid,
};
}
const clock = Clock.get();
export type ChipStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeChipStore>["reducers"];
payload?: unknown;
}>;
export function makeChipStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
storage: Record<string, string>,
dispatch: MutableRefObject<ChipStoreDispatch>,
upgraded: boolean,
) {
let _chipName = "";
let _dir = "";
let chip = new Low();
let backupHdl = "";
let tests: string[] = [];
let test = new ChipTest();
let usingBuiltin = false;
let invalid = false;
const reducers = {
setFiles(
state: ChipPageState,
{
hdl = state.files.hdl,
tst = state.files.tst,
cmp = state.files.cmp,
out = "",
}: {
hdl?: string;
tst?: string;
cmp?: string;
out?: string;
},
) {
state.files.hdl = hdl;
state.files.tst = tst;
state.files.cmp = cmp;
state.files.out = out;
},
updateChip(
state: ChipPageState,
payload?: {
pending?: boolean;
invalid?: boolean;
chipName?: string;
error?: CompilationError | undefined;
},
) {
state.sim = reduceChip(
chip,
payload?.pending ?? state.sim.pending,
payload?.invalid ?? state.sim.invalid,
);
state.controls.error = state.sim.invalid
? (payload?.error ?? state.controls.error)
: undefined;
},
setProjects(state: ChipPageState, projects: string[]) {
state.controls.projects = projects;
},
setProject(state: ChipPageState, project: keyof typeof CHIP_PROJECTS) {
state.controls.project = project;
},
setChips(state: ChipPageState, chips: string[]) {
state.controls.chips = chips;
},
setChip(
state: ChipPageState,
{ chipName, dir }: { chipName: string; dir: string },
) {
_dir = dir;
_chipName = chipName;
state.controls.chipName = chipName;
state.title = `${chipName}.hdl`;
state.controls.tests = Array.from(tests);
state.dir = dir;
},
clearChip(state: ChipPageState) {
_chipName = "";
state.controls.chipName = "";
state.title = undefined;
state.controls.tests = [];
this.setFiles(state, { hdl: "", tst: "", cmp: "", out: "" });
},
setTest(state: ChipPageState, testName: string) {
state.controls.testName = testName;
},
setVisualizationParams(state: ChipPageState, params: Set<string>) {
state.controls.visualizationParameters = new Set(params);
},
testRunning(state: ChipPageState) {
state.controls.runningTest = true;
},
testFinished(state: ChipPageState) {
state.controls.runningTest = false;
const passed = compare(state.files.cmp.trim(), state.files.out.trim());
// For some reason, this is happening during a render but I can't track it down.
Promise.resolve().then(() => {
setStatus(
passed
? {
message: `Simulation successful: The output file is identical to the compare file`,
severity: "SUCCESS",
}
: {
message: `Simulation error: The output file differs from the compare file`,
severity: "ERROR",
},
);
});
},
updateTestStep(state: ChipPageState) {
state.files.out = test?.log() ?? "";
if (test?.currentStep?.span) {
state.controls.span = test.currentStep.span;
} else {
if (test.done) {
const end = state.files.tst.length;
state.controls.span = {
start: end - 1,
end,
line: state.files.tst.split("\n").length,
};
}
}
this.updateChip(state, {
pending: state.sim.pending,
invalid: state.sim.invalid,
});
},
updateConfig(state: ChipPageState, config: Partial<ChipPageConfig>) {
state.config = { ...state.config, ...config };
},
updateUsingBuiltin(state: ChipPageState) {
state.controls.usingBuiltin = usingBuiltin;
},
displayBuiltin(state: ChipPageState) {
backupHdl = state.files.hdl;
this.setFiles(state, {
hdl: convertToBuiltin(state.controls.chipName, state.files.hdl),
});
},
toggleBuiltin(state: ChipPageState) {
state.controls.usingBuiltin = usingBuiltin;
if (usingBuiltin) {
this.displayBuiltin(state);
} else {
this.setFiles(state, { hdl: backupHdl });
}
},
};
const actions = {
async initialize() {
const projectsFolder = upgraded ? "/" : "/projects";
const entries = await fs.scandir(projectsFolder);
const hdlProjects = [];
for (const project of entries.filter((project) =>
project.isDirectory(),
)) {
const items = await fs.scandir(`${projectsFolder}/${project.name}`);
if (items.some((item) => item.isFile() && item.name.endsWith(".hdl"))) {
hdlProjects.push(project);
}
}
const sortedNames = sortFiles(hdlProjects).map((project) => project.name);
dispatch.current({
action: "setProjects",
payload: sortedNames,
});
if (hdlProjects.length > 0) {
await actions.setProject(sortedNames[0]);
} else {
dispatch.current({ action: "setChips", payload: [] });
}
dispatch.current({ action: "clearChip" });
},
reset() {
Clock.get().reset();
chip.reset();
test.reset();
dispatch.current({ action: "setFiles", payload: {} });
dispatch.current({ action: "updateChip" });
},
async setProject(project: string) {
storage["/chip/project"] = project;
dispatch.current({ action: "setProject", payload: project });
const prefix = upgraded ? "/" : "/projects";
const chips = (await fs.scandir(`${prefix}/${project}`))
.filter((entry) => entry.isFile() && entry.name.endsWith(".hdl"))
.map((file) => file.name.replace(".hdl", ""));
const payload = sortChips(project, chips);
dispatch.current({ action: "setChips", payload });
if (chips.length > 0) {
this.loadChip(`${prefix}/${project}/${chips[0]}.hdl`, true);
}
},
async loadChip(path: string, loadTests = true) {
dispatch.current({ action: "updateUsingBuiltin", payload: false });
const hdl = await fs.readFile(path);
const parts = path.split("/");
const name = assertExists(parts.pop()).replace(".hdl", "");
const dir = parts.join("/");
await this.compileChip(hdl, dir, name);
if (loadTests) {
await this.initializeTests(dir, name);
}
dispatch.current({
action: "setChip",
payload: { chipName: name, dir: dir },
});
dispatch.current({ action: "setFiles", payload: { hdl } });
if (usingBuiltin) {
this.loadBuiltin();
dispatch.current({ action: "displayBuiltin" });
}
},
async compileChip(hdl: string, dir?: string, name?: string) {
chip.remove();
const maybeChip = await parseChip(hdl, dir, name, fs);
if (isErr(maybeChip)) {
const error = Err(maybeChip);
setStatus({
message: Err(maybeChip).message,
severity: "ERROR",
});
invalid = true;
dispatch.current({
action: "updateChip",
payload: { invalid: true, error },
});
return;
}
this.replaceChip(Ok(maybeChip));
},
replaceChip(nextChip: SimChip) {
// Store current inPins
const inPins = chip.ins;
for (const [pin, { busVoltage }] of inPins) {
const nextPin = nextChip.ins.get(pin);
if (nextPin) {
nextPin.busVoltage = busVoltage;
}
}
clock.reset();
nextChip.eval();
chip = nextChip;
chip.reset();
dispatch.current({ action: "updateChip", payload: { invalid: false } });
dispatch.current({ action: "updateTestStep" });
},
async initializeTests(dir: string, chip: string) {
tests = TEST_NAMES[chip] ?? [];
this.loadTest(tests[0] ?? chip, dir);
},
async loadTest(name: string, dir?: string) {
if (!fs) return;
try {
dir ??= _dir;
const tst = await fs.readFile(`${dir}/${name}.tst`);
dispatch.current({ action: "setFiles", payload: { tst, cmp: "" } });
dispatch.current({ action: "setTest", payload: name });
this.compileTest(tst, dir);
} catch (e) {
setStatus({
message: `Could not find ${name}.tst. Please load test file separately.`,
severity: "WARNING",
});
console.error(e);
}
},
compileTest(file: string, path: string) {
if (!fs) return;
dispatch.current({ action: "setFiles", payload: { tst: file } });
const tst = TST.parse(file);
if (isErr(tst)) {
setStatus({
message: `Failed to parse test ${Err(tst).message}`,
severity: "ERROR",
});
invalid = true;
return false;
}
const maybeTest = ChipTest.from(Ok(tst), {
dir: path,
setStatus: setStatus,
loadAction: async (file) => {
await this.loadChip(file, false);
return chip;
},
compareTo: async (file) => {
const cmp = await fs.readFile(`${_dir}/${file}`);
dispatch.current({ action: "setFiles", payload: { cmp } });
},
});
if (isErr(maybeTest)) {
invalid = true;
setStatus({
message: Err(maybeTest).message,
severity: "ERROR",
});
return false;
} else {
test = Ok(maybeTest).with(chip).reset();
test.setFileSystem(fs);
dispatch.current({ action: "updateTestStep" });
return true;
}
},
async updateFiles({
hdl,
tst,
cmp,
tstPath,
}: {
hdl?: string;
tst?: string;
cmp: string;
tstPath?: string;
}) {
invalid = false;
dispatch.current({ action: "setFiles", payload: { hdl, tst, cmp } });
try {
if (hdl) {
await this.compileChip(hdl, _dir, _chipName);
}
if (tst) {
this.compileTest(tst, tstPath ?? _dir);
}
} catch (e) {
setStatus({
message: display(e),
severity: "ERROR",
});
}
dispatch.current({ action: "updateChip", payload: { invalid: invalid } });
if (!invalid) {
setStatus(`HDL code: No syntax errors`);
}
},
async saveChip(hdl: string) {
dispatch.current({ action: "setFiles", payload: { hdl } });
const path = `${_dir}/${_chipName}.hdl`;
if (fs && path) {
await fs.writeFile(path, hdl);
}
},
toggle(pin: Pin, i: number | undefined) {
if (i !== undefined) {
pin.busVoltage = pin.busVoltage ^ (1 << i);
} else {
if (pin.width === 1) {
pin.toggle();
} else {
pin.busVoltage += 1;
}
}
dispatch.current({ action: "updateChip", payload: { pending: true } });
},
eval() {
chip.eval();
dispatch.current({ action: "updateChip", payload: { pending: false } });
},
clock() {
clock.toggle();
if (clock.isLow) {
clock.frame();
}
dispatch.current({ action: "updateChip" });
},
async loadBuiltin() {
const builtinName = _chipName;
const nextChip = await getBuiltinChip(builtinName);
if (isErr(nextChip)) {
setStatus({
message: `Failed to load builtin ${builtinName}: ${display(Err(nextChip))}`,
severity: "ERROR",
});
return;
}
this.replaceChip(Ok(nextChip));
},
async toggleBuiltin() {
usingBuiltin = !usingBuiltin;
dispatch.current({ action: "toggleBuiltin" });
if (usingBuiltin) {
await this.loadBuiltin();
} else {
await this.compileChip(backupHdl, _dir, _chipName);
}
},
tick(): Promise<boolean> {
return this.stepTest();
},
async stepTest(): Promise<boolean> {
try {
const done = await test.step();
dispatch.current({ action: "updateTestStep" });
if (done) {
dispatch.current({ action: "testFinished" });
}
return done;
} catch (e) {
setStatus({
message: (e as Error).message,
severity: "ERROR",
});
return true;
}
},
async getProjectFiles() {
console.log(_dir);
return (await fs.scandir(_dir))
.filter((entry) => entry.isFile() && entry.name.endsWith(".hdl"))
.map((entry) => ({
name: entry.name,
content: fs.readFile(`${_dir}/${entry.name}`),
}));
},
};
const initialState: ChipPageState = (() => {
const controls: ControlsState = {
projects: ["1", "2", "3", "5"],
project: "1",
chips: [],
chipName: "",
tests,
testName: "",
usingBuiltin: false,
runningTest: false,
error: undefined,
visualizationParameters: new Set(),
};
const sim = reduceChip(new Low());
return {
controls,
files: {
hdl: "",
cmp: "",
tst: "",
out: "",
backupHdl: "",
},
sim,
config: { speed: 2 },
};
})();
return { initialState, reducers, actions };
}
export function useChipPageStore() {
const { fs, setStatus, storage, localFsRoot } = useContext(BaseContext);
const dispatch = useRef<ChipStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() =>
makeChipStore(fs, setStatus, storage, dispatch, localFsRoot != undefined),
[fs, setStatus, storage, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,175 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { compile } from "@nand2tetris/simulator/jack/compiler.js";
import { CompilationError } from "@nand2tetris/simulator/languages/base.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { useImmerReducer } from "../react.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
export interface CompiledFile {
vm?: string;
valid: boolean;
error?: CompilationError;
}
export interface CompilerPageState {
fs?: FileSystem;
files: Record<string, string>;
compiled: Record<string, CompiledFile>;
isValid: boolean;
isCompiled: boolean;
selected: string;
title?: string;
}
export type CompilerStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeCompilerStore>["reducers"];
payload?: unknown;
}>;
function classTemplate(name: string) {
return `class ${name} {\n\n}\n`;
}
export function makeCompilerStore(
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
dispatch: MutableRefObject<CompilerStoreDispatch>,
) {
let fs: FileSystem | undefined;
const reducers = {
setFs(state: CompilerPageState, fs: FileSystem) {
state.fs = fs;
},
reset(state: CompilerPageState) {
state.files = {};
state.title = undefined;
},
setFile(
state: CompilerPageState,
{ name, content }: { name: string; content: string },
) {
state.files[name] = content;
state.isCompiled = false;
this.compile(state);
},
// the keys of 'files' have to be the full file path, not basename
setFiles(state: CompilerPageState, files: Record<string, string>) {
state.files = files;
state.isCompiled = false;
this.compile(state);
},
compile(state: CompilerPageState) {
const compiledFiles = compile(state.files);
state.compiled = {};
for (const [name, compiled] of Object.entries(compiledFiles)) {
if (typeof compiled === "string") {
state.compiled[name] = {
valid: true,
vm: compiled,
};
} else {
state.compiled[name] = {
valid: false,
error: compiled,
};
}
}
state.isValid = Object.keys(state.files).every(
(file) => state.compiled[file].valid,
);
},
writeCompiled(state: CompilerPageState) {
if (Object.values(state.compiled).every((compiled) => compiled.valid)) {
for (const [name, compiled] of Object.entries(state.compiled)) {
if (compiled.vm) {
fs?.writeFile(`${name}.vm`, compiled.vm);
}
}
}
state.isCompiled = true;
},
setSelected(state: CompilerPageState, selected: string) {
state.selected = selected;
},
setTitle(state: CompilerPageState, title: string) {
state.title = title;
},
};
const actions = {
async loadProject(_fs: FileSystem, title: string) {
this.reset();
fs = _fs;
dispatch.current({ action: "setFs", payload: fs });
dispatch.current({ action: "setTitle", payload: title });
const files: Record<string, string> = {};
for (const file of (await fs.scandir("/")).filter(
(entry) => entry.isFile() && entry.name.endsWith(".jack"),
)) {
files[file.name.replace(".jack", "")] = await fs.readFile(file.name);
}
this.loadFiles(files);
},
async loadFiles(files: Record<string, string>) {
dispatch.current({ action: "setFiles", payload: files });
if (Object.entries(files).length > 0) {
dispatch.current({
action: "setSelected",
payload: Object.keys(files)[0],
});
}
},
async writeFile(name: string, content?: string) {
content ??= classTemplate(name);
dispatch.current({ action: "setFile", payload: { name, content } });
if (fs) {
await fs.writeFile(`${name}.jack`, content);
}
},
async reset() {
fs = undefined;
dispatch.current({ action: "reset" });
},
async compile() {
dispatch.current({ action: "writeCompiled" });
},
};
const initialState: CompilerPageState = {
files: {},
compiled: {},
selected: "",
isCompiled: false,
isValid: true,
};
return { initialState, reducers, actions };
}
export function useCompilerPageStore() {
const { setStatus } = useContext(BaseContext);
const dispatch = useRef<CompilerStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeCompilerStore(setStatus, dispatch),
[setStatus, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,355 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs";
import {
Err,
isErr,
Ok,
unwrap,
} from "@davidsouther/jiffies/lib/esm/result.js";
import {
Format,
KeyboardAdapter,
MemoryAdapter,
MemoryKeyboard,
ROM,
} from "@nand2tetris/simulator/cpu/memory.js";
import { Span } from "@nand2tetris/simulator/languages/base.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { loadAsm, loadBlob, loadHack } from "@nand2tetris/simulator/loader.js";
import { CPUTest } from "@nand2tetris/simulator/test/cputst.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { ScreenScales } from "src/chips/screen.js";
import { RunSpeed } from "src/runbar.js";
import { compare } from "../compare.js";
import { loadTestFiles } from "../file_utils.js";
import { useImmerReducer } from "../react.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
import { ImmMemory } from "./imm_memory.js";
function makeTst() {
return `repeat {
ticktock;
}`;
}
export interface CpuSim {
A: number;
D: number;
PC: number;
RAM: MemoryAdapter;
ROM: MemoryAdapter;
Screen: MemoryAdapter;
Keyboard: KeyboardAdapter;
}
export interface CPUTestSim {
name: string;
tst: string;
cmp: string;
out: string;
highlight: Span | undefined;
valid: boolean;
}
export interface CPUPageConfig {
romFormat: Format;
ramFormat: Format;
screenScale: ScreenScales;
speed: RunSpeed;
testSpeed: RunSpeed;
}
export interface CpuPageState {
sim: CpuSim;
test: CPUTestSim;
path: string;
tests: string[];
title?: string;
config: CPUPageConfig;
}
function reduceCPUTest(
cpuTest: CPUTest,
dispatch: MutableRefObject<CpuStoreDispatch>,
): CpuSim {
const RAM = new ImmMemory(cpuTest.cpu.RAM, dispatch);
const ROM = new ImmMemory(cpuTest.cpu.ROM, dispatch);
const Screen = new ImmMemory(cpuTest.cpu.Screen, dispatch);
const Keyboard = new MemoryKeyboard(new ImmMemory(cpuTest.cpu.RAM, dispatch));
return {
A: cpuTest.cpu.A,
D: cpuTest.cpu.D,
PC: cpuTest.cpu.PC,
RAM,
ROM,
Screen,
Keyboard,
};
}
export type CpuStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeCpuStore>["reducers"];
payload?: unknown;
}>;
export function makeCpuStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
storage: Record<string, string>,
dispatch: MutableRefObject<CpuStoreDispatch>,
) {
let test = new CPUTest();
let animate = true;
let valid = true;
let path = "";
let tests: string[] = [];
let tstName = "";
let _title: string | undefined;
const reducers = {
update(state: CpuPageState) {
state.sim = reduceCPUTest(test, dispatch);
state.test.highlight = test.currentStep?.span;
state.test.valid = valid;
state.path = path;
state.tests = Array.from(tests);
state.test.name = tstName;
},
setTest(state: CpuPageState, { tst, cmp }: { tst?: string; cmp?: string }) {
state.test.tst = tst ?? state.test.tst;
state.test.cmp = cmp ?? state.test.cmp;
state.test.out = "";
},
testStep(state: CpuPageState) {
state.test.out = test.log();
this.update(state);
},
testFinished(state: CpuPageState) {
if (state.test.cmp.trim() === "") {
return;
}
const passed = compare(state.test.cmp.trim(), test.log().trim());
setStatus(
passed
? {
message: `Simulation successful: The output file is identical to the compare file`,
severity: "SUCCESS",
}
: {
message: `Simulation error: The output file differs from the compare file`,
severity: "ERROR",
},
);
},
setTitle(state: CpuPageState, title?: string) {
_title = title;
state.title = title;
if (title) {
test.fileLoaded = true;
}
},
updateConfig(state: CpuPageState, config: Partial<CPUPageConfig>) {
state.config = { ...state.config, ...config };
},
};
const actions = {
tick() {
test.cpu.tick();
},
setAnimate(value: boolean) {
animate = value;
},
async setPath(_path: string) {
path = _path;
const dir = path.split("/").slice(0, -1).join("/");
const files = await fs.scandir(dir);
tests = files
.filter((file) => file.name.endsWith(".tst"))
.map((file) => file.name);
if (tests.length > 0) {
this.loadTest(tests[0]);
} else {
tstName = "Default";
this.compileTest(makeTst(), "");
}
dispatch.current({ action: "update" });
},
async testStep() {
try {
const done = await test.step();
if (animate || done) {
dispatch.current({ action: "testStep" });
}
if (done) {
dispatch.current({ action: "testFinished" });
}
return done;
} catch (e) {
setStatus({
message: (e as Error).message,
severity: "ERROR",
});
return true;
}
},
resetRAM() {
test.cpu.RAM.loadBytes([]);
dispatch.current({ action: "update" });
setStatus("Reset RAM");
},
toggleUseTest() {
dispatch.current({ action: "update" });
},
reset() {
test.reset();
dispatch.current({ action: "update" });
},
clear() {
this.replaceROM(new ROM());
this.resetRAM();
this.clearTest();
this.reset();
dispatch.current({ action: "setTitle", payload: undefined });
},
clearTest() {
tstName = "";
this.compileTest(makeTst(), "");
dispatch.current({ action: "update" });
},
replaceROM(rom: ROM) {
test = new CPUTest({ dir: path, rom });
this.clearTest();
},
compileTest(file: string, cmp?: string, _path?: string) {
const tstPath = _path ?? path;
dispatch.current({ action: "setTest", payload: { tst: file, cmp } });
const tst = TST.parse(file);
if (isErr(tst)) {
setStatus({
message: `Failed to parse test - ${Err(tst).message}`,
severity: "ERROR",
});
valid = false;
dispatch.current({ action: "update" });
return false;
}
valid = true;
const maybeTest = CPUTest.from(Ok(tst), {
dir: tstPath,
rom: test.cpu.ROM,
doEcho: setStatus,
doLoad: async (path) => {
let file;
try {
file = await fs.readFile(path);
} catch (_e) {
throw new Error(`Cannot find ${path}`);
}
const loader = path.endsWith("hack")
? loadHack
: path.endsWith("asm")
? loadAsm
: loadBlob;
const bytes = await loader(file);
console.log(bytes);
test.cpu.ROM.loadBytes(bytes);
},
compareTo: async (file) => {
const dir = tstPath.split("/").slice(0, -1).join("/");
const cmp = await fs.readFile(`${dir}/${file}`);
dispatch.current({ action: "setTest", payload: { cmp } });
},
requireLoad: false,
});
if (isErr(maybeTest)) {
setStatus({
message: Err(maybeTest).message,
severity: "ERROR",
});
return false;
} else {
test = Ok(maybeTest);
test.fileLoaded = _title != undefined;
dispatch.current({ action: "update" });
return true;
}
},
async loadTest(name: string) {
const dir = path.split("/").slice(0, -1).join("/");
const files = await loadTestFiles(fs, `${dir}/${name}`);
if (isErr(files)) {
setStatus({
message: `Failed to load test`,
severity: "ERROR",
});
return;
}
tstName = name;
const { tst } = unwrap(files);
this.compileTest(tst, "");
},
};
const initialState: CpuPageState = {
sim: reduceCPUTest(test, dispatch),
test: {
highlight: test.currentStep?.span,
name: "",
tst: makeTst(),
cmp: "",
out: "",
valid: true,
},
path: "",
tests: [],
config: {
romFormat: "asm",
ramFormat: "dec",
screenScale: 1,
speed: 2,
testSpeed: 2,
},
};
return { initialState, reducers, actions };
}
export function useCpuPageStore() {
const { fs, setStatus, storage } = useContext(BaseContext);
const dispatch = useRef<CpuStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeCpuStore(fs, setStatus, storage, dispatch),
[fs, setStatus, storage, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
@@ -0,0 +1,20 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { MemoryAdapter, SubMemory } from "@nand2tetris/simulator/cpu/memory.js";
import { MutableRefObject } from "react";
export class ImmMemory<
Action extends { action: "update" },
Dispatch extends (a: Action) => void,
> extends SubMemory {
constructor(
parent: MemoryAdapter,
private dispatch: MutableRefObject<Dispatch>,
) {
super(parent, parent.size, 0);
}
override async load(fs: FileSystem, path: string): Promise<void> {
await super.load(fs, path);
this.dispatch.current({ action: "update" } as Action);
}
}
@@ -0,0 +1,465 @@
import { assertExists } from "@davidsouther/jiffies/lib/esm/assert.js";
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import {
Err,
isErr,
Ok,
Result,
unwrap,
} from "@davidsouther/jiffies/lib/esm/result.js";
import { FIBONACCI } from "@nand2tetris/projects/base.js";
import {
Format,
KeyboardAdapter,
MemoryAdapter,
MemoryKeyboard,
} from "@nand2tetris/simulator/cpu/memory.js";
import {
CompilationError,
Span,
} from "@nand2tetris/simulator/languages/base.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { VM, VmInstruction } from "@nand2tetris/simulator/languages/vm.js";
import { VMTest, VmFile } from "@nand2tetris/simulator/test/vmtst.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Vm, VmFrame } from "@nand2tetris/simulator/vm/vm.js";
import { Dispatch, MutableRefObject, useContext, useMemo, useRef } from "react";
import { ScreenScales } from "../chips/screen.js";
import { compare } from "../compare.js";
import { useImmerReducer } from "../react.js";
import { RunSpeed } from "../runbar.js";
import { BaseContext, StatusSeverity } from "./base.context.js";
import { ImmMemory } from "./imm_memory.js";
export const DEFAULT_TEST = "repeat {\n\tvmstep;\n}";
export interface VmSim {
RAM: MemoryAdapter;
Screen: MemoryAdapter;
Keyboard: KeyboardAdapter;
Stack: VmFrame[];
Prog: VmInstruction[];
Statics: number[];
Temp: number[];
AddedSysInit: boolean;
highlight: number;
showHighlight: boolean;
}
export interface VMTestSim {
highlight: Span | undefined;
}
export interface VmPageState {
vm: VmSim;
controls: ControlsState;
test: VMTestSim;
files: VMFiles;
title?: string;
config: VmPageConfig;
}
export interface VmPageConfig {
ram1Format: Format;
ram2Format: Format;
screenScale: ScreenScales;
speed: RunSpeed;
testSpeed: RunSpeed;
}
export interface ControlsState {
runningTest: boolean;
exitCode: number | undefined;
animate: boolean;
valid: boolean;
error?: CompilationError;
}
export interface VMFiles {
vm: string;
tst: string;
cmp: string;
out: string;
}
export type VmStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeVmStore>["reducers"];
payload?: unknown;
}>;
function reduceVMTest(
vmTest: VMTest,
dispatch: MutableRefObject<VmStoreDispatch>,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
showHighlight: boolean,
): VmSim {
const RAM = new ImmMemory(vmTest.vm.RAM, dispatch);
const Screen = new ImmMemory(vmTest.vm.Screen, dispatch);
const Keyboard = new MemoryKeyboard(new ImmMemory(vmTest.vm.RAM, dispatch));
const highlight = vmTest.vm.derivedLine();
let stack: VmFrame[] = [];
try {
stack = vmTest.vm.vmStack().reverse();
} catch (_e) {
dispatch.current({
action: "setError",
payload: new Error("Runtime error: Invalid stack"),
});
}
return {
Keyboard,
RAM,
Screen,
Stack: stack,
Prog: vmTest.vm.program,
Statics: [
...vmTest.vm.memory.map((_, v) => v, 16, 16 + vmTest.vm.getStaticCount()),
],
Temp: [...vmTest.vm.memory.map((_, v) => v, 5, 13)],
AddedSysInit: vmTest.vm.addedSysInit,
highlight,
showHighlight,
};
}
export function makeVmStore(
fs: FileSystem,
setStatus: Action<string | { message: string; severity?: StatusSeverity }>,
storage: Record<string, string>,
dispatch: MutableRefObject<VmStoreDispatch>,
) {
const parsed = unwrap(VM.parse(FIBONACCI));
let vm = unwrap(Vm.build(parsed.instructions));
let test = new VMTest({ doEcho: setStatus }).with(vm);
let useTest = false;
let animate = true;
let vmSource = "";
let showHighlight = true;
const reducers = {
setVm(state: VmPageState, vm: string) {
state.files.vm = vm;
},
setTst(state: VmPageState, { tst }: { tst: string }) {
state.files.tst = tst;
},
setCmp(state: VmPageState, { cmp }: { cmp: string }) {
state.files.cmp = cmp;
},
setExitCode(state: VmPageState, code: number | undefined) {
state.controls.exitCode = code;
},
setValid(state: VmPageState, valid: boolean) {
state.controls.valid = valid;
},
setShowHighlight(state: VmPageState, value: boolean) {
state.vm.showHighlight = value;
},
setError(state: VmPageState, error?: CompilationError) {
if (error) {
state.controls.valid = false;
setStatus({
message: error?.message,
severity: "ERROR",
});
} else {
state.controls.valid = true;
}
state.controls.error = error;
},
update(state: VmPageState) {
state.vm = reduceVMTest(test, dispatch, setStatus, showHighlight);
state.test.highlight = test.currentStep?.span;
},
setAnimate(state: VmPageState, value: boolean) {
state.controls.animate = value;
},
testStep(state: VmPageState) {
state.files.out = test.log();
},
testFinished(state: VmPageState) {
const passed = compare(state.files.cmp.trim(), state.files.out);
setStatus(
passed
? {
message: `Simulation successful: The output file is identical to the compare file`,
severity: "SUCCESS",
}
: {
message: `Simulation error: The output file differs from the compare file`,
severity: "ERROR",
},
);
},
setTitle(state: VmPageState, title: string) {
state.title = title;
},
updateConfig(state: VmPageState, config: Partial<VmPageConfig>) {
state.config = { ...state.config, ...config };
},
};
const initialState: VmPageState = {
vm: reduceVMTest(test, dispatch, setStatus, true),
controls: {
exitCode: undefined,
runningTest: false,
animate: true,
valid: true,
},
test: {
highlight: undefined,
},
files: {
vm: "",
tst: DEFAULT_TEST,
cmp: "",
out: "",
},
config: {
ram1Format: "dec",
ram2Format: "dec",
screenScale: 1,
speed: 2,
testSpeed: 2,
},
};
const actions = {
async load(path: string) {
const files: VmFile[] = [];
let title: string;
if ((await fs.stat(path)).isFile()) {
// single file
files.push({
name: assertExists(path.split("/").pop()).replace(".vm", ""),
content: await fs.readFile(path),
});
title = path.split("/").pop() ?? "";
} else {
// folder
for (const file of (await fs.scandir(path)).filter(
(entry) => entry.isFile() && entry.name.endsWith(".vm"),
)) {
files.push({
name: file.name.replace(".vm", ""),
content: await fs.readFile(`${path}/${file.name}`),
});
}
title = `${path.split("/").pop()} / *.vm`;
}
dispatch.current({ action: "setTitle", payload: title });
this.loadVm(files);
this.reset();
setStatus("");
},
setVm(content: string) {
showHighlight = false;
dispatch.current({
action: "setVm",
payload: content,
});
if (vmSource == content) {
return;
}
vmSource = content;
const parseResult = VM.parse(content);
if (isErr(parseResult)) {
dispatch.current({ action: "setError", payload: Err(parseResult) });
return false;
}
const instructions = unwrap(parseResult).instructions;
const buildResult = Vm.build(instructions);
return this.replaceVm(buildResult);
},
loadVm(files: VmFile[]) {
showHighlight = false;
const content = files.map((f) => f.content).join("\n");
dispatch.current({
action: "setVm",
payload: content,
});
if (vmSource == content) {
return;
}
vmSource = content;
const parsed = [];
let lineOffset = 0;
for (const file of files) {
const parseResult = VM.parse(file.content);
if (isErr(parseResult)) {
dispatch.current({ action: "setError", payload: Err(parseResult) });
return false;
}
const instructions = unwrap(parseResult).instructions;
for (const instruction of instructions) {
if (instruction.span?.line != undefined) {
instruction.span.line += lineOffset;
}
}
lineOffset += file.content.split("\n").length;
parsed.push({
name: file.name,
instructions,
});
}
const buildResult = Vm.buildFromFiles(parsed);
return this.replaceVm(buildResult);
},
replaceVm(buildResult: Result<Vm, CompilationError>) {
if (isErr(buildResult)) {
dispatch.current({ action: "setError", payload: Err(buildResult) });
return false;
}
dispatch.current({ action: "setError" });
// setStatus("Compiled VM code successfully");
vm = unwrap(buildResult);
test.vm = vm;
dispatch.current({ action: "update" });
return true;
},
loadTest(path: string, source: string) {
dispatch.current({ action: "setTst", payload: { tst: source } });
const tst = TST.parse(source);
if (isErr(tst)) {
dispatch.current({ action: "setValid", payload: false });
setStatus({
message: `Failed to parse test`,
severity: "ERROR",
});
return false;
}
dispatch.current({ action: "setValid", payload: true });
setStatus(`Parsed tst`);
vm.reset();
const maybeTest = VMTest.from(unwrap(tst), {
dir: path,
doLoad: async (path) => {
await this.load(path);
},
doEcho: setStatus,
compareTo: async (file) => {
const dir = path.split("/").slice(0, -1).join("/");
const cmp = await fs.readFile(`${dir}/${file}`);
dispatch.current({ action: "setCmp", payload: { cmp } });
},
});
if (isErr(maybeTest)) {
setStatus({
message: Err(maybeTest).message,
severity: "ERROR",
});
return false;
} else {
test = Ok(maybeTest).using(fs);
test.vm = vm;
dispatch.current({ action: "update" });
return true;
}
},
setAnimate(value: boolean) {
animate = value;
dispatch.current({ action: "setAnimate", payload: value });
},
async testStep() {
showHighlight = true;
let done = false;
try {
done = await test.step();
dispatch.current({ action: "testStep" });
if (done) {
dispatch.current({ action: "testFinished" });
}
if (animate) {
dispatch.current({ action: "update" });
}
return done;
} catch (e) {
setStatus({
message: `Runtime error: ${(e as Error).message}`,
severity: "ERROR",
});
dispatch.current({ action: "setError", payload: e });
return true;
}
},
setPaused(paused = true) {
vm.setPaused(paused);
},
step() {
showHighlight = true;
try {
let done = false;
const exitCode = vm.step();
if (exitCode !== undefined) {
done = true;
dispatch.current({ action: "setExitCode", payload: exitCode });
}
if (animate) {
dispatch.current({ action: "update" });
}
return done;
} catch (e) {
setStatus({
message: `Runtime error: ${(e as Error).message}`,
severity: "ERROR",
});
dispatch.current({ action: "setError", payload: e });
return true;
}
},
reset() {
showHighlight = true;
test.reset();
vm.reset();
dispatch.current({ action: "update" });
dispatch.current({ action: "setExitCode", payload: undefined });
dispatch.current({ action: "setValid", payload: true });
},
toggleUseTest() {
useTest = !useTest;
dispatch.current({ action: "update" });
},
initialize() {
dispatch.current({ action: "setTitle", payload: undefined });
this.setVm(FIBONACCI);
},
};
return { initialState, reducers, actions };
}
export function useVmPageStore() {
const { fs, setStatus, storage } = useContext(BaseContext);
const dispatch = useRef<VmStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeVmStore(fs, setStatus, storage, dispatch),
[fs, setStatus, storage, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch, actions };
}
+73
View File
@@ -0,0 +1,73 @@
import { rounded } from "@davidsouther/jiffies/lib/esm/dom/css/border.js";
import { TranslatorSymbol } from "./stores/asm.store";
export const Table = ({
values = [],
highlight = -1,
onClick,
}: {
values?: TranslatorSymbol[];
highlight?: number;
onClick?: (id: string, value: string) => void;
}) => {
return (
<div>
{values.map((entry, i) => (
<TableRow
key={i}
identifier={entry.name}
value={entry.value}
highlight={i === highlight}
onClick={() => onClick?.(entry.name, entry.value)}
/>
))}
</div>
);
};
const TableRow = ({
identifier,
value,
highlight = false,
onClick,
}: {
identifier: string;
value: string;
highlight?: boolean;
onClick?: () => void;
}) => {
return (
<div style={{ display: "flex", alignItems: "center" }} onClick={onClick}>
{identifier.length > 0 && (
<code
style={{
flex: "1",
...rounded("none"),
...(highlight
? { background: "var(--mark-background-color)" }
: {}),
whiteSpace: "pre",
padding: "3px",
}}
>
{identifier}
</code>
)}
{value.length > 0 && (
<code
style={{
flex: "1",
textAlign: "right",
padding: "3px",
...rounded("none"),
...(highlight
? { background: "var(--mark-background-color)" }
: {}),
}}
>
{value}
</code>
)}
</div>
);
};
+82
View File
@@ -0,0 +1,82 @@
import { Timer } from "@nand2tetris/simulator/timer.js";
import { useImmerReducer } from "./react.js";
export interface TimerStoreState {
steps: number;
speed: number;
running: boolean;
}
import { Dispatch, MutableRefObject, useMemo, useRef } from "react";
export type TimerStoreDispatch = Dispatch<{
action: keyof ReturnType<typeof makeTimerStore>["reducers"];
payload?: unknown;
}>;
const makeTimerStore = (
timer: Timer,
dispatch: MutableRefObject<TimerStoreDispatch>,
) => {
const initialState: TimerStoreState = {
running: timer.running,
speed: timer.speed,
steps: timer.steps,
};
const finishFrame = timer.finishFrame.bind(timer);
timer.finishFrame = function () {
finishFrame();
dispatch.current({ action: "update" });
};
const reducers = {
update(state: TimerStoreState) {
state.running = timer.running;
state.speed = timer.speed;
state.steps = timer.steps;
},
setSteps(state: TimerStoreState, steps: number) {
state.steps = steps;
timer.steps = steps;
},
setSpeed(state: TimerStoreState, speed: number) {
state.speed = speed;
timer.speed = speed;
},
};
const actions = {
frame() {
timer.frame();
},
start() {
timer.start();
dispatch.current({ action: "update" });
},
stop() {
timer.stop();
dispatch.current({ action: "update" });
},
reset() {
timer.reset();
dispatch.current({ action: "update" });
},
};
return { initialState, reducers, actions };
};
export function useTimer(timer: Timer) {
const dispatch = useRef<TimerStoreDispatch>(() => undefined);
const { initialState, reducers, actions } = useMemo(
() => makeTimerStore(timer, dispatch),
[timer, dispatch],
);
const [state, dispatcher] = useImmerReducer(reducers, initialState);
dispatch.current = dispatcher;
return { state, dispatch: dispatch.current, actions };
}
@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import VirtualScroll, { arrayAdapter } from "./virtual_scroll.js";
describe("<VirtualScroll />", () => {
it("initializes & renders", () => {
render(
<VirtualScroll<number>
settings={{ maxIndex: 3 }}
get={arrayAdapter([1, 2, 3])}
row={(i) => <div>{i}</div>}
rowKey={(i) => i}
/>,
);
const two = screen.getByText("2");
expect(two).toBeVisible();
});
});
@@ -0,0 +1,216 @@
/* eslint-disable @typescript-eslint/ban-types */
import {
Key,
ReactNode,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
} from "react";
export interface VirtualScrollSettings {
/**Minimum offset into the adapter. Default is 0. */
minIndex: number;
/** Maximum offset into the adapter. Default is Number.MAX_SAFE_INTEGER. */
maxIndex: number;
/** Initial index to start rendering from. Default is minIndex. */
startIndex: number;
/**
* Number of items to render in visible area. Default is entire range from
* minIndex to maxIndex.
*/
count: number;
/**
* Maximum number of items to render on either side of the visible area.
* Default is `count`.
*/
tolerance: number;
/** Height of each item, in pixels. Default is 20px. */
itemHeight: number;
}
export interface VirtualScrollDataAdapter<T> {
(offset: number, limit: number): Iterable<T>;
}
export function arrayAdapter<T>(data: T[]): VirtualScrollDataAdapter<T> {
return (offset, limit) => data.slice(offset, offset + limit);
}
export interface VirtualScrollProps<T, U extends ReactNode> {
settings?: Partial<VirtualScrollSettings>;
get: VirtualScrollDataAdapter<T>;
row: (t: T) => U;
rowKey: (t: T) => Key;
}
export function fillVirtualScrollSettings(
settings: Partial<VirtualScrollSettings>,
): VirtualScrollSettings {
const {
minIndex = 0,
maxIndex = Number.MAX_SAFE_INTEGER,
startIndex = 0,
itemHeight = 20,
count = Math.max(maxIndex - minIndex, 1),
tolerance = count,
} = settings;
return { minIndex, maxIndex, startIndex, itemHeight, count, tolerance };
}
export function initialState<T>(
settings: VirtualScrollSettings,
adapter: VirtualScrollDataAdapter<T>,
): VirtualScrollState<T> {
// From Denis Hilt, https://blog.logrocket.com/virtual-scrolling-core-principles-and-basic-implementation-in-react/
const { minIndex, maxIndex, startIndex, itemHeight, count, tolerance } =
settings;
const bufferedItems = count + 2 * tolerance;
const itemsAbove = Math.max(0, startIndex - tolerance - minIndex);
const viewportHeight = count * itemHeight;
const totalHeight = Math.max(maxIndex - minIndex, 1) * itemHeight;
const toleranceHeight = tolerance * itemHeight;
const bufferHeight = viewportHeight + 2 * toleranceHeight;
const topPaddingHeight = itemsAbove * itemHeight;
const bottomPaddingHeight = totalHeight - (topPaddingHeight + bufferHeight);
const state: VirtualScrollState<T> = {
scrollTop: 0,
settings,
viewportHeight,
totalHeight,
toleranceHeight,
bufferedItems,
topPaddingHeight,
bottomPaddingHeight,
data: [],
};
return {
...state,
...doScroll(topPaddingHeight + toleranceHeight, state, adapter),
};
}
export function getData<T>(
minIndex: number,
maxIndex: number,
offset: number,
limit: number,
get: VirtualScrollDataAdapter<T>,
): T[] {
const start = Math.max(0, minIndex, offset);
const end = Math.min(maxIndex, offset + limit - 1);
const data = get(start, end - start);
return [...data];
}
interface ScrollUpdate<T> {
scrollTop: number;
topPaddingHeight: number;
bottomPaddingHeight: number;
data: T[];
}
export function doScroll<T>(
scrollTop: number,
state: VirtualScrollState<T>,
get: VirtualScrollDataAdapter<T>,
): ScrollUpdate<T> {
const {
totalHeight,
toleranceHeight,
bufferedItems,
settings: { itemHeight, minIndex, maxIndex },
} = state;
const index =
minIndex + Math.floor((scrollTop - toleranceHeight) / itemHeight);
const data = getData(minIndex, maxIndex, index, bufferedItems, get);
const topPaddingHeight = Math.max((index - minIndex) * itemHeight, 0);
const bottomPaddingHeight = Math.max(
totalHeight - (topPaddingHeight + data.length * itemHeight),
0,
);
return { scrollTop, topPaddingHeight, bottomPaddingHeight, data };
}
interface VirtualScrollState<T> {
settings: VirtualScrollSettings;
scrollTop: number; // px
bufferedItems: number; // count
totalHeight: number; // px
viewportHeight: number; // px
topPaddingHeight: number; // px
bottomPaddingHeight: number; // px
toleranceHeight: number; // px
data: T[];
}
const scrollReducer =
<T extends {}>(get: VirtualScrollDataAdapter<T>) =>
(state: VirtualScrollState<T>, scrollTop: number) => ({
...state,
...doScroll(scrollTop, state, get),
});
export const VirtualScroll = <T extends {}, U extends ReactNode = ReactNode>(
props: VirtualScrollProps<T, U> & { className?: string },
) => {
const viewportRef = useRef<HTMLDivElement | null>(null);
const { settings, startState, reducer } = useMemo(() => {
const settings = fillVirtualScrollSettings(props.settings ?? {});
const startState = initialState<T>(settings, props.get);
const reducer = scrollReducer(props.get);
return { settings, reducer, startState };
}, [props.settings, props.get]);
const [state, dispatchScroll] = useReducer(reducer, startState);
useEffect(() => {
if (viewportRef.current !== null) {
dispatchScroll(viewportRef.current.scrollTop);
}
}, [settings, props.row]);
const initialScroll = useCallback(
(div: HTMLDivElement | null) => {
if (div) {
div.scrollTop = viewportRef.current
? viewportRef.current.scrollTop
: settings.startIndex * settings.itemHeight;
}
viewportRef.current = div;
},
[viewportRef, settings.startIndex, settings.itemHeight],
);
const rows = state.data.map((d) => (
<div key={props.rowKey(d)} style={{ height: `${settings.itemHeight}px` }}>
{props.row(d)}
</div>
));
return (
<div
ref={initialScroll}
style={{
height: `${state.viewportHeight}px`,
overflowY: "scroll",
overflowAnchor: "none",
}}
className={props.className ?? ""}
onScroll={(e) => dispatchScroll((e.target as HTMLDivElement).scrollTop)}
>
<div style={{ height: `${state.topPaddingHeight}px` }} />
{rows}
<div style={{ height: `${state.bottomPaddingHeight}px` }} />
</div>
);
};
export default VirtualScroll;