Web-Ide mit aufgenommen
This commit is contained in:
@@ -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][];
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
</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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user