Web-Ide mit aufgenommen

This commit is contained in:
Riwoldt
2026-04-09 14:14:56 +02:00
parent 64816c45cc
commit 15cfaf332d
489 changed files with 186891 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
web-ide
+107
View File
@@ -0,0 +1,107 @@
{
"name": "@nand2tetris/web",
"version": "0.0.0",
"private": true,
"description": "",
"author": "David Souther <davidsouther@gmail.com>",
"license": "ISC",
"homepage": "https://nand2tetris.github.io/web-ide",
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@davidsouther/jiffies": "^2.2.5",
"@lingui/cli": "^4.11.1",
"@lingui/macro": "^4.11.1",
"@lingui/react": "^4.11.1",
"@monaco-editor/react": "^4.6.0",
"@nand2tetris/components": "file:../components",
"@nand2tetris/projects": "file:../projects",
"@nand2tetris/simulator": "file:../simulator",
"@rollup/plugin-node-resolve": "^15.2.3",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/error-cause": "^1.0.4",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/vscode": "^1.89.0",
"@vscode/vsce": "^2.27.0",
"gh-pages": "^6.1.1",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"make-plural": "^7.4.0",
"ohm-js": "^17.1.0",
"raw-loader": "^4.0.2",
"raw.macro": "^0.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-ga4": "^2.1.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.23.1",
"react-scripts": "^5.0.1",
"remark-gfm": "^4.0.0",
"rxjs": "^7.8.1",
"sass": "^1.77.4",
"source-map-explorer": "^2.5.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"preanalyze": "npm run map-build",
"start": "react-scripts start",
"build": "cross-env GENERATE_SOURCEMAP=false react-scripts build",
"map-build": "react-scripts build",
"postbuild": "node ./scripts/predeploy.js",
"preserve-pwa": "npm run build ; ln -s build web-ide",
"serve-pwa": "python3 -m http.server",
"test": "react-scripts test",
"prebuild": "npm run extract && npm run lingui",
"extract": "lingui extract",
"lingui": "lingui compile --namespace es",
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
},
"browserslist": {
"production": [
">1%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"lingui": {
"locales": [
"en",
"en-PL"
],
"sourceLocale": "en",
"pseudoLocale": "en-PL",
"fallbackLocales": {
"en-PL": "en"
},
"catalogs": [
{
"path": "src/locales/{locale}/messages",
"include": [
"src",
"public"
]
}
],
"format": "po"
},
"jest": {
"moduleNameMapper": {
"^@nand2tetris/([^/]+)/(.*)": "<rootDir>/../node_modules/@nand2tetris/$1/build/$2",
"\\.css$": "identity-obj-proxy"
},
"transformIgnorePatterns": [
"node_modules/(?!@davidsouther)"
]
}
}
+1
View File
@@ -0,0 +1 @@
pico.css
+51
View File
@@ -0,0 +1,51 @@
<svg version="1.1" width="32" height="32" xmlns="http://www.w3.org/2000/svg">
<style>
:root {
--outline: black;
--block: red;
--gate: cyan;
}
@media (prefers-color-scheme: dark) {
:root {
--outline: white;
--block: orange;
--gate: green;
}
}
rect, path, circle {
stroke: var(--outline);
stroke-width: 1px;
stroke-linecap: square;
}
rect.tetris {
--size: 8px;
--offset: 1.5px + calc(10px - var(--size));
fill: var(--block);
width: var(--size);
height: var(--size);
x: calc(var(--x) * var(--size) + var(--offset) - 1px);
y: calc(var(--y) * var(--size) + var(--offset) + 1px);
}
.gate {
fill: var(--gate);
}
.letter {
fill: var(--outline);
}
</style>
<rect class="tetris" style="--x: 0; --y: 2"></rect>
<rect class="tetris" style="--x: 1; --y: 2"></rect>
<rect class="tetris" style="--x: 1; --y: 1"></rect>
<rect class="tetris" style="--x: 2; --y: 1"></rect>
<path class="gate" d="M2.5,1.5 h5 a 5 5 0 0 1 0,10 h-5 v-10"></path>
<circle class="gate" cx="14.5" cy="6.5" r="2"></circle>
<path class="letter" d="M26,10 h-6 v-1 l5,-4 v-1 l-1,-1 h-2.5 l-.5,1 h-1 v-1 l1,-1 h4 l1,1 v2 l-5,4 h5 v1"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>NAND2Tetris</title>
<meta name="description" content="NAND2Tetris Web IDE" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Cache-Control" content="no-cache" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link
rel="shortcut icon"
href="%PUBLIC_URL%/favicon.svg"
type="image/svg+xml"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo_192.png" />
<meta name="theme-color" content="rgb(16, 149, 193)" />
<link rel="stylesheet" href="%PUBLIC_URL%/root.css" />
<meta name="version" content="2025.49.0" />
</head>
<body class="flex">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex-1 flex"></div>
</body>
</html>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+42
View File
@@ -0,0 +1,42 @@
{
"short_name": "NAND2Tetris",
"name": "NAND2Tetris",
"icons": [
{
"src": "favicon.svg",
"sizes": "32x32",
"type": "image/svg+xml"
},
{
"src": "logo_192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo_512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"screenshots": [
{
"src": "user_guide/01_chip_empty.png",
"sizes": "2660x2076",
"type": "image/png",
"form_factor": "wide",
"label": "Empty Chip"
},
{
"src": "user_guide/01_chip_empty_mobile.png",
"sizes": "782x1692",
"type": "image/png",
"form_factor": "narrow",
"label": "Empty Chip (Mobile)"
}
],
"start_url": ".",
"id": "/web-ide/",
"display": "standalone",
"theme_color": "rgb(16, 149, 193)",
"background_color": "#ffffff"
}
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+28
View File
@@ -0,0 +1,28 @@
@import "./pico.min.css" layer(pico);
/* @import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Poppins:wght@400;700&display=swap"); */
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
/* src: url(https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPQ.ttf) format('truetype'); */
src: url(./jet_brains_mono.ttf) format("truetype");
}
@font-face {
font-family: "Poppins";
font-style: normal;
font-weight: 400;
font-display: swap;
/* src: url(https://fonts.gstatic.com/s/poppins/v21/pxiEyp8kv8JHgFVrFJA.ttf) format('truetype'); */
src: url(./poppins_400.ttf) format("truetype");
}
@font-face {
font-family: "Poppins";
font-style: normal;
font-weight: 700;
font-display: swap;
/* src: url(https://fonts.gstatic.com/s/poppins/v21/pxiByp8kv8JHgFVrLCz7V1s.ttf) format('truetype'); */
src: url(./poppins_700.ttf) format("truetype");
}
@layer pico component user;
Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
const fs = require("fs-extra");
const path = require("path");
const scriptDir = path.dirname(__filename);
const buildDir = path.resolve(scriptDir, "..", "build");
fs.ensureDirSync(buildDir);
process.chdir(buildDir);
const folders = [
"chip",
"cpu",
"asm",
"vm",
"compiler",
"bitmap",
"guide",
"util",
"about",
];
for (const folder of folders) {
fs.ensureDirSync(folder);
fs.copyFileSync("index.html", path.join(folder, "index.html"));
}
console.log("Predeploy tasks completed.");
+1
View File
@@ -0,0 +1 @@
locales
+93
View File
@@ -0,0 +1,93 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { useDialog } from "@nand2tetris/components/dialog.js";
import { createContext, useCallback, useState } from "react";
import { useFilePicker } from "./shell/file_select";
import { useTracking } from "./tracking";
export type Theme = "light" | "dark" | "system";
export function useMonaco() {
const canUseMonaco = true;
const [wantsMonaco, setWantsMonaco] = useState(canUseMonaco);
const toggleMonaco = useCallback(
(pleaseUseMonaco: boolean) => {
if (canUseMonaco && pleaseUseMonaco) {
setWantsMonaco(true);
} else {
setWantsMonaco(false);
}
},
[canUseMonaco],
);
return {
canUse: canUseMonaco,
wants: wantsMonaco,
toggle: toggleMonaco,
};
}
export function useAppContext(_fs: FileSystem = new FileSystem()) {
const [theme, setTheme] = useState<Theme>("system");
return {
monaco: useMonaco(),
settings: useDialog(),
filePicker: useFilePicker(),
tracking: useTracking(),
theme,
setTheme,
};
}
export const AppContext = createContext<ReturnType<typeof useAppContext>>({
monaco: {
canUse: true,
wants: true,
toggle() {
return undefined;
},
},
filePicker: {
close() {
return undefined;
},
open() {
return undefined;
},
select(options: FilePickerOptions) {
return Promise.reject("");
},
isOpen: false,
suffix: undefined,
} as ReturnType<typeof useFilePicker>,
settings: {
close() {
return undefined;
},
open() {
return undefined;
},
isOpen: false,
},
tracking: {
canTrack: false,
haveAsked: false,
accept() {
return undefined;
},
reject() {
return undefined;
},
trackEvent() {
return undefined;
},
trackPage() {
return undefined;
},
},
theme: "system",
setTheme() {
return undefined;
},
});
+106
View File
@@ -0,0 +1,106 @@
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import {
BaseContext,
useBaseContext,
} from "@nand2tetris/components/stores/base.context.js";
import { en } from "make-plural/plurals";
import { Suspense, useEffect, useRef, useState } from "react";
import { Route, BrowserRouter as Router, Routes } from "react-router-dom";
import { AppContext, useAppContext } from "./App.context";
import { registerLanguages } from "./languages/loader";
import { messages, plMessages } from "./locales";
import { FilePicker } from "./shell/file_select";
import Footer from "./shell/footer";
import Header from "./shell/header";
import { Settings } from "./shell/settings";
import { Tooltip } from "./shell/Tooltip";
import urls from "./urls";
import { ErrorBoundary, RenderError } from "./ErrorBoundary";
import { PageContextProvider } from "./Page.context";
import { Redirect } from "./pages/redirect";
import "./pico/flex.scss";
import "./pico/pico.scss";
import { TrackingBanner } from "./tracking";
import { updateVersion } from "./versions";
i18n.load("en", messages.messages);
i18n.load("en-PL", plMessages.messages);
i18n.loadLocaleData({
en: { plurals: en },
"en-US": { plurals: en },
"en-PL": { plurals: en },
});
i18n.activate(navigator.language);
type STATE = "none" | "initializing" | "initialized";
function App() {
const baseContext = useBaseContext();
const appContext = useAppContext();
const state = useRef<STATE>("none");
const [initialized, setInitialized] = useState(false);
const fs = baseContext.fs;
useEffect(() => {
registerLanguages();
}, []);
useEffect(() => {
if (state.current != "none") return;
state.current = "initializing";
Promise.resolve().then(async () => {
await updateVersion(fs);
state.current = "initialized";
setInitialized(true);
});
}, [fs, state]);
useEffect(() => {
(document.children[0] as HTMLHtmlElement).dataset.theme =
appContext.theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: appContext.theme;
}, [appContext.theme]);
return (
<I18nProvider i18n={i18n}>
<BaseContext.Provider value={baseContext}>
<AppContext.Provider value={appContext}>
{initialized ? (
<PageContextProvider>
<Settings />
<FilePicker />
<Router basename={process.env.PUBLIC_URL}>
<Header />
<main className="flex flex-1">
<ErrorBoundary fallback={RenderError}>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Redirect />} />
{Object.values(urls).map(({ href, target }) => (
<Route key={href} path={href} element={target} />
))}
</Routes>
</Suspense>
</ErrorBoundary>
</main>
<Footer />
<TrackingBanner />
</Router>
<Tooltip />
</PageContextProvider>
) : (
<div>Initializing...</div>
)}
</AppContext.Provider>
</BaseContext.Provider>
</I18nProvider>
);
}
export default App;
+60
View File
@@ -0,0 +1,60 @@
import { Component, PropsWithChildren, ReactElement } from "react";
type ErrorBoundaryProps = PropsWithChildren & {
fallback: (_: { error?: Error }) => ReactElement;
};
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
{ hasError: boolean; error?: Error }
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: unknown) {
// Update state so the next render will show the fallback UI.
return {
hasError: true,
error,
};
}
override render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return this.props.fallback({
error: this.state.error,
});
}
return this.props.children;
}
}
export const RenderError = ({ error }: { error?: Error }) =>
error ? (
<div>
<p>
<b>{error.name ?? "Error"}:</b> {error.message}
</p>
{error.stack && (
<pre>
<code>{error.stack}</code>
</pre>
)}
{error.cause ? (
<>
<div>
<em>Caused by</em>
</div>
<RenderError error={error.cause as Error} />
</>
) : (
<></>
)}
</div>
) : (
<p>Unknown Error</p>
);
+89
View File
@@ -0,0 +1,89 @@
import { useAsmPageStore } from "@nand2tetris/components/stores/asm.store";
import { BaseContext } from "@nand2tetris/components/stores/base.context";
import { useChipPageStore } from "@nand2tetris/components/stores/chip.store";
import { useCompilerPageStore } from "@nand2tetris/components/stores/compiler.store";
import { useCpuPageStore } from "@nand2tetris/components/stores/cpu.store";
import { useVmPageStore } from "@nand2tetris/components/stores/vm.store";
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
export function usePageContext() {
const { fs } = useContext(BaseContext);
const [title, setTitle] = useState<string>();
const [tool, setTool] = useState<string>();
const chip = useChipPageStore();
const cpu = useCpuPageStore();
const asm = useAsmPageStore();
const vm = useVmPageStore();
const compiler = useCompilerPageStore();
useEffect(() => {
chip.actions.initialize();
}, [chip.actions, fs]);
useEffect(() => {
vm.actions.initialize();
}, [vm.actions]);
useEffect(() => {
switch (tool) {
case "chip":
setTitle(chip.state.title);
break;
case "cpu":
setTitle(cpu.state.title);
break;
case "asm":
setTitle(asm.state.title);
break;
case "vm":
setTitle(vm.state.title);
break;
case "compiler":
setTitle(compiler.state.title);
break;
default:
setTitle(undefined);
break;
}
}, [
tool,
chip.state.title,
cpu.state.title,
asm.state.title,
vm.state.title,
compiler.state.title,
]);
return {
setTool,
title,
stores: {
chip,
cpu,
asm,
vm,
compiler,
},
};
}
export const PageContext = createContext<ReturnType<typeof usePageContext>>(
{} as ReturnType<typeof usePageContext>,
);
export function PageContextProvider(props: { children: ReactNode }) {
const context = usePageContext();
return (
<PageContext.Provider value={context}>
{props.children}
</PageContext.Provider>
);
}
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
import ReactDOM from "react-dom/client";
import * as serviceWorkerRegistration from "./serviceWorkerRegistration";
import "./pico/pico.scss";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://cra.link/PWA
serviceWorkerRegistration.register();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+13
View File
@@ -0,0 +1,13 @@
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import { base } from "./base";
export const AsmLanguage: monaco.languages.IMonarchLanguage = {
tokenizer: {
root: [
[/(@)(.+)/, ["operator", "keyword"]],
[/(\()(.+)(\))/, ["operator", "keyword", "operator"]],
{ include: "@whitespace" },
],
...base.tokenizer,
},
};
+23
View File
@@ -0,0 +1,23 @@
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
export const base: monaco.languages.IMonarchLanguage = {
tokenizer: {
comment: [
[/[^/*]+/, "comment"],
[/\/\*/, "comment", "@push"], // nested comment
["\\*/", "comment", "@pop"],
[/[/*]/, "comment"],
],
string: [
[/[^\\"]+/, "string"],
[/\\./, "string.escape.invalid"],
[/"/, { token: "string.quote", bracket: "@close", next: "@pop" }],
],
whitespace: [
[/[ \t\r\n]+/, "white"],
[/\/\*/, "comment", "@comment"],
[/\/\/.*$/, "comment"],
],
},
};
+19
View File
@@ -0,0 +1,19 @@
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
export const CmpLanguage: monaco.languages.IMonarchLanguage = {
tokenizer: {
root: [
// identifiers and keywords
[/[a-z_$][\w$]*/, "identifier"],
// whitespace
[/[ \t\r\n]+/, "white"],
// numbers
[/\d+\+?/, "number"],
// delimiter: after number because of .\d floats
[/[|]/, "delimiter"],
],
},
};
+170
View File
@@ -0,0 +1,170 @@
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import { base } from "./base";
export const HdlLanguage: monaco.languages.IMonarchLanguage = {
defaultToken: "invalid",
keywords: ["CHIP", "CPU", "IN", "OUT", "PARTS", "BUILTIN", "CLOCKED"],
chips: [
"Nand",
"Nand16",
"Not",
"Not16",
"And",
"And16",
"Or",
"Or16",
"Or8Way",
"XOr",
"XOr16",
"Xor",
"Xor16",
"Mux",
"Mux16",
"Mux4Way16",
"Mux8Way16",
"DMux",
"DMux4Way",
"DMux8Way",
"HalfAdder",
"FullAdder",
"Add16",
"Inc16",
"ALU",
"ALUNoStat",
"DFF",
"Bit",
"Register",
"ARegister",
"DRegister",
"PC",
"RAM8",
"RAM64",
"RAM512",
"RAM4K",
"RAM16K",
"ROM32K",
"Screen",
"Keyboard",
"CPU",
"Computer",
"Memory",
"ARegister",
"DRegister",
],
operators: ["="],
// we include these common regular expressions
symbols: /[=]+/,
// C# style strings
escapes:
/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
// The main tokenizer for our languages
tokenizer: {
root: [
// identifiers and keywords
[
/[a-zA-Z][a-zA-Z0-9]*/,
{
cases: {
"@keywords": "keyword",
"@chips": "keyword.chip",
"@default": "identifier",
},
},
],
// whitespace
{ include: "@whitespace" },
// delimiters and operators
[/[{}()[\]]/, "@brackets"],
[/[<>](?!@symbols)/, "@brackets"],
[/@symbols/, { cases: { "@operators": "operator", "@default": "" } }],
// @ annotations.
// As an example, we emit a debugging log message on these tokens.
// Note: message are supressed during the first load -- change some lines to see them.
[
/@\s*[a-zA-Z_$][\w$]*/,
{ token: "annotation", log: "annotation token: $0" },
],
// numbers
[/0[xX][0-9a-fA-F]+/, "number.hex"],
[/\d+(..\d+)?/, "number"],
// delimiter: after number because of .\d floats
[/[;:,.]/, "delimiter"],
// strings
[/"([^"\\]|\\.)*$/, "string.invalid"], // non-teminated string
[/"/, { token: "string.quote", bracket: "@open", next: "@string" }],
// characters
[/'[^\\']'/, "string"],
[/(')(@escapes)(')/, ["string", "string.escape", "string"]],
[/@escapes/, "string.escape"],
[/'/, "string.invalid"],
],
...base.tokenizer,
},
};
const HdlSignatures = {
Add16: "Add16(a= , b= , out= );",
ALU: "ALU(x= , y= , zx= , nx= , zy= , ny= , f= , no= , out= , zr= , ng= );",
And: "And(a= , b= , out= );",
And16: "And16(a= , b= , out= );",
ARegister: "ARegister(in= , load= , out= );",
Bit: "Bit(in= , load= , out= );",
CPU: "CPU(inM= , instruction= , reset= , outM= , writeM= , addressM= , pc= );",
DFF: "DFF(in= , out= );",
DMux: "DMux(in= , sel= , a= , b= );",
DMux4Way: "DMux4Way(in= , sel= , a= , b= , c= , d= );",
DMux8Way: "DMux8Way(in= , sel= , a= , b= , c= , d= , e= , f= , g= , h= );",
DRegister: "DRegister(in= , load= , out= );",
HalfAdder: "HalfAdder(a= , b= , sum= , carry= );",
FullAdder: "FullAdder(a= , b= , c= , sum= , carry= );",
Inc16: "Inc16(in= , out= );",
Keyboard: "Keyboard(out= );",
Memory: "Memory(in= , load= , address= , out= );",
Mux: "Mux(a= , b= , sel= , out= );",
Mux16: "Mux16(a= , b= , sel= , out= );",
Mux4Way16: "Mux4Way16(a= , b= , c= , d= , sel= , out= );",
Mux8Way16: "Mux8Way16(a= , b= , c= , d= , e= , f= , g= , h= , sel= , out= );",
Nand: "Nand(a= , b= , out= );",
Not16: "Not16(in= , out= );",
Not: "Not(in= , out= );",
Or: "Or(a= , b= , out= );",
Or8Way: "Or8Way(in= , out= );",
Or16: "Or16(a= , b= , out= );",
PC: "PC(in= , load= , inc= , reset= , out= );",
RAM8: "RAM8(in= , load= , address= , out= );",
RAM64: "RAM64(in= , load= , address= , out= );",
RAM512: "RAM512(in= , load= , address= , out= );",
RAM4K: "RAM4K(in= , load= , address= , out= );",
RAM16K: "RAM16K(in= , load= , address= , out= );",
Register: "Register(in= , load= , out= );",
ROM32K: "ROM32K(address= , out= );",
Screen: "Screen(in= , load= , address= , out= );",
Xor: "Xor(a= , b= , out= );",
};
export const HdlSnippets = {
provideCompletionItems: () => {
return {
suggestions: Object.entries(HdlSignatures).map(([name, signature]) => ({
label: name,
// kind: languages.CompletionItemKind.Function,
kind: 1,
insertText: signature,
})),
};
},
};
+45
View File
@@ -0,0 +1,45 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import { base } from "./base";
export const JackLanguage: monaco.languages.IMonarchLanguage = {
keywords: [
"class",
"int",
"char",
"boolean",
"void",
"let",
"function",
"method",
"constructor",
"var",
"if",
"do",
"while",
"else",
"return",
"true",
"false",
"null",
"this",
"field",
"static",
],
tokenizer: {
root: [
[
/[a-zA-Z_][a-zA-Z0-9_]*/,
{
cases: {
"@keywords": "keyword",
"@default": "identifier",
},
},
],
[/\d+/, "number"],
[/"[^"\n]*"/, "string"],
{ include: "@whitespace" },
],
...base.tokenizer,
},
};
+37
View File
@@ -0,0 +1,37 @@
import { AsmLanguage } from "./asm";
import { CmpLanguage } from "./cmp";
import { JackLanguage } from "./jack";
import { HdlLanguage, HdlSnippets } from "./hdl";
import { TstLanguage } from "./tst";
import { VmLanguage } from "./vm";
const LANGUAGES = {
hdl: HdlLanguage,
cmp: CmpLanguage,
tst: TstLanguage,
vm: VmLanguage,
asm: AsmLanguage,
jack: JackLanguage,
};
const SNIPPETS = {
hdl: HdlSnippets,
};
let lock = false;
export async function registerLanguages() {
if (lock) return;
lock = true;
lock = true;
const { loader } = await import("@monaco-editor/react");
const { languages } = await loader.init();
for (const [id, language] of Object.entries(LANGUAGES)) {
languages.register({ id });
languages.setMonarchTokensProvider(id, language);
if (SNIPPETS[id]) {
languages.registerCompletionItemProvider(id, SNIPPETS[id]);
}
}
lock = false;
}
+72
View File
@@ -0,0 +1,72 @@
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
export const TstLanguage: monaco.languages.IMonarchLanguage = {
defaultToken: "invalid",
keywords: [
"output-list",
"compare-to",
"output-file",
"set",
"eval",
"output",
"echo",
"clear-echo",
"repeat",
"while",
"load",
"resetRam",
],
// The main tokenizer for our languages
tokenizer: {
root: [
// Output Formats
[/%[BDSX]\d+\.\d+\.\d+/, "keyword"],
// identifiers and keywords
[/ROM32K/, "keyword"],
[
/[a-zA-Z-]+/,
{ cases: { "@keywords": "keyword", "@default": "identifier" } },
],
// numbers
[/%X[0-9a-fA-F]+/, "number.hex"],
[/(%D)?\d+/, "number"],
[/%B[01]+/, "number"],
// whitespace
{ include: "@whitespace" },
[/[{}]/, "@bracket"],
[/<>/, "operator"],
[/[[\].]/, "operator"],
// strings
[/"([^"\\]|\\.)*$/, "string.invalid"], // non-teminated string
[/"/, { token: "string.quote", bracket: "@open", next: "@string" }],
// delimiter: after number because of .\d floats
[/[;:!,]/, "delimiter"],
],
comment: [
[/[^/*]+/, "comment"],
[/\/\*/, "comment", "@push"], // nested comment
["\\*/", "comment", "@pop"],
[/[/*]/, "comment"],
],
whitespace: [
[/[ \t\r\n]+/, "white"],
[/\/\*/, "comment", "@comment"],
[/\/\/.*$/, "comment"],
],
string: [
[/[^\\"]+/, "string"],
[/"/, { token: "string.quote", bracket: "@close", next: "@pop" }],
],
},
};
+54
View File
@@ -0,0 +1,54 @@
import type * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import { base } from "./base";
export const VmLanguage: monaco.languages.IMonarchLanguage = {
keywords: [
"push",
"pop",
"add",
"sub",
"neg",
"lt",
"gt",
"eq",
"and",
"or",
"not",
"function",
"call",
"return",
"label",
"goto",
"if-goto",
"argument",
"local",
"static",
"constant",
"this",
"that",
"pointer",
"temp",
],
tokenizer: {
root: [
[/if-goto/, "keyword"], // next rule doesn't catch this because of the hyphen
[
/[_a-zA-Z][_a-zA-Z0-9.$]*/,
{
cases: {
"@keywords": "keyword",
"@default": "identifier",
},
},
],
[/\d+/, "number"],
// whitespace
{ include: "@whitespace" },
],
...base.tokenizer,
},
};
+5
View File
@@ -0,0 +1,5 @@
import * as _messages from "./locales/en/messages.mjs";
import * as _messagesPl from "./locales/en-PL/messages.mjs";
export const messages = _messages;
export const plMessages = _messagesPl;
+37
View File
@@ -0,0 +1,37 @@
**Nand to Tetris IDE Online** is a web-based, integrated, set of tools that supports the learning and
teaching of Nand to Tetris courses. It includes the following tools:
**Hardware simulator:** Used to create chips. Features a built-in HDL editor and all the necessary
services for simulating and testing chips. Relevant Nand to Tetris projects: 1, 2, 3, 5.
**CPU Emulator:** Emulates the Hack computer (CPU, RAM, ROM, Screen, Keyboard). Allows
loading the computer with machine language code stored in a file, or entering machine language
code directly into the instruction memory. Features a built-in assembler which is invoked
automatically if the loaded/entered code is written in the Hack assembly language. Relevant
projects: 4, 7, 8, 9.
**Assembler:** Used to translate programs written in the Hack assembly language into code written in
Hack binary code. Relevant projects: 4, 6.
**Bitmap editor:** Used for designing visual elements (like sprites) and generating either the Hack or
the Jack code that realizes them in memory. Can be used to help develop computer games and, in
general, programs that use graphics and animation. Relevant project: 9.
**VM emulator:** Used for two purposes: (i) Experimenting with VM commands and VM code
segments, and learning how they are realized on the host RAM, and (ii) Executing compiled Jack
programs. Includes a builtin implementation of the Jack OS. Relevant projects: 7, 8, 9, 11, 12
**Jack compiler:** Used for writing Jack programs and translating them into VM code. The latter can
then be loaded into, and executed by, the VM emulator. Relevant projects: 9, 12
---
**Team:** This online IDE is an open source, freely available, and ongoing project. The projects
architect is David Souther, and the developers are David Souther and Neta London. The bitmap
editor was written by Eric Umble. Nand to Tetris, the Hack computer, and related languages were
designed by Noam Nisan and Shimon Schocken.
**The desktop Nand2Tetris Software Suite:** This venerable working horse, which operated
faithfully for many years supporting 250,000+ users on every possible PC/OS configuration, was
developed by Yaron Ukrainintz, Nir Rozen, and Yannai Gonczarowski. The first bitmap editor was
developed by Golan Parashi.
+56
View File
@@ -0,0 +1,56 @@
# NAND2Tetris Web User Guide
This web application implements the [NAND2Tetris](https://nand2tetris.org) software suite, including an hdl chip simulator, a hack CPU, and a Hack VM.
Students are able to paste their implementations of the various chips and programs
## Chip Simulator
![Empty Chip](%PUBLIC_URL%/user_guide/01_chip_empty.png){:height="256px" width="512px"}
The chip page has five panels, as well as a project selction bar.
In the top left, users can input the text of their HDL chip.
The top center panel displays the chip's input pins, and the bottom center the output pins.
The bottom left panel has any internal pins in the chip.
The right panel includes the test program, the expected comparison output, and after executing the test, the test output and a diff of failed lines.
---
![Simple Not Chip](%PUBLIC_URL%/user_guide/02_chip_simple_nand.png)
The project selection bar loads the chip HDL outlines, tests, and cmp files from the book.
After choosing the "Not" chip, the input and output pins update.
After implementing the chip and clicking "Compile", clicking the input pin buttons will toggle their voltage states.
The output pins will update with the simulated state from the hdl spec.
---
![Complex Chip](%PUBLIC_URL%/user_guide/03_chip_complex.png)
Loading a chip with a 16-bit bus changes the pin panels to show a binary representation of the chip.
---
![Implemented Complex Chip](%PUBLIC_URL%/user_guide/04_chip_complex_implemented.png)
Clicking an input bus will increment that bus by one.
---
![Complex Chip Pins](%PUBLIC_URL%/user_guide/05_chip_complex_failed_test.png)
Clicking "Execute" will run the test against the chip implementation.
Any failing tests will be put in the bottom of the panel, with a diff between the expected and actual output.
You may need to scroll to see all failed lines.
---
![Passed Chip Test](%PUBLIC_URL%/user_guide/06_chip_complex_passed_test.png)
All tests pass!
---
![Settings](%PUBLIC_URL%/user_guide/07_settings.png)
Clicking `Save` in the HDL panel will store the HDL chip to user's local storage.
In settings, clicking `Files: Reset` will restore the files to the book' projects.
+26
View File
@@ -0,0 +1,26 @@
import raw from "raw.macro";
import Markdown from "../shell/markdown";
const VERSION =
document.querySelector("meta[name=version]")?.getAttribute("content") ??
"unknown";
const useVersion = () => {
return VERSION;
};
export const About = () => {
const version = useVersion();
return (
<div style={{ overflowY: "scroll" }}>
<div className="container">
<Markdown>{raw("./ABOUT.md")}</Markdown>
<p>
<b>Version</b> <code>{version}</code>
</p>
</div>
</div>
);
};
export default About;
+33
View File
@@ -0,0 +1,33 @@
@use "../pico/button-group.scss";
@use "../shell/tab.scss";
.AsmPage {
height: 100%;
margin: 0px;
gap: 0;
grid-template-areas: "source result compare" "source sym compare";
grid-template-columns: 2.5fr 1fr 1fr;
grid-template-rows: 2fr 1fr;
&.hide-sym {
grid-template-rows: 1fr auto;
}
.source {
grid-area: source;
}
.result {
grid-area: result;
}
.compare {
grid-area: compare;
}
.sym {
grid-area: sym;
}
}
+341
View File
@@ -0,0 +1,341 @@
import { Trans } from "@lingui/macro";
import { Runbar } from "@nand2tetris/components/runbar";
import { BaseContext } from "@nand2tetris/components/stores/base.context";
import { Table } from "@nand2tetris/components/table";
import { ASM } from "@nand2tetris/simulator/languages/asm.js";
import { loadHack } from "@nand2tetris/simulator/loader.js";
import { Timer } from "@nand2tetris/simulator/timer";
import { useContext, useEffect, useRef, useState } from "react";
import { Editor } from "../shell/editor";
import { Panel } from "../shell/panel";
import { LOADING } from "@nand2tetris/components/messages.js";
import { ROM } from "@nand2tetris/simulator/cpu/memory";
import { Link } from "react-router-dom";
import { isPath } from "src/shell/file_select";
import { AppContext } from "../App.context";
import { PageContext } from "../Page.context";
import URLs from "../urls";
import "./asm.scss";
export const Asm = () => {
const { filePicker } = useContext(AppContext);
const { stores, setTool } = useContext(PageContext);
const { state, actions, dispatch } = stores.asm;
const { fs, localFsRoot } = useContext(BaseContext);
const sourceCursorPos = useRef(0);
const resultCursorPos = useRef(0);
const runner = useRef<Timer>();
const [runnerAssigned, setRunnerAssigned] = useState(false);
const [showSymbolTable, setShowSymbolTable] = useState(true);
useEffect(() => {
setTool("asm");
}, [setTool]);
useEffect(() => {
runner.current = new (class AsmRunner extends Timer {
override async tick(): Promise<boolean> {
sourceCursorPos.current = 0;
resultCursorPos.current = 0;
return await actions.step();
}
override reset(): void {
actions.reset();
}
override toggle(): void {
return;
}
})();
setRunnerAssigned(true);
return () => {
runner.current?.stop();
};
}, [actions, dispatch]);
const fileDownloadRef = useRef<HTMLAnchorElement>(null);
const redirectRef = useRef<HTMLAnchorElement>(null);
const loadAsm = async () => {
const path = await filePicker.select({ suffix: ".asm" });
setStatus(LOADING);
requestAnimationFrame(async () => {
await actions.loadAsm(path.path);
setStatus("");
dispatch.current({
action: "setTitle",
payload: path.path.split("/").pop() ?? "",
});
});
};
const loadCompare = async () => {
const filesRef = await filePicker.selectAllowLocal({ suffix: "hack" });
if (isPath(filesRef)) {
const cmp = await fs.readFile(filesRef.path);
dispatch.current({
action: "setCmp",
payload: { cmp, name: filesRef.path.split("/").pop() },
});
} else {
const file = Array.isArray(filesRef) ? filesRef[0] : filesRef;
dispatch.current({
action: "setCmp",
payload: { cmp: file.content, name: file.name },
});
}
};
const { setStatus } = useContext(BaseContext);
const downloadAsm = () =>
download(state.asm, state.path?.split("/").pop() ?? "source.asm");
const downloadHack = () =>
download(
state.result,
state.path?.split("/").pop()?.replace(".asm", ".hack") ?? "result.hack",
);
const download = (content: string, name: string) => {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
if (!fileDownloadRef.current) {
return;
}
fileDownloadRef.current.href = url;
fileDownloadRef.current.download = name;
fileDownloadRef.current.click();
URL.revokeObjectURL(url);
};
const compare = () => {
actions.compare();
};
const onSpeedChange = (speed: number) => {
dispatch.current({ action: "updateConfig", payload: { speed } });
actions.setAnimate(speed <= 2);
};
const loadToCpu = async () => {
const bytes = await loadHack(state.result);
const rom = new ROM();
await rom.loadBytes(bytes);
stores.cpu.actions.replaceROM(rom);
if (state.path) {
stores.cpu.dispatch.current({
action: "setTitle",
payload: state.path.split("/").pop() ?? "",
});
stores.cpu.actions.setPath(state.path);
stores.cpu.actions.reset();
}
redirectRef.current?.click();
};
return (
<div className={`AsmPage grid ${showSymbolTable ? "" : "hide-sym"}`}>
<Panel
className="source"
isEditorPanel={true}
header={
<>
<div>
<Trans>Source</Trans>
</div>
<div className="flex-1">
{runnerAssigned && runner.current && (
<Runbar
runner={runner.current}
disabled={state.error != undefined}
prefix={
<button
className="flex-0"
onClick={loadAsm}
data-tooltip="Load file"
data-placement="bottom"
>
📂
</button>
}
overrideTooltips={{ step: "Translate", run: "Translate all" }}
speed={state.config.speed}
onSpeedChange={onSpeedChange}
/>
)}
</div>
{!localFsRoot && (
<fieldset role="group">
<button
data-tooltip="Download"
data-placement="left"
onClick={downloadAsm}
>
</button>
</fieldset>
)}
</>
}
>
<Editor
value={state.asm}
path={state.path??"tmp.asm"}
error={state.error}
alwaysRecenter={false}
onChange={(source: string) => {
actions.setAsm(source);
}}
onCursorPositionChange={(index) => {
if (index == sourceCursorPos.current) {
return;
}
sourceCursorPos.current = index;
// wait some time to allow the user to release the mouse before updating the highligh
// (otherwise we auto-scroll while the mouse is pressed and cause unwanted text selection)
setTimeout(() => {
actions.updateHighlight(index, true);
}, 100);
}}
grammar={ASM.parser}
language={"asm"}
highlight={state.translating ? state.sourceHighlight : undefined}
lineNumberTransform={(n) => {
if (!state.translating) {
return "";
}
const num = state.lineNumbers[n] as number | undefined;
return (num === undefined ? "" : num).toString();
}}
/>
</Panel>
<Panel
className="result"
isEditorPanel={true}
header={
<>
<div>
<Trans>Binary Code</Trans>
</div>
<div>
<a ref={fileDownloadRef} style={{ display: "none" }} />
<Link
ref={redirectRef}
style={{ display: "none" }}
to={URLs["cpu"].href}
/>
<fieldset role="group">
<button
data-tooltip="Load in CPU Emulator"
data-placement="left"
onClick={loadToCpu}
>
</button>
{!localFsRoot && (
<button
data-tooltip="Download"
data-placement="left"
onClick={downloadHack}
>
</button>
)}
</fieldset>
</div>
</>
}
>
<Editor
value={state.result}
path={(state.path??"tmp.asm").replace(/\.asm$/, '.hack')}
highlight={state.resultHighlight}
disabled={true}
onChange={function (_source: string): void {
return;
}}
onCursorPositionChange={(index) => {
if (index == resultCursorPos.current) {
return;
}
resultCursorPos.current = index;
actions.updateHighlight(index, false);
}}
grammar={undefined}
language={""}
alwaysRecenter={false}
lineNumberTransform={(n) => (n - 1).toString()}
/>
</Panel>
<Panel
className="sym"
isEditorPanel={true}
header={
<>
<div className="flex-1">
<Trans>Symbol Table</Trans>
</div>
<input
type="checkbox"
role="switch"
checked={showSymbolTable}
onChange={() => setShowSymbolTable(!showSymbolTable)}
/>
</>
}
>
{state.translating && showSymbolTable && (
<Table values={state.symbols} />
)}
</Panel>
<Panel
className="compare"
header={
<>
<div>
<Trans>Compare Code</Trans>
{state.compareName && `: ${state.compareName}`}
</div>
<fieldset role="group">
<button onClick={loadCompare}>📂</button>
</fieldset>
<div className="flex-1" />
<fieldset role="group">
<button onClick={compare}>Compare</button>
</fieldset>
</>
}
>
<Editor
value={state.compare}
path={(state.path??"tmp.asm").replace(/\.asm$/, '.cmp')}
highlight={state.translating ? state.resultHighlight : undefined}
highlightType={state.compareError ? "error" : "highlight"}
alwaysRecenter={false}
onChange={function (_source: string): void {
return;
}}
disabled={true}
onCursorPositionChange={(index) => {
if (index == resultCursorPos.current) {
return;
}
resultCursorPos.current = index;
actions.updateHighlight(index, false);
}}
language={""}
lineNumberTransform={(n) => (n - 1).toString()}
/>
</Panel>
</div>
);
};
export default Asm;
+237
View File
@@ -0,0 +1,237 @@
@use "./page.scss";
.BitmapPage {
grid-template-areas: "canvas code" "controls controls";
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
.canvas-panel {
grid-area: canvas;
overflow: auto;
}
.code-panel {
grid-area: code;
min-width: 400px;
}
.controls-panel {
grid-area: controls;
}
// Canvas size header controls
.canvas-size-controls {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: var(--spacing);
align-items: center;
padding: var(--spacing);
input[type="text"] {
width: 3rem;
margin: 0;
padding: var(--spacing);
}
label {
margin: 0;
font-weight: normal;
font-size: 0.85rem;
}
button {
padding: var(--spacing) calc(var(--spacing) * 2);
font-size: 0.85rem;
}
}
// Tab button group for Jack/Hack
.tab-container {
display: flex;
gap: 0;
.tab-button {
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 4);
border: 1px solid var(--card-border-color);
background-color: var(--secondary);
color: var(--secondary-inverse);
cursor: pointer;
margin-bottom: -1px;
position: relative;
border-radius: var(--border-radius) var(--border-radius) 0 0;
font-weight: normal;
&.active {
background-color: var(--primary);
color: var(--primary-inverse);
border-bottom: 1px solid var(--primary);
font-weight: bold;
}
&:hover:not(.active) {
background-color: var(--secondary-hover);
}
}
}
// Canvas grid styling
.canvas-grid {
border-collapse: collapse;
user-select: none;
td {
padding: 0;
border: 1px solid var(--light-grey);
&.cell {
cursor: crosshair;
&.filled {
background-color: var(--text-color);
}
&.empty {
background-color: var(--card-background-color);
}
}
&.row-label,
&.col-label {
text-align: center;
font-size: 10px;
color: var(--light-grey);
padding: 2px;
width: auto;
min-width: 0;
}
&.row-label {
width: 20px;
max-width: 20px;
}
}
// Word edge styling (when obvious styling is enabled)
&.obvious-edges {
td.word-edge {
border-left: 2px solid red !important;
}
}
}
// Generated code textarea
.generated-code {
width: 100%;
height: 100%;
min-height: 400px;
resize: none;
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace);
}
// Control rows
.control-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing);
align-items: center;
padding: var(--spacing);
border-top: 1px solid var(--card-border-color);
&:first-child {
border-top: none;
}
// Button groups
.button-group {
display: flex;
gap: var(--spacing);
align-items: center;
}
// Checkbox/radio groups
.checkbox-group,
.radio-group {
display: flex;
gap: calc(var(--spacing) * 3);
align-items: center;
label {
display: flex;
align-items: center;
gap: var(--spacing);
margin: 0;
cursor: pointer;
font-weight: normal;
input {
margin: 0;
}
}
}
// Spacer
.spacer {
width: calc(var(--spacing) * 4);
}
// Input with label
.input-group {
display: flex;
align-items: center;
gap: var(--spacing);
label {
margin: 0;
font-weight: normal;
white-space: nowrap;
}
input[type="text"] {
width: 3rem;
margin: 0;
}
}
}
// Buttons
button {
margin: 0;
padding: var(--spacing) calc(var(--spacing) * 2);
white-space: nowrap;
font-size: 0.85rem;
}
// Hidden file input
.hidden-file-input {
display: none;
}
}
// Responsive layouts
@media (max-width: 1200px) {
.BitmapPage {
grid-template-areas: "canvas" "code" "controls";
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
.code-panel {
min-width: unset;
}
.generated-code {
min-height: 200px;
}
}
}
@media (max-width: 768px) {
.BitmapPage {
.control-row {
.button-group {
flex-wrap: wrap;
}
}
}
}
+892
View File
@@ -0,0 +1,892 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel } from "../shell/panel";
import {
createGrid,
generateHackAssemblyCode,
generateJackCodeLine,
getColumns,
getWordValue,
} from "../utils/bitmapUtils";
import "./bitmap.scss";
// Constants
const STATE_KEY = "bitmap_editor_state";
const GRID_KEY = "bitmap_editor_grid";
interface BitmapState {
width: number;
height: number;
pixelSize: number;
currentIShift: number;
currentJShift: number;
marginSaveFrames: number;
baseI: number;
baseJ: number;
drawHeight: number;
invertMode: boolean;
comments: boolean;
}
const defaultState: BitmapState = {
width: 48,
height: 32,
pixelSize: 16,
currentIShift: 0,
currentJShift: 0,
marginSaveFrames: 1,
baseI: 0,
baseJ: 0,
drawHeight: 0,
invertMode: false,
comments: true,
};
type CodeMode = "jack" | "hack";
type MarginType = "fitToDrawing" | "rectangular" | "fullCanvas";
export const BitmapEditor = () => {
// Load state from localStorage
const loadState = (): BitmapState => {
try {
const saved = localStorage.getItem(STATE_KEY);
if (saved) {
return { ...defaultState, ...JSON.parse(saved) };
}
} catch (e) {
console.error("Error loading bitmap editor state:", e);
}
return defaultState;
};
const loadGrid = (width: number, height: number, invertMode: boolean): boolean[][] => {
try {
const saved = localStorage.getItem(GRID_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed;
}
}
} catch (e) {
console.error("Error loading bitmap editor grid:", e);
}
return createGrid(width, height, invertMode);
};
// State
const initialState = loadState();
const [width, setWidth] = useState(initialState.width);
const [height, setHeight] = useState(initialState.height);
const [pixelSize, setPixelSize] = useState(initialState.pixelSize);
const [currentIShift, setCurrentIShift] = useState(initialState.currentIShift);
const [currentJShift, setCurrentJShift] = useState(initialState.currentJShift);
const [marginSaveFrames, setMarginSaveFrames] = useState(initialState.marginSaveFrames);
const [invertMode, setInvertMode] = useState(initialState.invertMode);
const [comments, setComments] = useState(initialState.comments);
const [grid, setGrid] = useState<boolean[][]>(() =>
loadGrid(initialState.width, initialState.height, initialState.invertMode)
);
const [currentColor, setCurrentColor] = useState<boolean | null>(null);
const [codeMode, setCodeMode] = useState<CodeMode>("jack");
const [pauseCode, setPauseCode] = useState(false);
const [marginType, setMarginType] = useState<MarginType>("fitToDrawing");
const [obviousStyling, setObviousStyling] = useState(false);
const [baseTopLeft, setBaseTopLeft] = useState(true);
// Refs for input fields
const widthInputRef = useRef<HTMLInputElement>(null);
const heightInputRef = useRef<HTMLInputElement>(null);
const pixelSizeInputRef = useRef<HTMLInputElement>(null);
const marginFramesInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Save state to localStorage
useEffect(() => {
const state: BitmapState = {
width,
height,
pixelSize,
currentIShift,
currentJShift,
marginSaveFrames,
baseI: 0,
baseJ: 0,
drawHeight: 0,
invertMode,
comments,
};
localStorage.setItem(STATE_KEY, JSON.stringify(state));
}, [width, height, pixelSize, currentIShift, currentJShift, marginSaveFrames, invertMode, comments]);
// Save grid to localStorage
useEffect(() => {
localStorage.setItem(GRID_KEY, JSON.stringify(grid));
}, [grid]);
// Get used words for code generation
const getUsedWords = useCallback(() => {
const words: Record<string, [number, number][]> = {};
let leftJ = 0;
let rightJ = width - 1;
let baseJ = 0;
let bottomI = height - 1;
let topI = 0;
let baseI = height - 1;
if (marginType !== "fullCanvas") {
// Find drawing borders
findLeft: for (let j = 0; j < width; j++) {
for (let i = height - 1; i >= 0; i--) {
if (grid[i]?.[j] !== invertMode) {
leftJ = j;
break findLeft;
}
}
}
findRight: for (let j = width - 1; j >= 0; j--) {
for (let i = height - 1; i >= 0; i--) {
if (grid[i]?.[j] !== invertMode) {
rightJ = j;
break findRight;
}
}
}
findBottom: for (let i = height - 1; i >= 0; i--) {
for (let j = 0; j < width; j++) {
if (grid[i]?.[j] !== invertMode) {
bottomI = i;
break findBottom;
}
}
}
findTop: for (let i = 0; i < height; i++) {
for (let j = 0; j < width; j++) {
if (grid[i]?.[j] !== invertMode) {
topI = i;
break findTop;
}
}
}
baseJ = leftJ - currentJShift;
baseI = bottomI - currentIShift;
rightJ = Math.max(rightJ, rightJ - currentJShift);
leftJ = Math.min(leftJ, leftJ - currentJShift);
}
const columns = getColumns(leftJ, rightJ, baseJ);
for (const col of columns) {
const thisCol: [number, number][] = [];
const colIndex = col * 16 + baseJ;
if (marginType === "fullCanvas") {
for (let i = 0; i < height; i++) {
thisCol.push([i, colIndex]);
}
} else if (marginType === "rectangular") {
const minI = Math.min(topI, baseI);
const maxI = Math.max(bottomI, baseI);
for (let i = minI; i <= maxI; i++) {
if (i < 0 || i >= height) continue;
thisCol.push([i, colIndex]);
}
} else {
// fitToDrawing: collect rows that contain drawing data in this 16-px column,
// including extra rows needed for horizontal-shift animation frames.
const includedIvalues = new Set<number>();
const minI = Math.min(topI, baseI);
const maxI = Math.max(bottomI, baseI);
for (let i = minI; i <= maxI; i++) {
if (i < 0 || i >= height) continue;
let thisIIn = false; // true if row i has any drawn pixel in this word
// Check pixels within the current 16-px word
for (let j = colIndex; j < colIndex + 16; j++) {
if (j >= 0 && j < width) {
if (grid[i]?.[j] !== invertMode) {
thisIIn = true;
break;
}
// Also check neighboring columns that will shift into this word during animation
if (currentJShift > 0) {
for (let l = colIndex + 16; l < Math.min(currentJShift, marginSaveFrames) + colIndex + 16; l++) {
if (l >= width) break;
if (grid[i]?.[l] !== invertMode) {
thisIIn = true;
break;
}
}
} else if (currentJShift < 0) {
for (let l = colIndex - 1; l > Math.max(currentJShift, -marginSaveFrames) + colIndex - 1; l--) {
if (l < 0) break;
if (grid[i]?.[l] !== invertMode) {
thisIIn = true;
break;
}
}
}
}
}
if (thisIIn) {
includedIvalues.add(i);
const minM = Math.min(i, i - currentIShift);
const maxM = Math.max(i, i - currentIShift);
for (let m = minM; m <= maxM; m++) {
if (m >= 0 && m < height) includedIvalues.add(m);
}
}
}
for (const item of includedIvalues) {
thisCol.push([item, colIndex]);
}
}
words[col.toString()] = thisCol;
}
const drawHeight = Math.max(bottomI, baseI) - Math.min(topI, baseI);
return { words, baseI, baseJ, columns, drawHeight };
}, [grid, width, height, invertMode, marginType, currentIShift, currentJShift, marginSaveFrames]);
// Generate code
const generatedCode = useMemo(() => {
if (pauseCode) return "";
const { words, baseI, columns, drawHeight } = getUsedWords();
const baseRow = baseTopLeft ? drawHeight : 0;
const subroutineName = "draw";
if (codeMode === "jack") {
let code =
"function void " +
subroutineName +
"(int location) {\n\tvar int memAddress; \n\tlet memAddress = 16384+location;\n";
for (const col of columns) {
if (comments) {
code += "\t// column " + col + "\n";
}
const coords = words[col.toString()] || [];
for (const [i, j] of coords) {
const value = getWordValue(grid, i, j, invertMode, width);
code += generateJackCodeLine(i - baseI + baseRow, value, col);
}
}
code += "\treturn;\n}";
return code;
} else {
// hack assembly
const rowsOfWords: Record<string, { coords: [number, number]; col: string }[]> = {};
for (const col in words) {
for (const coords of words[col]) {
const key = coords[0].toString();
if (!rowsOfWords[key]) rowsOfWords[key] = [];
rowsOfWords[key].push({ coords, col });
}
}
let code = "(" + subroutineName + ")\n";
if (comments) {
code += "\t// put bitmap location value in R12\n\t// put code return address in R13\n";
}
code += "\t@SCREEN\n\tD=A\n\t@R12\n\tAD=D+M\n";
let dHoldsAddr = true;
let previousCoords: [number, number] | null = null;
let previousCol: string | null = null;
const sortedRows = Object.keys(rowsOfWords)
.map(Number)
.sort((a, b) => a - b);
for (const row of sortedRows) {
if (comments) code += "\t// row " + (row + 1) + "\n";
for (const data of rowsOfWords[row.toString()]) {
const { coords, col } = data;
const value = getWordValue(grid, coords[0], coords[1], invertMode, width);
let addrIncrement = 0;
if (previousCoords !== null && previousCol !== null) {
addrIncrement =
(coords[0] - previousCoords[0]) * 32 + (parseInt(col) - parseInt(previousCol));
}
const hackCode = generateHackAssemblyCode(addrIncrement, value, dHoldsAddr, comments);
dHoldsAddr = hackCode.dHoldsAddr;
code += hackCode.code;
previousCoords = coords;
previousCol = col;
}
}
if (comments) code += "\t// return\n";
code += "\t@R13\n\tA=M\n\tD;JMP\n";
return code;
}
}, [grid, codeMode, comments, pauseCode, getUsedWords, invertMode, width, baseTopLeft]);
// Word edge columns for styling
const wordEdgeColumns = useMemo(() => {
if (!obviousStyling) return new Set<number>();
const { columns, baseJ } = getUsedWords();
const edges = new Set<number>();
for (const col of columns) {
edges.add(col * 16 + baseJ);
}
return edges;
}, [obviousStyling, getUsedWords]);
// Drawing event handlers
const startDraw = useCallback(
(i: number, j: number) => {
// use functional update to avoid reading stale grid from closure
setGrid((prev) => {
const newGrid = prev.map((row) => [...row]);
const newColor = !newGrid[i][j];
newGrid[i][j] = newColor;
setCurrentColor(newColor);
return newGrid;
});
},
[]
);
const moveDraw = useCallback(
(i: number, j: number) => {
if (currentColor === null) return;
setGrid((prev) => {
const newGrid = prev.map((row) => [...row]);
newGrid[i][j] = currentColor;
return newGrid;
});
},
[currentColor]
);
const stopDraw = useCallback(() => {
setCurrentColor(null);
}, []);
const handleResize = useCallback(() => {
const newWidth = Math.floor(parseInt(widthInputRef.current?.value || "48") / 16) * 16;
const newHeight = parseInt(heightInputRef.current?.value || "32");
const newPixelSize = parseInt(pixelSizeInputRef.current?.value || "16");
if (isNaN(newWidth) || isNaN(newHeight) || isNaN(newPixelSize)) return;
setWidth(newWidth);
setHeight(newHeight);
setPixelSize(newPixelSize);
setGrid((prev) => {
const newGrid = createGrid(newWidth, newHeight, invertMode);
for (let i = 0; i < Math.min(newHeight, prev.length); i++) {
for (let j = 0; j < Math.min(newWidth, prev[i]?.length || 0); j++) {
newGrid[i][j] = prev[i][j];
}
}
return newGrid;
});
}, [invertMode]);
const handleReset = useCallback(() => {
setWidth(defaultState.width);
setHeight(defaultState.height);
setPixelSize(defaultState.pixelSize);
setCurrentIShift(0);
setCurrentJShift(0);
setMarginSaveFrames(1);
setInvertMode(false);
setComments(true);
setGrid(createGrid(defaultState.width, defaultState.height, false));
// Reset input fields
if (widthInputRef.current) widthInputRef.current.value = defaultState.width.toString();
if (heightInputRef.current) heightInputRef.current.value = defaultState.height.toString();
if (pixelSizeInputRef.current) pixelSizeInputRef.current.value = defaultState.pixelSize.toString();
if (marginFramesInputRef.current) marginFramesInputRef.current.value = "1";
}, []);
const shiftLeft = useCallback(() => {
setCurrentJShift((prev) => prev - 1);
setGrid((prev) => {
return prev.map((row) => {
const newRow = [...row];
for (let j = 0; j < width - 1; j++) {
newRow[j] = row[j + 1];
}
newRow[width - 1] = invertMode;
return newRow;
});
});
}, [width, invertMode]);
const shiftRight = useCallback(() => {
setCurrentJShift((prev) => prev + 1);
setGrid((prev) => {
return prev.map((row) => {
const newRow = [...row];
for (let j = width - 1; j > 0; j--) {
newRow[j] = row[j - 1];
}
newRow[0] = invertMode;
return newRow;
});
});
}, [width, invertMode]);
const shiftUp = useCallback(() => {
setCurrentIShift((prev) => prev - 1);
setGrid((prev) => {
const newGrid = prev.map((row) => [...row]);
for (let i = 0; i < height - 1; i++) {
newGrid[i] = [...prev[i + 1]];
}
newGrid[height - 1] = new Array(width).fill(invertMode);
return newGrid;
});
}, [height, width, invertMode]);
const shiftDown = useCallback(() => {
setCurrentIShift((prev) => prev + 1);
setGrid((prev) => {
const newGrid = prev.map((row) => [...row]);
for (let i = height - 1; i > 0; i--) {
newGrid[i] = [...prev[i - 1]];
}
newGrid[0] = new Array(width).fill(invertMode);
return newGrid;
});
}, [height, width, invertMode]);
const clearShifting = useCallback(() => {
setCurrentIShift(0);
setCurrentJShift(0);
}, []);
const rotateBitmapRight = useCallback(() => {
if (height !== width) return;
setGrid((prev) => {
const newGrid = createGrid(width, height, invertMode);
for (let i = 0; i < height; i++) {
for (let j = 0; j < width; j++) {
newGrid[j][height - 1 - i] = prev[i][j];
}
}
return newGrid;
});
clearShifting();
}, [height, width, invertMode, clearShifting]);
const mirrorBitmap = useCallback(() => {
setGrid((prev) => {
return prev.map((row) => {
const newRow = [...row];
for (let j = 0; j < width; j++) {
newRow[j] = row[width - 1 - j];
}
return newRow;
});
});
}, [width]);
const invertBitmap = useCallback(() => {
setGrid((prev) => {
return prev.map((row) => row.map((cell) => !cell));
});
}, []);
const handleMarginFramesChange = useCallback(() => {
let value = parseInt(marginFramesInputRef.current?.value || "1");
if (value < 1) value = 1;
setMarginSaveFrames(value);
}, []);
// keep uncontrolled input refs in sync when state changes (consistent with other pages)
useEffect(() => {
if (widthInputRef.current) widthInputRef.current.value = width.toString();
if (heightInputRef.current) heightInputRef.current.value = height.toString();
if (pixelSizeInputRef.current) pixelSizeInputRef.current.value = pixelSize.toString();
if (marginFramesInputRef.current) marginFramesInputRef.current.value = marginSaveFrames.toString();
}, [width, height, pixelSize, marginSaveFrames]);
// File import handlers
const handleFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const extension = file.name.toLowerCase().split(".").pop();
if (extension === "bmp") {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = new Uint8Array(e.target?.result as ArrayBuffer);
parseBMP(buffer);
};
reader.readAsArrayBuffer(file);
} else if (extension === "png") {
parsePNG(file);
} else {
alert("This file type is not supported. Please upload a .bmp (24-bit) or .png image.");
}
event.target.value = "";
},
[]
);
const parseBMP = useCallback((bytes: Uint8Array) => {
const bmpWidth = bytes[18] + (bytes[19] << 8);
const bmpHeight = bytes[22] + (bytes[23] << 8);
const offset = bytes[10] + (bytes[11] << 8);
const depth = bytes[28];
if (depth !== 24) {
alert(
"Only 24-bit BMP files are supported. Please convert your BMP file to a 24-bit depth bitmap."
);
return;
}
const rowSize = Math.floor((24 * bmpWidth + 31) / 32) * 4;
const newWidth = Math.ceil(bmpWidth / 16) * 16;
const newHeight = bmpHeight;
const newGrid: boolean[][] = [];
for (let i = 0; i < newHeight; i++) {
newGrid[i] = new Array(newWidth).fill(false);
}
for (let y = 0; y < bmpHeight; y++) {
const row = bmpHeight - 1 - y;
for (let x = 0; x < bmpWidth; x++) {
const index = offset + row * rowSize + x * 3;
const blue = bytes[index];
const green = bytes[index + 1];
const red = bytes[index + 2];
const luminance = (red + green + blue) / 3;
newGrid[y][x] = luminance < 128;
}
}
setWidth(newWidth);
setHeight(newHeight);
setGrid(newGrid);
}, []);
const parsePNG = useCallback((file: File) => {
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
img.onload = () => {
const newWidth = Math.ceil(img.width / 16) * 16;
const newHeight = img.height;
const canvas = document.createElement("canvas");
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, newWidth, newHeight);
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, newWidth, newHeight).data;
const newGrid: boolean[][] = [];
for (let y = 0; y < newHeight; y++) {
newGrid[y] = new Array(newWidth).fill(false);
for (let x = 0; x < newWidth; x++) {
const index = (y * newWidth + x) * 4;
const r = imageData[index];
const g = imageData[index + 1];
const b = imageData[index + 2];
const a = imageData[index + 3];
const luminance = (r + g + b) / 3;
newGrid[y][x] = a > 0 && luminance < 250;
}
}
setWidth(newWidth);
setHeight(newHeight);
setGrid(newGrid);
};
}, []);
return (
<div className="Page BitmapPage grid" onMouseUp={stopDraw} onMouseLeave={stopDraw}>
{/* Canvas Panel */}
<Panel
className="canvas-panel"
header={
<div className="canvas-size-controls">
<label htmlFor="inputWidth">Canvas Size:</label>
<input
ref={widthInputRef}
id="inputWidth"
type="text"
placeholder="width"
maxLength={3}
defaultValue={width}
/>
<span>x</span>
<input
ref={heightInputRef}
id="inputHeight"
type="text"
placeholder="height"
maxLength={3}
defaultValue={height}
/>
<label htmlFor="pixelSize">Pixel size:</label>
<input
ref={pixelSizeInputRef}
id="pixelSize"
type="text"
placeholder="px"
maxLength={2}
defaultValue={pixelSize}
/>
<button onClick={handleResize} title="Resize Canvas Preserving Upper-left Contents">
Resize
</button>
<button onClick={handleReset} title="Reset Canvas Size, Settings, and Clear Contents">
Reset
</button>
</div>
}
>
<div style={{ overflow: "auto", padding: "var(--spacing)" }}>
<table
className={`canvas-grid ${obviousStyling ? "obvious-edges" : ""}`}
onMouseLeave={stopDraw}
>
<tbody>
{/* Column labels row */}
<tr>
<td></td>
{Array.from({ length: width }, (_, j) => (
<td key={`col-${j}`} className="col-label" style={{ fontSize: `${pixelSize * 0.6}px` }}>
{j + 1}
</td>
))}
</tr>
{/* Grid rows */}
{Array.from({ length: height }, (_, i) => (
<tr key={`row-${i}`}>
<td className="row-label" style={{ fontSize: `${pixelSize * 0.6}px` }}>
{i + 1}
</td>
{Array.from({ length: width }, (_, j) => (
<td
key={`cell-${i}-${j}`}
className={`cell ${grid[i]?.[j] ? "filled" : "empty"} ${wordEdgeColumns.has(j) ? "word-edge" : ""
}`}
style={{ width: pixelSize, height: pixelSize }}
onMouseDown={() => startDraw(i, j)}
onMouseOver={() => moveDraw(i, j)}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
</Panel>
{/* Code Panel */}
<Panel
className="code-panel"
header={
<div className="tab-container">
<button
className={`tab-button ${codeMode === "jack" ? "active" : ""}`}
onClick={() => setCodeMode("jack")}
>
Generated Jack Code
</button>
<button
className={`tab-button ${codeMode === "hack" ? "active" : ""}`}
onClick={() => setCodeMode("hack")}
>
Generated Hack Assembly
</button>
</div>
}
>
<textarea
className="generated-code"
readOnly
value={generatedCode}
/>
</Panel>
{/* Controls Panel */}
<Panel className="controls-panel">
{/* Row 1: Manipulation buttons */}
<div className="control-row">
<input
ref={fileInputRef}
type="file"
className="hidden-file-input"
accept=".bmp, .png, image/bmp, image/png"
onChange={handleFileSelect}
/>
<button
onClick={() => fileInputRef.current?.click()}
title="Import BMP (24-bit) or PNG (black and white) file"
>
Import Image
</button>
<div className="button-group">
<button onClick={shiftLeft}>Shift left &lt;</button>
<button onClick={shiftRight}>Shift right &gt;</button>
<button onClick={shiftUp}>Shift up ^</button>
<button onClick={shiftDown}>Shift down v</button>
<button onClick={clearShifting}>Clear Shifting</button>
</div>
<div className="spacer" />
<div className="button-group">
<button onClick={rotateBitmapRight} disabled={width !== height}>
Rotate right
</button>
<button onClick={mirrorBitmap}>Flip horizontally</button>
<button onClick={invertBitmap}>Invert</button>
</div>
<div className="checkbox-group">
<label>
<input
type="checkbox"
checked={invertMode}
onChange={(e) => setInvertMode(e.target.checked)}
/>
Inverted Mode
</label>
</div>
<div className="spacer" />
<div className="checkbox-group">
<label>
<input
type="checkbox"
checked={pauseCode}
onChange={(e) => setPauseCode(e.target.checked)}
/>
Pause Code Generation
</label>
<label>
<input
type="checkbox"
checked={comments}
onChange={(e) => setComments(e.target.checked)}
/>
Comments On
</label>
<label>
<input
type="checkbox"
checked={obviousStyling}
onChange={(e) => setObviousStyling(e.target.checked)}
/>
Obvious Word Edges
</label>
</div>
</div>
{/* Row 2: Margin type controls */}
<div className="control-row">
<div className="radio-group">
<label>
<input
type="radio"
name="marginType"
checked={marginType === "fitToDrawing"}
onChange={() => setMarginType("fitToDrawing")}
/>
Fit to drawing
</label>
<label>
<input
type="radio"
name="marginType"
checked={marginType === "rectangular"}
onChange={() => setMarginType("rectangular")}
/>
Rectangular
</label>
<label>
<input
type="radio"
name="marginType"
checked={marginType === "fullCanvas"}
onChange={() => setMarginType("fullCanvas")}
/>
Full Canvas
</label>
</div>
{marginType === "fitToDrawing" && (
<div className="input-group">
<label htmlFor="marginSaveFrames"># horizontal shifts per animation frame:</label>
<input
ref={marginFramesInputRef}
id="marginSaveFrames"
type="text"
maxLength={2}
defaultValue={marginSaveFrames}
onChange={handleMarginFramesChange}
/>
</div>
)}
</div>
{/* Row 3: Base address controls */}
<div className="control-row">
<span>Base address:</span>
<div className="radio-group">
<label>
<input
type="radio"
name="baseRowFrom"
checked={baseTopLeft}
onChange={() => setBaseTopLeft(true)}
/>
Top Left
</label>
<label>
<input
type="radio"
name="baseRowFrom"
checked={!baseTopLeft}
onChange={() => setBaseTopLeft(false)}
/>
Bottom Left
</label>
</div>
</div>
</Panel>
</div>
);
};
export default BitmapEditor;
+71
View File
@@ -0,0 +1,71 @@
@use "./page.scss";
.ChipPage {
grid-template-areas: "hdl prt tst";
._hdl_panel {
grid-area: hdl;
min-height: calc(var(--line-height) * 10rem);
}
._test_panel {
grid-area: tst;
}
._parts_panel {
grid-area: prt;
}
}
@media (max-width: 1616px) {
.ChipPage {
grid-template-rows: 3fr 2fr;
grid-template-columns: 1fr var(--screen-size);
grid-template-areas:
"hdl prt"
"tst tst";
._test_panel {
grid-area: tst;
main {
> .Editor {
max-height: 50%;
}
}
}
}
}
@media (max-width: 1074px) {
.ChipPage {
grid-template-rows: 1fr 1fr 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"hdl"
"prt"
"tst";
._test_panel {
grid-area: tst;
main {
> .Editor {
max-height: 40%;
}
}
}
}
}
// used for svg of ALU component
path,
polygon {
stroke: var(--color);
}
text {
fill: var(--color);
}
+47
View File
@@ -0,0 +1,47 @@
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import { AppContext } from "../App.context";
import {
render,
screen,
cleanState,
userEvent,
useTestingAppContext,
} from "../testing";
import "../shell/editor.mock";
import Chip from "./chip";
describe.skip("chip page", () => {
const state = cleanState(
() => ({ context: useTestingAppContext() }),
beforeEach,
);
it("tracks the clock", async () => {
const events = userEvent.setup();
await render(
<BaseContext.Provider value={state.context.base}>
<AppContext.Provider value={state.context.app}>
<Chip />
</AppContext.Provider>
</BaseContext.Provider>,
);
await events.type(
screen.getByTestId("editor-hdl"),
`CHIP Foo { IN load; PARTS: CLOCKED load; }`,
);
await events.click(screen.getByText("Eval"));
const clock = screen.getByTestId("clock");
const clockReset = screen.getByTestId("clock-reset");
expect(clock).toHaveTextContent("Clock: 0");
await events.click(clock);
expect(clock).toHaveTextContent("Clock: 0+");
await events.click(clock);
expect(clock).toHaveTextContent("Clock: 1");
await events.click(clockReset);
expect(clock).toHaveTextContent("Clock: 0");
});
});
+416
View File
@@ -0,0 +1,416 @@
import { Trans, t } from "@lingui/macro";
import {
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import "./chip.scss";
import { makeVisualizationsWithId } from "@nand2tetris/components/chips/visualizations.js";
import { Clockface } from "@nand2tetris/components/clockface.js";
import {
FullPinout,
PinContext,
PinResetDispatcher,
} from "@nand2tetris/components/pinout.js";
import { useStateInitializer } from "@nand2tetris/components/react.js";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import { hasBuiltinChip } from "@nand2tetris/simulator/chip/builtins/index.js";
import { HDL } from "@nand2tetris/simulator/languages/hdl.js";
import { Timer } from "@nand2tetris/simulator/timer.js";
import { TestPanel } from "src/shell/test_panel";
import { AppContext } from "../App.context";
import { PageContext } from "../Page.context";
import { Editor } from "../shell/editor";
import { Accordian, Panel } from "../shell/panel";
import { zip } from "../shell/zip";
interface CompileInput {
hdl: string;
tst: string;
cmp: string;
tstDir: string;
}
export const Chip = () => {
const { setStatus, localFsRoot } = useContext(BaseContext);
const { stores, setTool } = useContext(PageContext);
const { tracking, filePicker } = useContext(AppContext);
const { state, actions, dispatch } = stores.chip;
const [hdl, setHdl] = useStateInitializer(state.files.hdl);
const [tst, setTst] = useStateInitializer(state.files.tst);
const [cmp, setCmp] = useStateInitializer(state.files.cmp);
const [out, setOut] = useStateInitializer(state.files.out);
const [tstDir, setTstDir] = useStateInitializer(state.dir);
const [tstPath, setTstPath] = useState<string>();
useEffect(() => {
if (tstPath) {
setTstDir(tstPath?.split("/").slice(0, -1).join("/"));
}
}, [tstPath]);
useEffect(() => {
setTool("chip");
}, [setTool]);
useEffect(() => {
tracking.trackPage("/chip");
}, [tracking]);
useEffect(() => {
tracking.trackEvent("action", "setProject", state.controls.project);
tracking.trackEvent("action", "setChip", state.controls.chipName);
}, []);
const doEval = useCallback(() => {
actions.eval();
tracking.trackEvent("action", "eval");
}, [actions, tracking]);
const compile = useRef<(files?: Partial<CompileInput>) => void>(
() => undefined,
);
compile.current = async (files: Partial<CompileInput> = {}) => {
const hdlToCompile = state.controls.usingBuiltin
? files.hdl
: (files.hdl ?? hdl);
await actions.updateFiles({
hdl: hdlToCompile,
tst: files.tst ?? tst,
cmp: files.cmp ?? cmp,
tstPath: files.tstDir ?? tstDir,
});
};
useEffect(() => {
compile.current({ tst, cmp, tstDir });
actions.reset();
}, [tst, cmp, tstDir]);
const runner = useRef<Timer>();
useEffect(() => {
runner.current = new (class ChipTimer extends Timer {
async reset(): Promise<void> {
await compile.current();
await actions.reset();
}
override finishFrame(): void {
super.finishFrame();
dispatch.current({ action: "updateTestStep" });
}
async tick(): Promise<boolean> {
return await actions.stepTest();
}
toggle(): void {
dispatch.current({ action: "updateTestStep" });
}
})();
return () => {
runner.current?.stop();
};
}, [compile, actions, dispatch]);
const clockActions = useMemo(
() => ({
toggle() {
actions.clock();
tracking.trackEvent("action", "toggleClock");
},
reset() {
tracking.trackEvent("action", "resetClock");
actions.reset();
},
}),
[actions],
);
const toggleUseBuiltin = () => {
actions.toggleBuiltin();
pinResetDispatcher.reset();
};
const loadFile = async () => {
const path = await filePicker.select({ suffix: "hdl" });
actions.loadChip(path.path);
};
const downloadRef = useRef<HTMLAnchorElement>(null);
const downloadProject = async () => {
if (!downloadRef.current) {
return;
}
const files = await actions.getProjectFiles();
const url = await zip(files);
downloadRef.current.href = url;
downloadRef.current.download = `${state.controls.project}`;
downloadRef.current.click();
URL.revokeObjectURL(url);
};
const selectors = (
<>
<fieldset
role="group"
data-tooltip="Open an HDL file using this menu"
data-placement="bottom"
>
<select
value={state.controls.project}
onChange={({ target: { value } }) => {
actions.setProject(value);
}}
data-testid="project-picker"
>
{state.controls.projects.map((project) => (
<option key={project} value={project}>
{`Project ${project.replace(/^0/, "")}`}
</option>
))}
</select>
<select
value={state.controls.chipName}
onChange={({ target: { value } }) => {
if (value != "") {
let path = `/${state.controls.project}/${value}.hdl`;
if (!localFsRoot) {
path = `/projects${path}`;
}
actions.loadChip(path);
}
}}
data-testid="chip-picker"
>
{state.controls.chipName == "" && <option></option>}
{state.controls.chips.map((chip) => (
<option key={chip} value={chip}>
{chip}
</option>
))}
</select>
</fieldset>
</>
);
const hdlPanel = (
<Panel
className="_hdl_panel"
isEditorPanel={true}
header={
<>
<div tabIndex={0}>HDL</div>
<label
style={{
visibility: hasBuiltinChip(state.controls.chipName)
? "visible"
: "hidden",
}}
>
<input
type="checkbox"
role="switch"
checked={state.controls.usingBuiltin}
onChange={toggleUseBuiltin}
/>
<Trans>Builtin</Trans>
</label>
<div style={{ width: "30px" }}></div>
<div className="flex-4">{selectors}</div>
<fieldset role="group">
<button
data-tooltip="Open an HDL file directly"
data-placement="bottom"
onClick={loadFile}
>
📂
</button>
<a ref={downloadRef} style={{ display: "none" }} />
<button
onClick={downloadProject}
data-tooltip={t`Download .hdl files`}
data-placement="left"
>
</button>
</fieldset>
</>
}
>
<Editor
className="flex-1"
value={hdl}
error={state.controls.error}
onChange={async (source) => {
setHdl(source);
if (!state.controls.usingBuiltin) {
actions.saveChip(source);
}
compile.current(state.controls.usingBuiltin ? {} : { hdl: source });
}}
grammar={HDL.parser}
language={"hdl"}
path={`${state.controls.project}/${state.controls.chipName}.hdl`}
disabled={state.controls.usingBuiltin || state.controls.chipName == ""}
/>
</Panel>
);
const [inputValid, setInputValid] = useState(true);
const showCannotTestError = () => {
setStatus(t`Cannot test a chip that has syntax errors`);
};
const evalIfCan = () => {
if (state.sim.invalid) {
showCannotTestError();
return;
}
doEval();
};
const chipButtons = (
<fieldset role="group">
<button
onClick={evalIfCan}
onKeyDown={evalIfCan}
disabled={!state.sim.pending || !inputValid}
>
<Trans>Eval</Trans>
</button>
<button
onClick={() => {
if (state.sim.invalid) {
showCannotTestError();
return;
}
clockActions.reset();
}}
style={{ maxWidth: "initial" }}
disabled={!state.sim.clocked}
>
<Trans>Reset</Trans>
</button>
<button
onClick={() => {
if (state.sim.invalid) {
showCannotTestError();
return;
}
clockActions.toggle();
}}
style={{ minWidth: "7em", textAlign: "start" }}
disabled={!state.sim.clocked}
>
<Trans>Clock</Trans>:{"\u00a0"}
<Clockface />
</button>
</fieldset>
);
const visualizations: [string, ReactNode][] = makeVisualizationsWithId(
{
parts: state.sim.chip,
},
() => {
dispatch.current({ action: "updateChip" });
},
state.controls.visualizationParameters,
);
const pinResetDispatcher = new PinResetDispatcher();
const pinsPanel = (
<Panel
className="_parts_panel"
header={
<>
<div>
<Trans>Chip</Trans> {state.controls.chipName}
</div>
{chipButtons}
</>
}
>
{state.sim.invalid ? (
<Trans>Syntax errors in the HDL code or test</Trans>
) : (
state.controls.chipName != "" && (
<>
<PinContext.Provider value={pinResetDispatcher}>
<FullPinout
sim={state.sim}
toggle={actions.toggle}
setInputValid={setInputValid}
hideInternal={state.controls.usingBuiltin}
/>
</PinContext.Provider>
{visualizations.length > 0 && (
<Accordian summary={<Trans>Visualization</Trans>} open={true}>
<main>{visualizations.map(([_, v]) => v)}</main>
</Accordian>
)}
</>
)
)}
</Panel>
);
const testPanel = (
<TestPanel
runner={runner}
disabled={state.sim.invalid}
prefix={
state.controls.tests.length > 1 ? (
<select
value={state.controls.testName}
onChange={({ target: { value } }) => {
actions.loadTest(value);
}}
data-testid="test-picker"
>
{state.controls.tests.map((test) => (
<option key={test} value={test}>
{test}
</option>
))}
</select>
) : (
<></>
)
}
tst={[tst, setTst, state.controls.span]}
cmp={[cmp, setCmp]}
out={[out, setOut]}
setPath={setTstPath}
speed={state.config.speed}
onSpeedChange={(speed) => {
dispatch.current({ action: "updateConfig", payload: { speed } });
}}
/>
);
return (
<>
<div className="Page ChipPage grid">
{hdlPanel}
{pinsPanel}
{testPanel}
</div>
</>
);
};
export default Chip;
+21
View File
@@ -0,0 +1,21 @@
@use "./page.scss";
.CompilerPage {
grid-template-areas: "code";
.files {
grid-area: files;
}
.code {
grid-area: code;
}
}
.file-entry {
border: 1px solid var(--light-grey);
.button {
background-color: var(--light-grey);
}
}
+323
View File
@@ -0,0 +1,323 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { Trans, t } from "@lingui/macro";
import { useDialog } from "@nand2tetris/components/dialog";
import { BaseContext } from "@nand2tetris/components/stores/base.context";
import {
FileSystemAccessFileSystemAdapter,
openNand2TetrisDirectory,
} from "@nand2tetris/components/stores/base/fs.js";
import { VmFile } from "@nand2tetris/simulator/test/vmtst";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Editor } from "src/shell/editor";
import { Tab, TabList } from "src/shell/tabs";
import { AppContext } from "../App.context";
import { PageContext } from "../Page.context";
import { Panel } from "../shell/panel";
import URLs from "../urls";
import "./compiler.scss";
export const Compiler = () => {
const { setStatus, canUpgradeFs } = useContext(BaseContext);
const { tracking } = useContext(AppContext);
const { stores, setTool } = useContext(PageContext);
const { state, dispatch, actions } = stores.compiler;
const [selected, setSelected] = useState(0);
const [suppressStatus, setSuppressStatus] = useState(false);
const [editable, setEditable] = useState(false);
const redirectRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
setTool("compiler");
}, [setTool]);
const showStatus = () => {
const current = state.compiled[state.selected];
if (current) {
setStatus(current.valid ? "" : (current.error?.message ?? ""));
}
};
useEffect(() => {
if (!suppressStatus) {
showStatus();
}
}, [state.selected, state.files, suppressStatus]);
const onSelect = useCallback(
(tab: string) => {
dispatch.current({ action: "setSelected", payload: tab });
tracking.trackEvent("tab", "change", tab);
},
[tracking],
);
useEffect(() => {
setSelected(Object.keys(state.files).indexOf(state.selected));
}, [state.selected]);
const loadRef = useRef<HTMLInputElement>(null);
const uploadFiles = async () => {
if (canUpgradeFs) {
const handle = await openNand2TetrisDirectory();
const fs = new FileSystem(new FileSystemAccessFileSystemAdapter(handle));
const empty =
(await fs.scandir("/")).filter(
(entry) => entry.isFile() && entry.name.endsWith(".jack"),
).length == 0;
if (empty) {
setStatus("No .jack files in the selected folder");
setSuppressStatus(true);
} else {
setStatus("");
actions.loadProject(fs, `${handle.name} / *.jack`);
setEditable(true);
}
} else {
loadRef.current?.click();
setEditable(false);
}
};
const onLoad = async () => {
if (
!loadRef.current ||
!loadRef.current?.files ||
loadRef.current.files?.length == 0
) {
return;
}
const files: Record<string, string> = {};
for (const file of loadRef.current.files) {
if (file.name.endsWith(".jack")) {
files[file.name.replace(".jack", "")] = await file.text();
}
}
actions.loadFiles(files);
};
const compileAll = (): VmFile[] => {
const files = [];
for (const file of Object.keys(state.files)) {
let compiled = state.compiled[file].vm ?? "";
compiled = `// Compiled ${file}.jack:\n`.concat(compiled);
files.push({ name: file, content: compiled });
}
return files;
};
const compileFiles = () => {
if (state.isValid) {
actions.compile();
setStatus("Compiled successfully");
}
};
const runInVm = () => {
if (state.title) {
stores.vm.dispatch.current({
action: "setTitle",
payload: state.title.replace(".jack", ".vm"),
});
}
stores.vm.actions.loadVm(compileAll());
redirectRef.current?.click();
};
const isNameValid = (name: string) => {
return (
(name?.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/) &&
!Object.keys(state.files).includes(name)) ??
false
);
};
const newFileDialog = useDialog();
const createFile = () => {
if (!state.fs) {
setStatus("No project folder loaded");
return;
}
newFileDialog.open();
};
const onCreateFile = async (name?: string) => {
if (name) {
await actions.writeFile(name);
onSelect(name);
setSuppressStatus(false);
}
};
const newFileDialogComponent = (
<NameDialog
title="Create New File"
buttonText={"Create"}
dialog={newFileDialog}
isValid={isNameValid}
onExit={onCreateFile}
/>
);
useEffect(() => {
setEditable(true);
}, [state.fs]);
return (
<div className="Page CompilerPage grid">
<input
type="file"
ref={loadRef}
webkitdirectory=""
onChange={onLoad}
style={{ display: "none" }}
></input>
<Link
ref={redirectRef}
to={URLs["vm"].href}
style={{ display: "none" }}
/>
{newFileDialogComponent}
<Panel
className="code"
header={
<>
<div>
<Trans>Source</Trans>
</div>
<div className="flex row flex-1">
<button
data-tooltip={t`Open a folder containing Jack file(s)`}
data-placement="right"
className="flex-0"
onClick={uploadFiles}
>
📂
</button>
<Padding />
<button
data-tooltip={t`Create a new file in the currently opened folder`}
data-placement="right"
className="flex-0"
onClick={createFile}
>
+
</button>
<Padding />
<button
className="flex-0"
data-tooltip={`Compile all the opened Jack files`}
data-placement="bottom"
onClick={compileFiles}
disabled={!state.isValid}
>
Compile
</button>
<Padding />
<button
className="flex-0"
disabled={!state.isCompiled}
data-tooltip={t`Load the compiled code into the VM emulator`}
data-placement="bottom"
onClick={runInVm}
>
Run
</button>
</div>
</>
}
>
<TabList tabIndex={{ value: selected, set: setSelected }}>
{Object.keys(state.files).map((file) => (
<Tab
title={`${file}.jack`}
key={file}
onSelect={() => onSelect(file)}
style={{
backgroundColor: !state.compiled[file].valid
? "var(--compiler-err-color)"
: undefined,
}}
>
<Editor
value={state.files[file]}
path={file}
onChange={(source: string) => {
actions.writeFile(file, source);
}}
error={state.compiled[file].error}
language={"jack"}
disabled={!editable}
/>
</Tab>
))}
</TabList>
</Panel>
</div>
);
};
export default Compiler;
function Padding() {
return <div style={{ width: "0.25vw" }} />;
}
const NameDialog = ({
title,
buttonText,
dialog,
isValid,
onExit,
}: {
title: string;
buttonText: string;
dialog: ReturnType<typeof useDialog>;
isValid: (value: string) => boolean;
onExit: (value?: string) => void;
}) => {
const [value, setValue] = useState<string>();
return (
<dialog open={dialog.isOpen}>
<article>
<header>
<Trans>{title}</Trans>
<a
className="close"
href="#root"
onClick={(e) => {
e.preventDefault();
onExit();
dialog.close();
}}
/>
</header>
<main>
<div className="flex row">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
></input>
<span>.jack</span>
</div>
<button
disabled={!isValid(value ?? "")}
onClick={() => {
dialog.close();
setValue("");
onExit(value);
}}
>
{buttonText}
</button>
</main>
</article>
</dialog>
);
};
+49
View File
@@ -0,0 +1,49 @@
@use "./page.scss";
.CpuPage {
&.normal {
grid-template-areas: "ROM RAM" "IO IO" "test test";
grid-template-columns: 1fr 1fr !important;
grid-template-rows: 1fr 1fr 1fr !important;
@media screen and (min-width: 1024px) {
grid-template-areas: "ROM RAM IO" "test test test";
grid-template-columns: 1fr 1fr var(--screen-size) !important;
grid-template-rows: 1fr 1fr !important;
}
@media screen and (min-width: 1500px) {
grid-template-areas: "ROM RAM IO test";
grid-template-columns: 350px 350px var(--screen-size) auto !important;
grid-template-rows: 1fr !important;
}
}
&.large-screen {
grid-template-columns: 350px calc(var(--screen-size) * 2) 1fr;
grid-template-rows: 1fr 1fr;
grid-template-areas: "ROM IO test" "RAM IO test";
@media screen and (min-width: 2200px) {
grid-template-columns: 350px 350px calc(var(--screen-size) * 2) 1fr;
grid-template-rows: 1fr;
grid-template-areas: "ROM RAM IO test";
}
}
.memory.ROM {
grid-area: ROM;
}
.memory.RAM {
grid-area: RAM;
}
.IO {
grid-area: IO;
}
._test_panel {
grid-area: test;
}
}
+257
View File
@@ -0,0 +1,257 @@
import { Timer } from "@nand2tetris/simulator/timer.js";
import { Keyboard } from "@nand2tetris/components/chips/keyboard";
import MemoryComponent from "@nand2tetris/components/chips/memory.js";
import { Screen, ScreenScales } from "@nand2tetris/components/chips/screen.js";
import { useContext, useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/macro";
import { useStateInitializer } from "@nand2tetris/components/react";
import { Runbar } from "@nand2tetris/components/runbar";
import { BaseContext } from "@nand2tetris/components/stores/base.context";
import { isPath } from "src/shell/file_select";
import { AppContext } from "../App.context";
import { PageContext } from "../Page.context";
import { Accordian, Panel } from "../shell/panel";
import { TestPanel } from "../shell/test_panel";
import "./cpu.scss";
export const CPU = () => {
const { filePicker } = useContext(AppContext);
const { setTool, stores } = useContext(PageContext);
const { state, actions, dispatch } = stores.cpu;
const { fs } = useContext(BaseContext);
const [tst, setTst] = useStateInitializer(state.test.tst);
const [out, setOut] = useStateInitializer(state.test.out);
const [cmp, setCmp] = useStateInitializer(state.test.cmp);
const [tstPath, setTstPath] = useState<string>();
const [screenRenderKey, setScreenRenderKey] = useState(0);
useEffect(() => {
setTool("cpu");
}, [setTool]);
useEffect(() => {
actions.compileTest(tst, cmp, tstPath);
actions.reset();
}, [tst, cmp, tstPath]);
const cpuRunner = useRef<Timer>();
const testRunner = useRef<Timer>();
const [runnersAssigned, setRunnersAssigned] = useState(false);
useEffect(() => {
cpuRunner.current = new (class CPUTimer extends Timer {
override async tick() {
actions.tick();
return false;
}
override finishFrame() {
dispatch.current({ action: "update" });
}
override reset() {
actions.reset();
}
override toggle() {
dispatch.current({ action: "update" });
}
})();
testRunner.current = new (class CPUTestTimer extends Timer {
override async tick() {
return actions.testStep();
}
override finishFrame() {
dispatch.current({ action: "update" });
}
override reset() {
actions.reset();
}
override toggle() {
dispatch.current({ action: "update" });
}
})();
setRunnersAssigned(true);
return () => {
cpuRunner.current?.stop();
testRunner.current?.stop();
};
}, [actions, dispatch]);
const setPath = async (fullPath: string) => {
setTstPath(fullPath);
actions.setPath(fullPath);
actions.reset();
};
const rerenderScreen = () => {
setScreenRenderKey(screenRenderKey + 1);
};
const onMemoryChange = () => {
rerenderScreen();
};
const onKeyChange = () => {
dispatch.current({ action: "update" });
};
const onScale = (scale: ScreenScales) => {
dispatch.current({
action: "updateConfig",
payload: { screenScale: scale },
});
};
const loadFile = async () => {
const file = await filePicker.selectAllowLocal({
suffix: [".asm", ".hack"],
});
const title = isPath(file)
? (file.path.split("/").pop() ?? "")
: Array.isArray(file)
? file[0].name
: file.name;
dispatch.current({ action: "setTitle", payload: title });
if (isPath(file)) {
setPath(file.path);
return {
name: file.path.split("/").pop() ?? "",
content: await fs.readFile(file.path),
};
} else {
actions.clearTest();
return Array.isArray(file) ? file[0] : file;
}
};
return (
<div
className={`Page CpuPage grid ${state.config.screenScale == 2 ? "large-screen" : "normal"}`}
>
<MemoryComponent
name="ROM"
memory={state.sim.ROM}
highlight={state.sim.PC}
format={state.config.romFormat}
fileSelect={loadFile}
onSetFormat={(format) => {
dispatch.current({
action: "updateConfig",
payload: { romFormat: format },
});
}}
onClear={() => actions.clear()}
loadTooltip={{
value: "Load an .asm or .hack file",
placement: "right",
}}
/>
<MemoryComponent
name="RAM"
memory={state.sim.RAM}
format={state.config.ramFormat}
excludedFormats={["asm"]}
onChange={onMemoryChange}
onSetFormat={(format) => {
dispatch.current({
action: "updateConfig",
payload: { ramFormat: format },
});
}}
/>
<Panel
className="IO"
header={
<>
<div className="flex-1">
{runnersAssigned && cpuRunner.current && (
<Runbar
runner={cpuRunner.current}
speed={state.config.speed}
onSpeedChange={(speed) => {
dispatch.current({
action: "updateConfig",
payload: { speed },
});
}}
/>
)}
</div>
</>
}
>
<Screen
key={screenRenderKey}
memory={state.sim.Screen}
showScaleControls={true}
scale={state.config.screenScale}
onScale={onScale}
></Screen>
<Keyboard update={onKeyChange} keyboard={state.sim.Keyboard} />
<Accordian summary={<Trans>Registers</Trans>} open={true}>
<main>
<div>
<dl>
<dt>PC</dt>
<dd>{state.sim.PC}</dd>
<dt>A</dt>
<dd>{state.sim.A}</dd>
<dt>D</dt>
<dd>{state.sim.D}</dd>
</dl>
</div>
</main>
</Accordian>
</Panel>
{runnersAssigned && (
<TestPanel
runner={testRunner}
tst={[tst, setTst, state.test.highlight]}
out={[out, setOut]}
cmp={[cmp, setCmp]}
setPath={setTstPath}
tstName={state.test.name}
disabled={!state.test.valid}
showName={state.tests.length < 2}
speed={state.config.testSpeed}
onSpeedChange={(speed) => {
dispatch.current({
action: "updateConfig",
payload: { testSpeed: speed },
});
actions.setAnimate(speed <= 2);
}}
prefix={
state.tests.length > 1 ? (
<select
value={state.test.name}
onChange={({ target: { value } }) => {
actions.loadTest(value);
}}
data-testid="test-picker"
>
{state.tests.map((test) => (
<option key={test} value={test}>
{test}
</option>
))}
</select>
) : (
<></>
)
}
/>
)}
</div>
);
};
export default CPU;
@@ -0,0 +1,29 @@
**The Hardware Simulator** is used for building and testing all the chips discussed in Nand to Tetris projects 1, 2, 3, and 5. Each chip is defined in a chipName.hdl file, and the most recent versions of these files are autosaved and persisted in your browser memory. This means that the next time you will use the hardware simulator, you will see the most recent versions of these files.
**Submitting HDL files:** If the course that you take requires submitting HDL files (e.g. for grading), you can write and test the files using this hardware simulator. When a chip passes its tests, you can copy its HDL code from the simulators editor panel and paste it into any standard text editor.
The simulator features three panels (from left to right): an HDL editor, a pins panel, and a test panel.
##### **HDL editor** (left panel)
To build or edit a chip (HDL program), select the chip name from the Project menu and the menu right next to it. This results in three actions: (i) the chipName.hdl file is loaded into the editor; (ii) the chips pin names are displayed in the middle panel, and (iii) the chips test script is displayed in the right panel. Any change that you make to the HDL code is saved automatically.
**Builtin chips:** Each chip in projects 1, 2, 3 and 5 has a builtin version. The builtin version features the chips interface, and a builtin implementation which is part of the simulators software. The builtin version allows users to experiment with the chips operations before implementing it in HDL. To do so, select the BuiltIn toggle and test the chip using either interactive or script-based simulation (described next).
##### **Pins panel** (in the middle)
Displays the names of the chips pins (input, output, internal), and their current values. The current pin values are computed by the chip logic when the user clicks the Eval button, or when an “eval” command is executed in the chips test script. If a pins width is more than one bit, a dec/bin toggle allows displaying its value in either a decimal or a binary format. To “evaluate a chip”, change one or more of its input pins, and click the Eval button.
**The clock and reset buttons** are enabled only for sequential chips. A chip is said to be sequential if it contains a sequential chip-part, or one of its chip-parts contains a sequential chip-part. The DFF chip is sequential. Therefore, all the chips that use a DFF directly or indirectly are sequential. Clicking the clock button advances the clock forward in either one tick or one tock. The resulting time step is displayed. Clicking the reset button resets the clock.
**Chip visualizations:** Some builtin chips have optional visualizations, which are displayed automatically by the simulator (and can be turned off by the user). The chip visualizations provide helpful information about some of the chips. For example, the ALU visualization displays the name of the operation that the ALU performs, and the visualizations of the memory chips display their internal states. We use this information to illustrate the intended chips behavior.
##### **Test panel** (on the right)
The test panel displays the test script that we supply for the loaded chip. The “current command”, which is highlighted in yellow, is the test script command that will be executed next. To test a chip, use the step button (executes the current command), run button (executes the entire test script, from the current command onward), or the reset button (makes the first command in the script the current command).
The compare file (if one is supplied) and the output file generated by the test script can be displayed by clicking the respective tabs.
The test script can be edited, but it is recommended to start testing the chip using the supplied script. Changes made to the test script (if any) are not saved.
**Bug / issue reports**
If you wish to report a bug or propose how to improve something, click the bug icon and write your bug/issue description. You will be asked to login into your GitHub account (if you have one).
@@ -0,0 +1,14 @@
import raw from "raw.macro";
import Markdown from "../../shell/markdown";
const ChipGuide = () => {
return (
<div style={{ overflowY: "scroll" }}>
<div className="container">
<Markdown>{raw("./HARDWARE_SIMULATOR.md")}</Markdown>
</div>
</div>
);
};
export default ChipGuide;
+121
View File
@@ -0,0 +1,121 @@
import { useBaseContext } from "@nand2tetris/components/stores/base.context";
import { DiffTable } from "@nand2tetris/components/difftable";
import { runTests } from "@nand2tetris/simulator/projects/runner.js";
import { Trans } from "@lingui/macro";
import { ChangeEventHandler, useCallback, useState } from "react";
import { AssignmentStubs } from "@nand2tetris/projects/base.js";
import type { ParsedPath } from "path";
// import { parse, ParsedPath } from "node:path";
function hasTest({ name, ext }: { name: string; ext: string }) {
return (
AssignmentStubs[name as keyof typeof AssignmentStubs] !== undefined &&
ext === ".hdl"
);
}
const TestResult = (props: {
name: string;
pass: boolean;
hdl: string;
tst: string;
cmp: string;
out: string;
}) => (
<details>
<summary>
{props.name} {props.pass ? <Trans>Passed</Trans> : <Trans>Failed</Trans>}
</summary>
<div className="flex row">
<pre>
<code>{props.hdl}</code>
</pre>
<pre>
<code>{props.tst}</code>
</pre>
</div>
<DiffTable cmp={props.cmp} out={props.out} />
</details>
);
async function loadAssignment(file: ParsedPath & { file?: File }) {
const { Assignments } = await import("@nand2tetris/projects/full.js");
const assignment = Assignments[file.name as keyof typeof Assignments];
const hdl = (await file.file?.text()) ?? "";
const tst = assignment[
`${file.name}.tst` as keyof typeof assignment
] as string;
const cmp = assignment[
`${file.name}.cmp` as keyof typeof assignment
] as string;
return { ...file, hdl, tst, cmp };
}
declare module "react" {
// eslint-disable-next-line
interface HTMLAttributes<T> {
// extends React's HTMLAttributes
directory?: string;
webkitdirectory?: string;
}
}
const Home = () => {
const [tests, setTests] = useState(
[] as Array<Parameters<typeof TestResult>[0]>,
);
const { fs } = useBaseContext();
const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
async ({ target }) => {
const files = await Promise.all(
[...(target.files ?? [])]
.filter((file) => file.name.endsWith(".hdl"))
.map((file) => {
const { name, base, ext } =
file.name.match(/^(?<base>(?<name>.*)(?<ext>\.[^.]*))?$/)
?.groups ?? {};
const root = "/";
const dir =
root + (file.webkitRelativePath?.replace(base, "") ?? "");
return { name, base, ext, dir, root, file };
})
.filter(hasTest)
.map(async (file) => {
const hdl = await file.file.text();
return { ...file, hdl };
}),
);
const tests = await runTests(files, loadAssignment, fs);
fs.pushd("/samples");
setTests(tests);
fs.popd();
},
[setTests, fs],
);
return (
<>
<h1>NAND2Tetris Web IDE</h1>
<form>
<fieldset>
<legend>Files for grading:</legend>
<input type="file" multiple webkitdirectory="" onChange={onChange} />
</fieldset>
</form>
<figure>
{tests.length > 0 ? (
tests.map((t, i) => <TestResult key={t.name} {...t} />)
) : (
<></>
)}
</figure>
</>
);
};
export default Home;
+15
View File
@@ -0,0 +1,15 @@
@use "../pico/button-group.scss";
@use "../shell/tab.scss";
.Page {
h2 {
margin: 0 var(--nav-element-spacing-horizontal);
}
--screen-size: calc(512px + calc(var(--block-spacing-horizontal) * 5));
height: 100%;
margin: 0px;
gap: 0;
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { LAST_ROUTE_KEY } from "../urls";
// Redirects the user to the last route they visited,
// and handles any context changes that should happen before entering the new route
export const Redirect = () => {
const [route, setRoute] = useState<string>();
useEffect(() => {
const lastRoute = localStorage.getItem(LAST_ROUTE_KEY) ?? "/chip";
setRoute(lastRoute);
}, []);
return route ? <Navigate to={route} /> : <></>;
};
+3
View File
@@ -0,0 +1,3 @@
input {
font-family: var(--font-family-monospace);
}
+178
View File
@@ -0,0 +1,178 @@
import { asm, op } from "@nand2tetris/simulator/util/asm.js";
import {
bin,
dec,
hex,
int10,
int16,
int2,
unsigned,
} from "@nand2tetris/simulator/util/twos.js";
import { ChangeEvent, Dispatch, SetStateAction, useState } from "react";
import "./util.scss";
function validBin(value: string) {
return /^[01]+$/.test(value) && value.length <= 16;
}
function validDec(value: string) {
return (
/^-?\d+$/.test(value) && Number(value) <= 32767 && Number(value) >= -32768
);
}
function validUnsigned(value: string) {
return /^\d+$/.test(value) && Number(value) <= 65535;
}
function validHex(value: string) {
return /^0x[0-9a-fA-F]+$/.test(value) && value.length <= 6;
}
function validAsm(value: string) {
try {
op(value);
return true;
} catch {
return false;
}
}
export const FormattedInput = (props: {
id: string;
value?: number;
setValue: Dispatch<SetStateAction<number | undefined>>;
setError: Dispatch<SetStateAction<string | undefined>>;
isValid: (value: string) => boolean;
parse: (value: string) => number;
format: (value: number) => string;
}) => {
const [selected, setSelected] = useState(false);
const [rawValue, setRawValue] = useState("");
const onChange = ({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (!selected) {
return;
}
setRawValue(value);
if (!props.isValid(value)) {
props.setError("Invalid value");
props.setValue(undefined);
} else {
props.setError(undefined);
const parsed = props.parse(value);
props.setValue(parsed);
}
};
return (
<input
id="util_setBin"
type="text"
value={
selected
? rawValue
: props.value !== undefined
? props.format(props.value)
: ""
}
onChange={onChange}
onFocus={() => {
setSelected(true);
setRawValue(props.value !== undefined ? props.format(props.value) : "");
}}
onBlur={() => setSelected(false)}
/>
);
};
export const Util = () => {
const [value, setValue] = useState<number | undefined>();
const [error, setError] = useState<string | undefined>();
return (
<article>
<header>
<h2>Convert Hack Number Types</h2>
</header>
<main>
<dl>
<dt>
<label htmlFor="util_setBin">Binary</label>
</dt>
<dd>
<FormattedInput
id="util_setBin"
value={value}
setValue={setValue}
setError={setError}
parse={int2}
format={bin}
isValid={validBin}
/>
</dd>
<dt>
<label htmlFor="util_setInt">Decimal</label>
</dt>
<dd>
<FormattedInput
id="util_setInt"
value={value}
setValue={setValue}
setError={setError}
parse={int10}
format={dec}
isValid={validDec}
/>
</dd>
<dt>
<label htmlFor="util_setUnsigned">Unsigned</label>
</dt>
<dd>
<FormattedInput
id="util_setUnsigned"
value={value}
setValue={setValue}
setError={setError}
parse={int10}
format={unsigned}
isValid={validUnsigned}
/>
</dd>
<dt>
<label htmlFor="util_setHex">Hex</label>
</dt>
<dd>
<FormattedInput
id="util_setHex"
value={value}
setValue={setValue}
setError={setError}
parse={int16}
format={hex}
isValid={validHex}
/>
</dd>
<dt>
<label htmlFor="util_setAsm">HACK ASM</label>
</dt>
<dd>
<FormattedInput
id="util_setAsm"
value={value}
setValue={setValue}
setError={setError}
parse={op}
format={asm}
isValid={validAsm}
/>
</dd>
</dl>
{error && <p>{error}</p>}
</main>
</article>
);
};
export default Util;
+67
View File
@@ -0,0 +1,67 @@
@use "./page.scss";
.VmPage {
&.normal {
grid-template-columns:
1fr calc(var(--screen-size) * 0.5) calc(var(--screen-size) * 0.5)
1fr !important;
grid-template-rows: auto 1fr 4fr !important;
grid-template-areas:
"program display display test"
"program stack RAM test"
"vm stack RAM test";
}
&.no-screen {
grid-template-columns:
1fr calc(var(--screen-size) * 0.5) calc(var(--screen-size) * 0.5)
1fr !important;
grid-template-rows: auto 1fr 1fr !important;
grid-template-areas:
"program display display test"
"program stack RAM test"
"vm stack RAM test";
}
&.large-screen {
grid-template-columns:
1fr var(--screen-size) var(--screen-size)
1fr !important;
grid-template-rows: auto 1fr !important;
grid-template-areas:
"program display display test"
"vm stack RAM test";
}
.program {
grid-area: program;
}
.vm {
grid-area: vm;
}
.display {
grid-area: display;
}
.memory.Stack {
grid-area: stack;
}
.memory.RAM {
grid-area: RAM;
}
._test_panel {
grid-area: test;
}
tbody {
font-family: var(--font-family-monospace);
}
tr.highlight {
background-color: antiquewhite;
}
}
+486
View File
@@ -0,0 +1,486 @@
import { useContext, useEffect, useRef, useState } from "react";
import { Trans, t } from "@lingui/macro";
import { Keyboard } from "@nand2tetris/components/chips/keyboard.js";
import Memory from "@nand2tetris/components/chips/memory";
import { Screen } from "@nand2tetris/components/chips/screen.js";
import { useStateInitializer } from "@nand2tetris/components/react";
import { Runbar } from "@nand2tetris/components/runbar";
import { BaseContext } from "@nand2tetris/components/stores/base.context";
import { DEFAULT_TEST } from "@nand2tetris/components/stores/vm.store.js";
import { Timer } from "@nand2tetris/simulator/timer.js";
import { ERRNO, isSysError } from "@nand2tetris/simulator/vm/os/errors.js";
import { IMPLICIT, SYS_INIT, VmFrame } from "@nand2tetris/simulator/vm/vm.js";
import { VmFile } from "@nand2tetris/simulator/test/vmtst";
import { AppContext } from "src/App.context";
import { isPath } from "src/shell/file_select";
import { PageContext } from "../Page.context";
import { Editor } from "../shell/editor";
import { Panel } from "../shell/panel";
import { TestPanel } from "../shell/test_panel";
import "./vm.scss";
const ERROR_MESSAGES: Record<ERRNO, string> = {
[ERRNO.SYS_WAIT_DURATION_NOT_POSITIVE]: t`Duration must be positive (Sys.wait)`,
[ERRNO.ARRAY_SIZE_NOT_POSITIVE]: t`Array size must be positive (Array.new)`,
[ERRNO.DIVIDE_BY_ZERO]: t`Division by zero (Math.divide)`,
[ERRNO.SQRT_NEG]: t`Cannot compute square root of a negative number (Math.sqrt)`,
[ERRNO.ALLOC_SIZE_NOT_POSITIVE]: t`Allocated memory size must be positive (Memory.alloc)`,
[ERRNO.HEAP_OVERFLOW]: t`Heap overflow (Memory.alloc)`,
[ERRNO.ILLEGAL_PIXEL_COORD]: t`Illegal pixel coordinates (Screen.drawPixel)`,
[ERRNO.ILLEGAL_LINE_COORD]: t`Illegal line coordinates (Screen.drawLine)`,
[ERRNO.ILLEGAL_RECT_COORD]: t`Illegal rectangle coordinates (Screen.drawRectangle)`,
[ERRNO.ILLEGAL_CENTER_COORD]: t`Illegal center coordinates (Screen.drawCircle)`,
[ERRNO.ILLEGAL_RADIUS]: t`Illegal radius (Screen.drawCircle)`,
[ERRNO.STRING_LENGTH_NEG]: t`Maximum length must be non-negative (String.new)`,
[ERRNO.GET_CHAR_INDEX_OUT_OF_BOUNDS]: t`String index out of bounds (String.charAt)`,
[ERRNO.SET_CHAR_INDEX_OUT_OF_BOUNDS]: t`String index out of bounds (String.setCharAt)`,
[ERRNO.STRING_FULL]: t`String is full (String.appendChar)`,
[ERRNO.STRING_EMPTY]: t`String is empty (String.eraseLastChar)`,
[ERRNO.STRING_INSUFFICIENT_CAPACITY]: t`Insufficient string capacity (String.setInt)`,
[ERRNO.ILLEGAL_CURSOR_LOCATION]: t`Illegal cursor location (Output.moveCursor)`,
};
interface Rerenderable {
rerender: () => void;
}
const VM = () => {
const { filePicker } = useContext(AppContext);
const { setTool, stores } = useContext(PageContext);
const { state, actions, dispatch } = stores.vm;
const { setStatus, fs } = useContext(BaseContext);
const [tst, setTst] = useStateInitializer(state.files.tst);
const [out, setOut] = useStateInitializer(state.files.out);
const [cmp, setCmp] = useStateInitializer(state.files.cmp);
const [path, setPath] = useState<string>();
useEffect(() => {
setTool("vm");
}, [setTool]);
useEffect(() => {
if (path) {
actions.loadTest(path, tst);
actions.reset();
}
}, [tst, path]);
useEffect(() => {
if (state.controls.exitCode !== undefined) {
setStatus(
state.controls.exitCode == 0
? "Program halted"
: `Program exited with error code ${state.controls.exitCode}${
isSysError(state.controls.exitCode)
? `: ${ERROR_MESSAGES[state.controls.exitCode]}`
: ""
}`,
);
}
}, [state.controls.exitCode]);
const vmRunner = useRef<Timer>();
const testRunner = useRef<Timer>();
const [runnersAssigned, setRunnersAssigned] = useState(false);
useEffect(() => {
vmRunner.current = new (class VMTimer extends Timer {
override async tick() {
return actions.step();
}
override finishFrame() {
dispatch.current({ action: "update" });
}
override reset() {
setStatus("Reset");
actions.reset();
}
override toggle() {
actions.setPaused(!this.running);
dispatch.current({ action: "update" });
}
})();
testRunner.current = new (class TestTimer extends Timer {
override async tick() {
return actions.testStep();
}
override finishFrame() {
dispatch.current({ action: "update" });
}
override reset() {
setStatus("Reset");
actions.reset();
}
override toggle() {
actions.setPaused(!this.running);
dispatch.current({ action: "update" });
}
})();
setRunnersAssigned(true);
return () => {
vmRunner.current?.stop();
testRunner.current?.stop();
};
}, [actions, dispatch]);
const load = async () => {
const target = await filePicker.selectAllowLocal({
suffix: "vm",
allowFolders: true,
});
let files: VmFile[] = [];
let title = "";
if (isPath(target)) {
if (target.isDir) {
// folder
for (const file of (await fs.scandir(target.path)).filter(
(entry) => entry.isFile() && entry.name.endsWith(".vm"),
)) {
files.push({
name: file.name.replace(".vm", ""),
content: await fs.readFile(`${target.path}/${file.name}`),
});
}
title = `${target.path.split("/").pop()} / *.vm`;
} else {
// single file
files.push({
name: target.path.replace(".vm", ""),
content: await fs.readFile(target.path),
});
title = target.path.split("/").pop() ?? "";
}
loadTest(target.path);
} else {
files = Array.isArray(target)
? target.filter((file) => file.name.endsWith(".vm"))
: [target];
}
if (files.length == 0) {
return;
}
dispatch.current({ action: "setTitle", payload: title });
actions.loadVm(files);
actions.reset();
setStatus("");
};
const loadTest = async (path: string) => {
let tstPath = "";
if (path.includes(".")) {
tstPath = path.replace(".vm", "VME.tst");
} else {
const name = (await fs.scandir(path)).find(
(entry) => entry.isFile() && entry.name.endsWith("VME.tst"),
)?.name;
tstPath = `${path}/${name}`;
}
try {
const test = await fs.readFile(tstPath);
actions.loadTest(tstPath, test);
} catch (e) {
// No test file found.
}
};
const onSpeedChange = (speed: number, testPanel: boolean) => {
dispatch.current({
action: "updateConfig",
payload: testPanel ? { testSpeed: speed } : { speed },
});
actions.setAnimate(speed <= 2);
};
const stackRef = useRef<Rerenderable>();
return (
<div
className={`Page VmPage grid ${
state.config.screenScale == 0
? "no-screen"
: state.config.screenScale == 2
? "large-screen"
: "normal"
}`}
>
<Panel
className="program"
isEditorPanel={true}
header={
<>
<div className="flex-0" style={{ whiteSpace: "nowrap" }}>
<Trans>VM Code</Trans>
</div>
<div className="flex-1">
{runnersAssigned && vmRunner.current && (
<Runbar
prefix={
<button
className="flex-0"
onClick={load}
data-tooltip="Load files"
data-placement="bottom"
>
📂
</button>
}
runner={vmRunner.current}
disabled={!state.controls.valid}
speed={state.config.speed}
onSpeedChange={(speed) => onSpeedChange(speed, false)}
/>
)}
</div>
</>
}
>
<Editor
value={state.files.vm}
path={path ?? 'vm'}
onChange={(source: string) => {
actions.setVm(source);
}}
language={"vm"}
highlight={state.vm.showHighlight ? state.vm.highlight : undefined}
highlightType={state.controls.valid ? "highlight" : "error"}
error={state.controls.error}
/>
</Panel>
<Panel className="vm" header={<Trans>VM Structures</Trans>}>
<>
{state.vm.Stack.length > 0 && (
<VMStackFrame
statics={state.vm.Statics}
temp={state.vm.Temp}
frame={state.vm.Stack[0]}
/>
)}
<CallStack
stack={state.vm.Stack}
addedSysInit={state.vm.AddedSysInit}
/>
</>
</Panel>
<Panel className="display" style={{ gridArea: "display" }}>
<Screen
memory={state.vm.Screen}
showScaleControls={true}
scale={state.config.screenScale}
onScale={(scale) => {
dispatch.current({
action: "updateConfig",
payload: { screenScale: scale },
});
}}
/>
<Keyboard keyboard={state.vm.Keyboard} />
</Panel>
<Memory
ref={stackRef}
name="RAM"
memory={state.vm.RAM}
initialAddr={256}
format={state.config.ram1Format}
onSetFormat={(format) => {
dispatch.current({
action: "updateConfig",
payload: { ram1Format: format },
});
}}
showClear={false}
/>
<Memory
name="RAM"
className="Stack"
memory={state.vm.RAM}
format={state.config.ram2Format}
onSetFormat={(format) => {
dispatch.current({
action: "updateConfig",
payload: { ram2Format: format },
});
}}
cellLabels={[
"SP:",
"LCL:",
"ARG:",
"THIS:",
"THAT:",
"TEMP0:",
"TEMP1:",
"TEMP2:",
"TEMP3:",
"TEMP4:",
"TEMP5:",
"TEMP6:",
"TEMP7:",
"R13:",
"R14:",
"R15:",
]}
onChange={() => {
stackRef.current?.rerender();
}}
/>
{runnersAssigned && (
<TestPanel
runner={testRunner}
tst={[tst, setTst, state.test.highlight]}
out={[out, setOut]}
cmp={[cmp, setCmp]}
setPath={setPath}
showClear={true}
defaultTst={DEFAULT_TEST}
speed={state.config.testSpeed}
onSpeedChange={(speed) => onSpeedChange(speed, true)}
disabled={!state.controls.valid}
/>
)}
</div>
);
};
export default VM;
const UNKNOWN = "Unknown function";
function callStack(frames: VmFrame[], addedSysInit: boolean) {
const nameCounts: Record<string, number> = {};
frames = frames.filter((frame) => frame.fn?.name != IMPLICIT);
for (const frame of frames) {
if (!frame.fn) {
continue;
}
if (nameCounts[frame.fn.name]) {
nameCounts[frame.fn.name]++;
} else {
nameCounts[frame.fn.name] = 1;
}
}
const names = frames
.slice()
.reverse()
.map((frame) =>
frame.fn?.name == SYS_INIT.name
? addedSysInit
? `${SYS_INIT.name} (built-in)`
: SYS_INIT.name
: (frame.fn?.name ?? UNKNOWN),
);
for (const name of Object.keys(nameCounts)) {
if (nameCounts[name] == 1) {
continue;
}
nameCounts[name] = 0;
for (let i = 0; i < names.length; i++) {
if (names[i] === name) {
names[i] = `${name}[${nameCounts[name]}]`;
nameCounts[name]++;
}
}
}
return names;
}
function CallStack({
stack,
addedSysInit,
}: {
stack: VmFrame[];
addedSysInit: boolean;
}) {
return (
<section>
<p>
Call-stack:
<code>{callStack(stack, addedSysInit).join(" > ")}</code>
</p>
</section>
);
}
function VMStackFrame({
statics,
temp,
frame,
}: {
statics: number[];
temp: number[];
frame: VmFrame;
}) {
return (
<section>
<main>
<p>
Stack:
<code>[{frame.stack.values.join(", ")}]</code>
</p>
{frame.usedSegments?.has("local") && (
<p>
local:
<code>[{frame.locals.values.join(", ")}]</code>
</p>
)}
{frame.usedSegments?.has("argument") && (
<p>
argument:
<code>[{frame.args.values.join(", ")}]</code>
</p>
)}
{frame.usedSegments?.has("static") && (
<p>
static:
<code>[{statics.join(", ")}]</code>
</p>
)}
{frame.usedSegments?.has("pointer") && (
<p>
pointer:
<code>[{`${frame.frame.THIS}, ${frame.frame.THAT}`}]</code>
</p>
)}
{frame.usedSegments?.has("this") && (
<p>
this:
<code>[{frame.this.values.join(", ")}]</code>
</p>
)}
{frame.usedSegments?.has("that") && (
<p>
that:
<code>[{frame.that.values.join(", ")}]</code>
</p>
)}
{frame.usedSegments?.has("temp") && (
<p>
temp:
<code>[{temp.join(", ")}]</code>
</p>
)}
</main>
</section>
);
}
+111
View File
@@ -0,0 +1,111 @@
// https://github.com/picocss/pico/blob/main/scss/components/_accordion.scss
/**
* Accordion (<details>)
*/
details {
display: block;
margin-bottom: var(--spacing);
summary {
line-height: 1rem;
list-style-type: none;
cursor: pointer;
&:not([role]) {
color: var(--accordion-close-summary-color);
}
transition: color var(--transition);
// Reset marker
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
}
&::-moz-list-bullet {
list-style-type: none;
}
&::after {
display: none;
}
// Marker
&::before {
display: block;
width: 1rem;
height: 1rem;
margin-inline-start: calc(var(--spacing, 1rem) * 0.5);
float: left;
transform: rotate(-90deg);
background-image: var(--icon-chevron);
background-position: left center;
background-size: 1rem auto;
background-repeat: no-repeat;
content: "";
transition: transform var(--transition);
}
&:focus {
outline: none;
&:not([role]) {
color: var(--accordion-active-summary-color);
}
}
&:focus-visible {
&:not([role]) {
outline: var(--outline-width) solid var(--primary-focus);
outline-offset: calc(var(--spacing, 1rem) * 0.5);
color: var(--primary);
}
}
// Type button
&[role="button"] {
width: 100%;
text-align: left;
// Marker
&::before {
height: calc(1rem * var(--line-height, 1.5));
}
}
}
// Open
&[open] {
> summary {
margin-bottom: var(--spacing);
&:not([role]) {
&:not(:focus) {
color: var(--accordion-open-summary-color);
}
}
&::before {
transform: rotate(0);
}
}
}
}
[dir="rtl"] {
details {
summary {
text-align: right;
&::before {
float: right;
background-position: right center;
}
}
}
}
@@ -0,0 +1,37 @@
[role="group"] {
margin: 0;
display: flex;
flex-direction: row;
button,
[role="button"],
input,
select {
margin: 0;
&.colored {
background-color: var(--primary);
color: var(--primary-inverse);
}
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&[aria-current="true"] {
background-color: var(--primary-inverse);
color: var(--primary);
}
input[type="radio"],
input[type="checkbox"] {
display: none;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
.flex {
display: flex;
flex-direction: column;
&.row {
flex-direction: row;
&.inline > * {
display: inline-block;
}
}
&.justify-around {
justify-content: space-around;
}
&.justify-between {
justify-content: space-between;
}
&.justify-center {
justify-content: center;
}
&.align-baseline {
align-items: baseline;
}
&.align-center {
align-items: center;
}
&.align-stretch {
align-items: stretch;
}
&.align-end {
align-items: end;
}
&.wrap {
flex-wrap: wrap;
}
}
.flex-0 {
flex-basis: 0;
}
@for $i from 1 through 4 {
.flex-#{$i} {
flex: $i;
}
}
+39
View File
@@ -0,0 +1,39 @@
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200");
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
}
ul.icon-list {
margin-right: 0.4rem;
@media (max-width: 576px) {
overflow: auto;
}
li {
display: flex;
flex-direction: column;
align-items: center;
border: none;
margin: 1.2rem 0.6rem;
cursor: pointer;
--raise: -20px;
--time: 0.33s;
span,
a {
position: relative;
top: 0;
transition: var(--time);
}
a {
opacity: 0;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import "./icon.scss";
export const Icon = ({ name }: { name: string }) => {
return <span className="material-symbols-outlined">{name}</span>;
};
+78
View File
@@ -0,0 +1,78 @@
import { width } from "@davidsouther/jiffies/lib/esm/dom/css/sizing";
import { useStateInitializer } from "@nand2tetris/components/react.js";
import { Action } from "@nand2tetris/simulator/types";
import { useCallback, useState } from "react";
const Mode = { VIEW: 0, EDIT: 1 };
export const InlineEdit = (props: {
mode?: keyof typeof Mode;
value: string;
onChange: Action<string>;
}) => {
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 = () => (
<span
style={{ cursor: "text", ...width("full", "inline") }}
onClick={() => {
setMode(Mode.EDIT);
}}
>
{value}
</span>
);
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",
}}
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;
+193
View File
@@ -0,0 +1,193 @@
@layer user {
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
:root {
--line-height: 1.25;
/* 1.5 */
--spacing: 0.25rem;
/* 1rem */
--typography-spacing-vertical: 1.15rem;
/* 1.5rem */
--grid-spacing-horizontal: var(--spacing);
--grid-spacing-vertical: var(--spacing);
--form-element-spacing-vertical: 0.15rem;
/* .75rem */
--form-element-spacing-horizontal: 0.25rem;
/* 1rem */
--font-family: Poppins, system-ui, -apple-system, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Noto Sans", sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-size-monospace: 16px;
--font-family-monospace: "JetBrains Mono", source-code-pro, Menlo, Monaco,
Consolas, "Roboto Mono", "Ubuntu Monospace", "Noto Mono", "Oxygen Mono",
"Liberation Mono", monospace, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--card-border-color: black;
--text-color: black;
--mark-background-color: rgb(255, 230, 121);
--mark-error-color: rgb(252, 169, 154);
--light-grey: rgb(170, 170, 170);
--compiler-err-color: "#ffaaaa";
--disabled: var(--light-grey);
--file-picker-width: 400px;
}
@media only screen and (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--card-border-color: white;
--text-color: white;
--mark-background-color: rgb(30, 74, 109);
}
}
[data-theme="dark"] {
--card-border-color: white;
--text-color: white;
--mark-background-color: rgb(30, 74, 109);
--compiler-err-color: rgb(69, 25, 22);
--code-color: rgb(180, 180, 180);
.outline {
color: var(--light-grey);
}
--disabled: rgb(76, 85, 93);
}
}
@layer component {
.scroll-x {
overflow-x: auto;
}
.scroll-y {
overflow-y: auto;
}
code,
kbd,
.font-monospace {
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace);
}
del {
text-decoration: none;
}
textarea {
overflow: scroll;
white-space: pre;
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace);
}
article.fill {
padding: 0;
margin: 0;
}
article.no-shadow {
box-shadow: none;
}
// Tighten up panel articles
article.panel {
border: solid var(--border-width) var(--card-border-color);
padding: 0;
margin: 0;
border-radius: 0;
box-shadow: none;
display: flex;
flex-direction: column;
overflow-y: auto;
&.editor {
overflow-y: hidden;
}
> header {
padding: 0;
margin: 0;
font-weight: bold;
border-radius: 0;
}
> header > *:first-child {
padding: 0 var(--block-spacing-vertical);
}
main {
flex: 1;
}
}
// Tighten up accordian headers
details {
margin-bottom: 0;
padding-bottom: 0;
> summary {
display: flex;
flex-direction: row;
align-items: baseline;
background-color: var(--card-sectionning-background-color);
padding: calc(var(--block-spacing-vertical) * 0.66)
var(--block-spacing-horizontal);
font-weight: bold;
}
> summary > *:first-child {
flex: 1;
padding: 0 var(--block-spacing-horizontal);
}
}
#root {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
}
> main {
overflow: hidden;
}
> footer {
border-top: 1px solid var(--card-border-color);
background-color: var(--card-background-color);
padding-top: calc(var(--block-spacing-vertical) / 2);
padding-left: var(--block-spacing-horizontal);
align-items: baseline;
}
}
nav :is(ol, ul) {
margin-right: 0;
}
nav li {
padding: 0 var(--nav-link-spacing-horizontal);
}
td {
vertical-align: top;
}
// Disable old tooltip pseudo-elements (using new React component instead)
[data-tooltip]::before,
[data-tooltip]::after {
display: none !important;
}
}
+33
View File
@@ -0,0 +1,33 @@
dl {
display: grid;
grid-template-columns: minmax(max-content, 1fr) minmax(auto, 2fr);
> header {
grid-column: 1 / span 2;
font-weight: bold;
background-color: var(--muted-color);
color: var(--primary-inverse);
padding: var(--form-element-spacing-vertical)
var(--form-element-spacing-horizontal);
}
dt {
grid-column-start: 1;
}
dd {
grid-column-start: 2;
margin: 0;
}
dd,
dt {
padding: var(--form-element-spacing-vertical)
var(--form-element-spacing-horizontal);
border: 1px solid var(--muted-border-color);
&:nth-of-type(even) {
background-color: var(--table-row-stripped-background-color);
}
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+15
View File
@@ -0,0 +1,15 @@
import { ReportHandler } from "web-vitals";
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+138
View File
@@ -0,0 +1,138 @@
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from "workbox-core";
import { ExpirationPlugin } from "workbox-expiration";
import { createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate } from "workbox-strategies";
declare const self: ServiceWorkerGlobalScope;
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute([
...self.__WB_MANIFEST,
{ url: "/web-ide/root.css", revision: null },
{ url: "/web-ide/pico.min.css", revision: null },
// { url: "https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Poppins:wght@400;700&display=swap", revision: null, },
{ url: "/web-ide/poppins_400.ttf", revision: null },
{ url: "/web-ide/poppins_700.ttf", revision: null },
{ url: "/web-ide/jet_brains_mono.ttf", revision: null },
{ url: "/web-ide/manifest.json", revision: null },
{ url: "/web-ide/favicon.svg", revision: null },
{ url: "/web-ide/logo_192.png", revision: null },
{ url: "/web-ide/logo_512.png", revision: null },
{
url: "https://fonts.gstatic.com/s/materialsymbolsoutlined/v179/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsLjBuVY.woff2",
revision: null,
},
{
url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs/loader.js",
revision: null,
},
{
url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs/editor/editor.main.js",
revision: null,
},
{
url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs/editor/editor.main.css",
revision: null,
},
{
url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs/editor/editor.main.nls.js",
revision: null,
},
{
url: "user_guide/chip.pdf",
revision: null,
},
{
url: "user_guide/cpu.pdf",
revision: null,
},
{
url: "user_guide/asm.pdf",
revision: null,
},
{
url: "user_guide/vm.pdf",
revision: null,
},
{
url: "user_guide/compiler.pdf",
revision: null,
},
{
url: "user_guide/bitmap_editor.pdf",
revision: null,
},
]);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp("/[^/?]+\\.[^/]+$");
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== "navigate") {
return false;
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith("/_")) {
return false;
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
}
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + "/index.html"),
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) =>
url.origin === self.location.origin && url.pathname.endsWith(".png"),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: "images",
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
}),
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.
@@ -0,0 +1,147 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address.
window.location.hostname === "[::1]" ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
),
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
// const swUrl = `${process.env.PUBLIC_URL}/sw.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://cra.link/PWA",
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
"New content is available and will be used when all " +
"tabs for this page are closed. See https://cra.link/PWA.",
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log("Content is cached for offline use.");
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { "Service-Worker": "script" },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get("content-type");
if (
response.status === 404 ||
(contentType != null && contentType.indexOf("javascript") === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
"No internet connection found. App is running in offline mode.",
);
});
}
export function unregister() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
+15
View File
@@ -0,0 +1,15 @@
// 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";
import { i18n } from "@lingui/core";
import { en } from "make-plural/plurals";
import { messages } from "./locales/en/messages.mjs";
i18n.load("en", messages);
i18n.loadLocaleData({
en: { plurals: en },
});
i18n.activate("en");
+277
View File
@@ -0,0 +1,277 @@
import MonacoEditor, { type OnMount } from "@monaco-editor/react";
import { CompilationError, Span } from "@nand2tetris/simulator/languages/base";
import { Action } from "@nand2tetris/simulator/types";
import * as monacoT from "monaco-editor/esm/vs/editor/editor.api";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { AppContext } from "../App.context";
import { Decoration, HighlightType } from "./editor";
const isRangeVisible = (
editor: monacoT.editor.IStandaloneCodeEditor | undefined,
range: monacoT.Range,
) => {
for (const visibleRange of editor?.getVisibleRanges() ?? []) {
if (visibleRange.containsRange(range)) {
return true;
}
}
return false;
};
export const makeDecorations = (
monaco: typeof monacoT | null,
editor: monacoT.editor.IStandaloneCodeEditor | undefined,
highlight: Span | undefined,
additionalDecorations: Decoration[],
decorations: string[],
type: HighlightType = "highlight",
alwaysCenter = true,
): string[] => {
if (!(editor && highlight)) return decorations;
const model = editor.getModel();
if (!model) return decorations;
const start = model.getPositionAt(highlight.start);
const end = model.getPositionAt(highlight.end);
const range = monaco?.Range.fromPositions(start, end);
const nextDecoration: monacoT.editor.IModelDeltaDecoration[] = [];
if (range) {
nextDecoration.push({
range,
options: {
inlineClassName: type == "error" ? "error-highlight" : "highlight",
},
});
if (highlight.start != highlight.end) {
if (alwaysCenter || !isRangeVisible(editor, range)) {
editor.revealRangeInCenter(range);
}
}
}
for (const decoration of additionalDecorations) {
const range = monaco?.Range.fromPositions(
model.getPositionAt(decoration.span.start),
// editor.getSc
model.getPositionAt(decoration.span.end),
);
if (range) {
nextDecoration.push({
range,
options: { inlineClassName: decoration.cssClass },
});
}
}
return editor.deltaDecorations(decorations, nextDecoration);
};
export const MONACO_LIGHT_THEME = "vs";
export const MONACO_DARK_THEME = "vs-dark";
export const Monaco = ({
value,
onChange,
onCursorPositionChange,
language,
path,
error,
disabled = false,
highlight: currentHighlight,
highlightType = "highlight",
customDecorations: currentCustomDecorations = [],
dynamicHeight = false,
alwaysRecenter = true,
lineNumberTransform,
}: {
value: string;
onChange: Action<string>;
onCursorPositionChange?: (index: number) => void;
language: string;
path: string;
error?: CompilationError;
disabled?: boolean;
highlight?: Span | number;
highlightType?: HighlightType;
customDecorations?: Decoration[];
dynamicHeight?: boolean;
alwaysRecenter?: boolean;
lineNumberTransform?: (n: number) => string;
}) => {
const { theme } = useContext(AppContext);
const monaco = useRef<typeof monacoT>();
const [height, setHeight] = useState(0);
const editor = useRef<monacoT.editor.IStandaloneCodeEditor>();
const decorations = useRef<string[]>([]);
const highlight = useRef<Span | number | undefined>(undefined);
const customDecorations = useRef<Decoration[]>([]);
const codeTheme = useCallback(() => {
const isDark =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
: theme === "dark";
return isDark ? MONACO_DARK_THEME : MONACO_LIGHT_THEME;
}, [theme]);
const doDecorations = useCallback(() => {
let newHighlight: Span | undefined;
if (typeof highlight.current == "number") {
const lineCount = editor.current?.getModel()?.getLineCount() ?? 0;
if (highlight.current <= lineCount) {
const start =
editor.current
?.getModel()
?.getOffsetAt({ lineNumber: highlight.current, column: 0 }) ?? 0;
const end =
highlight.current == lineCount
? (editor.current?.getModel()?.getValueLength() ?? 0)
: (editor.current?.getModel()?.getOffsetAt({
lineNumber: highlight.current + 1,
column: 0,
}) ?? 1) - 1;
newHighlight = { start: start, end: end, line: highlight.current };
}
} else {
newHighlight = highlight.current;
}
decorations.current = makeDecorations(
monaco.current || null,
editor.current,
// I'm not sure why this makes things work, but it is load bearing.
// Removing the empty span will cause the initial first-statement
// highlight in the test view to not show. Setting it to [0, 1] will
// cause a 1-character highlight in the editor view, so don't do that
// either.
newHighlight ?? { start: 0, end: 0, line: 0 },
customDecorations.current,
decorations.current,
highlightType,
alwaysRecenter,
);
}, [decorations, monaco, editor, highlight, highlightType]);
const calculateHeight = () => {
if (dynamicHeight) {
const contentHeight = editor.current?.getContentHeight();
if (contentHeight) {
setHeight(contentHeight);
}
}
};
// Mark and center highlighted spans
useEffect(() => {
highlight.current = currentHighlight;
doDecorations();
}, [currentHighlight]);
useEffect(() => {
customDecorations.current = currentCustomDecorations;
doDecorations();
}, [currentCustomDecorations]);
// Set options when mounting
const onMount: OnMount = useCallback(
(ed, mon) => {
monaco.current = mon;
editor.current = ed;
editor.current?.updateOptions({
fontFamily: `"JetBrains Mono", source-code-pro, Menlo, Monaco,
Consolas, "Roboto Mono", "Ubuntu Monospace", "Noto Mono", "Oxygen Mono",
"Liberation Mono", monospace, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji"`,
fontSize: 16,
minimap: {
enabled: false,
},
theme: codeTheme(),
scrollBeyondLastLine: false,
readOnly: disabled,
lineNumbers: lineNumberTransform ?? "on",
renderValidationDecorations: "on",
folding: false,
quickSuggestions: {
other: "inline",
},
});
document.fonts.ready.then(() => {
monaco.current?.editor.remeasureFonts();
});
doDecorations();
calculateHeight();
editor.current?.onDidChangeCursorPosition((e) => {
const index = editor.current?.getModel()?.getOffsetAt(e.position);
if (index !== undefined) {
onCursorPositionChange?.(index);
}
});
const model = editor.current?.getModel();
model?.setEOL(monacoT.editor.EndOfLineSequence.LF);
},
[codeTheme],
);
useEffect(() => {
if (editor.current === undefined) return;
editor.current.updateOptions({ lineNumbers: lineNumberTransform ?? "on" });
}, [lineNumberTransform]);
// Set themes
useEffect(() => {
if (editor.current === undefined) return;
editor.current.updateOptions({ theme: codeTheme() });
}, [editor, codeTheme]);
// Prevent editing disabled editors
useEffect(() => {
if (editor.current === undefined) return;
editor.current.updateOptions({
readOnly: disabled,
renderValidationDecorations: "on",
});
}, [editor, disabled]);
// Add error markers on parse failure
useEffect(() => {
if (!editor.current) return;
if (!monaco.current) return;
const model = editor.current.getModel();
if (model === null) return;
if (error === undefined || error.span === undefined) {
monaco.current.editor.setModelMarkers(model, language, []);
return;
}
const startPos = model.getPositionAt(error.span.start);
const endPos = model.getPositionAt(error.span.end);
monaco.current.editor.setModelMarkers(model, language, [
{
message: error.message,
startColumn: startPos.column,
startLineNumber: startPos.lineNumber,
endColumn: endPos.column,
endLineNumber: endPos.lineNumber,
severity: 8, // monacoT.MarkerSeverity.Error,
},
]);
}, [error, editor, monaco, language]);
const onValueChange = (v = "") => {
calculateHeight();
onChange(v);
};
return (
<MonacoEditor
value={value}
language={language}
path={path}
onChange={onValueChange}
onMount={onMount}
height={dynamicHeight ? height : undefined}
/>
);
};
export default Monaco;
+338
View File
@@ -0,0 +1,338 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
type TooltipPlacement = "top" | "bottom" | "left" | "right";
const PLACEMENT_PRIORITY: TooltipPlacement[] = ["bottom", "top", "right", "left"];
const TOOLTIP_SPACING = 8;
const VIEWPORT_MARGIN = 10;
interface TooltipBounds {
width: number;
height: number;
}
interface TooltipState {
text: string;
placement: TooltipPlacement;
x: number;
y: number;
visible: boolean;
}
/**
* Estimates tooltip dimensions based on text content
*/
function estimateTooltipSize(text: string): TooltipBounds {
const temp = document.createElement("div");
temp.style.cssText = `
position: absolute;
visibility: hidden;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
max-width: 300px;
white-space: normal;
word-wrap: break-word;
`;
temp.textContent = text;
document.body.appendChild(temp);
const bounds = {
width: temp.offsetWidth,
height: temp.offsetHeight,
};
document.body.removeChild(temp);
return bounds;
}
/**
* Checks if a tooltip would be clipped with given placement
*/
function wouldBeClipped(
element: HTMLElement,
placement: TooltipPlacement,
tooltipSize: TooltipBounds
): boolean {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let tooltipRect: DOMRect;
switch (placement) {
case "top":
tooltipRect = new DOMRect(
rect.left + rect.width / 2 - tooltipSize.width / 2,
rect.top - tooltipSize.height - TOOLTIP_SPACING,
tooltipSize.width,
tooltipSize.height
);
break;
case "bottom":
tooltipRect = new DOMRect(
rect.left + rect.width / 2 - tooltipSize.width / 2,
rect.bottom + TOOLTIP_SPACING,
tooltipSize.width,
tooltipSize.height
);
break;
case "left":
tooltipRect = new DOMRect(
rect.left - tooltipSize.width - TOOLTIP_SPACING,
rect.top + rect.height / 2 - tooltipSize.height / 2,
tooltipSize.width,
tooltipSize.height
);
break;
case "right":
tooltipRect = new DOMRect(
rect.right + TOOLTIP_SPACING,
rect.top + rect.height / 2 - tooltipSize.height / 2,
tooltipSize.width,
tooltipSize.height
);
break;
}
const clippedLeft = tooltipRect.left < VIEWPORT_MARGIN;
const clippedRight = tooltipRect.right > viewportWidth - VIEWPORT_MARGIN;
const clippedTop = tooltipRect.top < VIEWPORT_MARGIN;
const clippedBottom = tooltipRect.bottom > viewportHeight - VIEWPORT_MARGIN;
return clippedLeft || clippedRight || clippedTop || clippedBottom;
}
/**
* Determines the optimal placement for a tooltip
*/
function getOptimalPlacement(element: HTMLElement, tooltipText: string): TooltipPlacement {
const tooltipSize = estimateTooltipSize(tooltipText);
// Check if element has a fixed placement preference
const fixed = element.getAttribute("data-tooltip-fixed");
if (fixed === "true") {
const currentPlacement = element.getAttribute("data-placement") as TooltipPlacement;
return currentPlacement || "bottom";
}
// Try placements in priority order
for (const placement of PLACEMENT_PRIORITY) {
if (!wouldBeClipped(element, placement, tooltipSize)) {
return placement;
}
}
return "bottom";
}
/**
* Calculate tooltip position based on placement
*/
function calculateTooltipPosition(
element: HTMLElement,
placement: TooltipPlacement
): { x: number; y: number } {
const rect = element.getBoundingClientRect();
switch (placement) {
case "top":
return {
x: rect.left + rect.width / 2,
y: rect.top,
};
case "bottom":
return {
x: rect.left + rect.width / 2,
y: rect.bottom,
};
case "left":
return {
x: rect.left,
y: rect.top + rect.height / 2,
};
case "right":
return {
x: rect.right,
y: rect.top + rect.height / 2,
};
}
}
export function Tooltip() {
const [tooltip, setTooltip] = useState<TooltipState>({
text: "",
placement: "bottom",
x: 0,
y: 0,
visible: false,
});
const tooltipElementsRef = useRef<Set<HTMLElement>>(new Set());
useEffect(() => {
const handleMouseEnter = (event: Event) => {
const element = event.currentTarget as HTMLElement;
const tooltipText = element.getAttribute("data-tooltip");
if (!tooltipText) return;
const optimalPlacement = getOptimalPlacement(element, tooltipText);
const { x, y } = calculateTooltipPosition(element, optimalPlacement);
setTooltip({
text: tooltipText,
placement: optimalPlacement,
x,
y,
visible: true,
});
};
const handleMouseLeave = () => {
setTooltip((prev) => ({ ...prev, visible: false }));
};
const observeTooltips = () => {
// Find all elements with data-tooltip
const elements = document.querySelectorAll<HTMLElement>("[data-tooltip]");
elements.forEach((element) => {
if (!tooltipElementsRef.current.has(element)) {
element.addEventListener("mouseenter", handleMouseEnter);
element.addEventListener("mouseleave", handleMouseLeave);
tooltipElementsRef.current.add(element);
}
});
};
// Initial observation
observeTooltips();
// Watch for dynamically added tooltip elements
const observer = new MutationObserver(() => {
observeTooltips();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
tooltipElementsRef.current.forEach((element) => {
element.removeEventListener("mouseenter", handleMouseEnter);
element.removeEventListener("mouseleave", handleMouseLeave);
});
tooltipElementsRef.current.clear();
};
}, []);
if (!tooltip.visible) return null;
// Calculate transform based on placement
const getTransform = () => {
switch (tooltip.placement) {
case "top":
return "translate(-50%, calc(-100% - 0.25rem))";
case "bottom":
return "translate(-50%, 0.25rem)";
case "left":
return "translate(calc(-100% - 0.25rem), -50%)";
case "right":
return "translate(0.25rem, -50%)";
}
};
// Calculate caret transform based on placement
const getCaretTransform = () => {
switch (tooltip.placement) {
case "top":
return "translate(-50%, -0.3rem)";
case "bottom":
return "translate(-50%, -0.3rem)";
case "left":
return "translate(0.3rem, -50%)";
case "right":
return "translate(-0.3rem, -50%)";
}
};
// Border styles for caret
const getCaretBorderStyle = () => {
const base = "0.3rem solid transparent";
switch (tooltip.placement) {
case "top":
return {
borderTop: "0.3rem solid var(--tooltip-background-color)",
borderRight: base,
borderLeft: base,
};
case "bottom":
return {
border: base,
borderBottom: "0.3rem solid var(--tooltip-background-color)",
};
case "left":
return {
border: base,
borderLeft: "0.3rem solid var(--tooltip-background-color)",
};
case "right":
return {
border: base,
borderRight: "0.3rem solid var(--tooltip-background-color)",
};
}
};
const tooltipStyle: React.CSSProperties = {
position: "fixed",
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
transform: getTransform(),
zIndex: 10000,
padding: "0.25rem 0.5rem",
maxWidth: "300px",
borderRadius: "var(--border-radius)",
backgroundColor: "var(--tooltip-background-color)",
color: "var(--tooltip-color)",
fontSize: "0.875rem",
fontWeight: "var(--font-weight)",
pointerEvents: "none",
whiteSpace: "normal",
wordWrap: "break-word",
animation: "tooltip-fade-in 0.2s ease",
};
const caretStyle: React.CSSProperties = {
position: "fixed",
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
transform: getCaretTransform(),
zIndex: 10000,
width: 0,
height: 0,
pointerEvents: "none",
...getCaretBorderStyle(),
};
return createPortal(
<>
<div style={tooltipStyle}>{tooltip.text}</div>
<div style={caretStyle} />
<style>
{`
@keyframes tooltip-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
`}
</style>
</>,
document.body
);
}
@@ -0,0 +1,17 @@
jest.mock("@monaco-editor/react", () => {
const FakeEditor = jest.fn((props) => {
console.log(props);
console.log("Used FakeEditor");
return (
<textarea
data-auto={props.wrapperClassName}
data-testid={`editor-${props.language}`}
onChange={(e) => props.onChange(e.target.value)}
value={props.value}
></textarea>
);
});
return FakeEditor;
});
export {};
+66
View File
@@ -0,0 +1,66 @@
:root[data-theme="light"] {
--diff-highlight-error-line-bg-color: #ffebe9;
--diff-highlight-error-cell-bg-color: #ffc1c0;
--diff-highlight-correct-line-bg-color: #dafbe1;
--diff-highlight-correct-cell-bg-color: #aceebb;
}
:root[data-theme="dark"] {
--diff-highlight-error-line-bg-color: #390504;
--diff-highlight-error-cell-bg-color: #842019;
--diff-highlight-correct-line-bg-color: #0d2705;
--diff-highlight-correct-cell-bg-color: #285d17;
}
.Editor {
width: 100%;
&:not(.dynamic-height) {
height: 100%;
}
.monaco-editor {
// Magic
position: absolute;
}
.highlight {
background-color: var(--mark-background-color);
}
.error-highlight {
background-color: var(--mark-error-color);
}
.diff-highlight-error-line {
background-color: var(--diff-highlight-error-line-bg-color);
}
.diff-highlight-error-cell {
background-color: var(--diff-highlight-error-cell-bg-color);
}
.diff-highlight-correct-line {
background-color: var(--diff-highlight-correct-line-bg-color);
}
.diff-highlight-correct-cell {
background-color: var(--diff-highlight-correct-cell-bg-color);
}
textarea {
flex: 1;
}
position: relative; // For ".overlay", below.
&:has(.overlay) {
// Available in chrome 105
position: relative;
}
.overlay {
position: absolute;
inset: 0;
background-color: var(--form-element-disabled-background-color);
opacity: var(--form-element-disabled-opacity);
}
}
+137
View File
@@ -0,0 +1,137 @@
import { Trans } from "@lingui/macro";
import { type Grammar } from "ohm-js";
import { CSSProperties, lazy, Suspense, useContext, useState } from "react";
import { AppContext } from "../App.context";
import {
CompilationError,
Span,
} from "@nand2tetris/simulator/languages/base.js";
import "./editor.scss";
import { Action } from "@nand2tetris/simulator/types";
const Monaco = lazy(() => import("./Monaco"));
export const ErrorPanel = ({ error }: { error?: CompilationError }) => {
return error ? (
<details className="ErrorPanel" open>
<summary role="button" className="secondary">
<Trans>Parse Error</Trans>
</summary>
<pre>
<code>{error?.message}</code>
</pre>
</details>
) : (
<></>
);
};
const Textarea = ({
value,
onChange,
language,
disabled = false,
}: {
value: string;
onChange: Action<string>;
language: string;
disabled?: boolean;
}) => {
const [text, setText] = useState(value);
return (
<textarea
data-testid={`editor-${language}`}
disabled={disabled}
value={text}
onChange={(e) => {
const value = e.target?.value;
setText(value);
onChange(value);
}}
/>
);
};
export interface Decoration {
span: Span;
cssClass: string;
}
export type HighlightType = "highlight" | "error";
export const Editor = ({
className = "",
style = {},
disabled = false,
value,
error,
onChange,
onCursorPositionChange,
grammar,
language,
path,
highlight,
highlightType = "highlight",
customDecorations = [],
dynamicHeight = false,
alwaysRecenter = true,
lineNumberTransform,
}: {
className?: string;
style?: CSSProperties;
disabled?: boolean;
value: string;
path: string;
error?: CompilationError;
onChange: Action<string>;
onCursorPositionChange?: (index: number) => void;
grammar?: Grammar;
language: string;
highlight?: Span | number;
highlightType?: HighlightType;
customDecorations?: Decoration[];
dynamicHeight?: boolean;
alwaysRecenter?: boolean;
lineNumberTransform?: (n: number) => string;
}) => {
const { monaco } = useContext(AppContext);
return (
<div
className={`Editor ${dynamicHeight ? "dynamic-height" : ""} ${className}`}
style={style}
>
{monaco.canUse && monaco.wants ? (
<Suspense fallback="Loading...">
<Monaco
value={value}
onChange={onChange}
onCursorPositionChange={onCursorPositionChange}
path={path}
language={language}
error={error}
disabled={disabled}
highlight={highlight}
highlightType={highlightType}
customDecorations={customDecorations}
dynamicHeight={dynamicHeight}
alwaysRecenter={alwaysRecenter}
lineNumberTransform={lineNumberTransform}
/>
</Suspense>
) : (
<>
<Textarea
value={value}
onChange={onChange}
language={language}
disabled={disabled}
/>
<ErrorPanel error={error} />
</>
)}
</div>
);
};
@@ -0,0 +1,14 @@
.file-select {
min-width: var(--file-picker-width);
.files-container {
max-height: 600px;
}
@media (max-width: 576px) {
width: 100%;
height: 100%;
padding: 5%;
padding: 5%;
}
}
+334
View File
@@ -0,0 +1,334 @@
import { FileSystem, Stats } from "@davidsouther/jiffies/lib/esm/fs";
import { t, Trans } from "@lingui/macro";
import { useDialog } from "@nand2tetris/components/dialog";
import { sortFiles } from "@nand2tetris/components/file_utils";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import type JSZip from "jszip";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AppContext } from "../App.context";
import { Icon } from "../pico/icon";
import "./file_select.scss";
import { newZip } from "./zip";
export const Selected = "file selected";
export interface FilePickerOptions {
suffix?: string | string[];
allowFolders?: boolean;
}
export interface LocalFile {
name: string;
content: string;
}
export function isPath(obj: unknown): obj is Path {
return (obj as Path).path != undefined && (obj as Path).isDir != undefined;
}
export interface Path {
path: string;
isDir: boolean;
}
// Selecting a file from the file picker would always return a Path on which we can use fs.readFile / fs.scandir later.
// In the case of local files, we have to load them on selection, and will return either a LocalFile or LocalFile[] in case of file/folder respectively.
export type FileSelectionRef = Path | LocalFile | LocalFile[];
export function useFilePicker() {
const dialog = useDialog();
const [suffix, setSuffix] = useState<string[]>();
const [allowFolders, setAllowFolders] = useState(false);
const allowLocal = useRef(false);
const selected = useRef<(v: FileSelectionRef) => void>();
const _select = useCallback(
async (options: FilePickerOptions): Promise<FileSelectionRef> => {
if (typeof options.suffix === "string") {
options.suffix = [options.suffix];
}
setSuffix(options.suffix);
setAllowFolders(options.allowFolders ?? false);
dialog.open();
return new Promise((resolve) => {
selected.current = resolve;
});
},
[dialog, selected],
);
const select = async (options: FilePickerOptions) => {
allowLocal.current = false;
return (await _select(options)) as Path;
};
const selectAllowLocal = async (options: FilePickerOptions) => {
allowLocal.current = true;
return _select(options);
};
return {
...dialog,
select,
selectAllowLocal,
[Selected]: selected,
suffix: suffix,
allowFolders,
allowLocal: allowLocal.current,
};
}
const FileEntry = ({
onClick,
onDoubleClick,
stats,
highlighted = false,
disabled = false,
}: {
stats: Stats;
highlighted?: boolean;
disabled?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
}) => {
const onClickCB = (event: { detail: number }) => {
if (event.detail == 1) {
onClick?.();
} else if (event.detail == 2) {
onDoubleClick?.();
}
};
return (
<div>
<button
className={`flex row justify-start outline ${
highlighted ? "" : "secondary"
}`}
style={{
textAlign: "left",
color: disabled ? "var(--disabled)" : undefined,
}}
onClick={onClickCB}
>
<Icon name={stats.isDirectory() ? "folder" : "draft"} />
{stats.name}
</button>
</div>
);
};
async function buildZip(zip: JSZip, fs: FileSystem, cwd: string) {
for (const entry of await fs.scandir(cwd)) {
if (entry.isDirectory()) {
const folder = zip.folder(entry.name);
if (folder) {
await buildZip(folder, fs, `${cwd}/${entry.name}`);
}
} else {
zip.file(entry.name, await fs.readFile(`${cwd}/${entry.name}`));
}
}
}
function isFileValid(filename: string, validSuffixes: string[]) {
return validSuffixes
.map((suffix) => filename.endsWith(suffix))
.reduce((p1, p2) => p1 || p2, false);
}
export const FilePicker = () => {
const { fs, setStatus, localFsRoot } = useContext(BaseContext);
const { filePicker } = useContext(AppContext);
const [files, setFiles] = useState<Stats[]>([]);
const [chosen, setFile] = useState<Path>({ path: fs.cwd(), isDir: true });
const cwd = fs.cwd();
const getFiles = (files: Stats[]) => {
return fs.cwd() != "/"
? [
{ isFile: () => false, isDirectory: () => true, name: ".." },
...sortFiles(files),
]
: sortFiles(files);
};
useEffect(() => {
if (!localFsRoot && fs.cwd() == "/") {
cd("projects");
}
setFile({ path: fs.cwd(), isDir: true });
}, [fs]);
useEffect(() => {
fs.scandir(fs.cwd()).then((files) => {
setFiles(getFiles(files));
});
}, [fs, cwd, setFiles]);
const cd = useCallback(
(dir: string) => {
fs.cd(dir);
fs.scandir(fs.cwd()).then((files) => {
setFiles(getFiles(files));
});
},
[fs, setFile, setFiles],
);
const select = useCallback(
(basename: string, isDir: boolean) => {
setFile({
path: `${fs.cwd() == "/" ? "" : fs.cwd()}/${basename}`,
isDir,
});
},
[setFile, fs],
);
const confirm = useCallback(() => {
filePicker.close();
filePicker[Selected].current?.(chosen);
}, [chosen, filePicker, setStatus]);
const loadRef = useRef<HTMLInputElement>(null);
const loadLocal = () => {
loadRef.current?.click();
};
const onLoadLocal = async () => {
if (
!loadRef.current ||
!loadRef.current.files ||
loadRef.current.files.length == 0
) {
return;
}
const files: LocalFile[] = [];
for (const file of loadRef.current.files) {
files.push({
name: file.name,
content: await file.text(),
});
}
filePicker[Selected].current?.(files.length == 1 ? files[0] : files);
filePicker.close();
};
const downloadRef = useRef<HTMLAnchorElement>(null);
const downloadFolder = async () => {
if (!downloadRef.current) {
return;
}
const zip = await newZip();
await buildZip(zip, fs, chosen.path);
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
downloadRef.current.href = url;
downloadRef.current.download = chosen.path.split("/").pop() ?? chosen.path;
downloadRef.current.click();
URL.revokeObjectURL(url);
};
const chosenFileName = useMemo(() => {
return chosen.path.split("/").pop();
}, [chosen]);
return (
<dialog open={filePicker.isOpen}>
<input
type="file"
ref={loadRef}
onChange={onLoadLocal}
style={{ display: "none" }}
webkitdirectory={filePicker.allowFolders ? "true" : undefined}
></input>
<article className="file-select flex">
<header>
<p>
<Trans>{t`Choose file`}</Trans>
</p>
<a
style={{ color: "rgba(0, 0, 0, 0)" }}
className="close"
href="#root"
onClick={(e) => {
e.preventDefault();
filePicker.close();
}}
>
{t`close`}
</a>
</header>
<main>
<a ref={downloadRef} style={{ display: "none" }} />
<div>
<b>{fs.cwd()}</b>
</div>
<div className="flex wrap files-container">
{files.map((file) => (
<FileEntry
key={file.name}
stats={file}
highlighted={file.name === chosenFileName}
onClick={() => select(file.name, file.isDirectory())}
onDoubleClick={() => {
if (file.isDirectory()) {
cd(file.name);
}
}}
disabled={
!(file.name == "..") &&
file.name.includes(".") &&
filePicker.suffix != undefined &&
!isFileValid(file.name, filePicker.suffix)
}
/>
))}
</div>
</main>
<footer>
<button
disabled={
!chosen ||
chosen.path == ".." ||
(filePicker.suffix != undefined &&
chosen.path.includes(".") &&
!isFileValid(chosen.path, filePicker.suffix)) ||
(!filePicker.allowFolders && !chosen.path.includes("."))
}
onClick={confirm}
>
{t`Select`}
</button>
{!localFsRoot && filePicker.allowLocal && (
<button onClick={loadLocal}>Select local file</button>
)}
{!localFsRoot && (
<button
onClick={downloadFolder}
data-tooltip={t`Download all files in this folder into a zip`}
disabled={chosen.path == "" || chosen.path.includes(".")}
>
{t`Download`}
</button>
)}
</footer>
</article>
</dialog>
);
};
+11
View File
@@ -0,0 +1,11 @@
import StatusLine from "./statusline";
const Footer = () => {
return (
<footer className="flex row justify-between">
<StatusLine />
</footer>
);
};
export default Footer;
+181
View File
@@ -0,0 +1,181 @@
import {
BaseContext,
useBaseContext,
} from "@nand2tetris/components/stores/base.context";
import { RefObject, useContext, useRef } from "react";
import { Link, useLocation } from "react-router-dom";
import { AppContext, useAppContext } from "src/App.context";
import { PageContext } from "src/Page.context";
import { Icon } from "../pico/icon";
import URLs, { LAST_ROUTE_KEY, TOOLS, URL } from "../urls";
interface HeaderButton {
tooltip: string;
icon: string;
href?: string;
tool?: string;
target?: JSX.Element;
onClick?: (context: HeaderButtonContext) => void;
}
interface HeaderButtonContext {
appContext: ReturnType<typeof useAppContext>;
baseContext: ReturnType<typeof useBaseContext>;
pathname: string;
}
function headerButtonFromURL(url: URL, icon: string, tooltip?: string) {
return {
href: url.href,
tool: url.tool,
tooltip:
tooltip ??
(url.tool && Object.keys(TOOLS).includes(url.tool)
? TOOLS[url.tool]
: ""),
icon,
target: url.target,
};
}
// When updating these, also edit service-worker.ts
const guideLinks: Record<string, string> = {
chip: "user_guide/chip.pdf",
cpu: "user_guide/cpu.pdf",
asm: "user_guide/asm.pdf",
vm: "user_guide/vm.pdf",
compiler: "user_guide/compiler.pdf",
bitmap: "user_guide/bitmap_editor.pdf",
};
const GUIDE_NOT_AVAILABLE_MESSAGE = "Guide not available for this tool";
async function openGuide(context: HeaderButtonContext) {
if (!guideLinks[context.pathname]) {
context.baseContext.setStatus(GUIDE_NOT_AVAILABLE_MESSAGE);
return;
}
const pdfLink = guideLinks[context.pathname];
const response = await fetch(pdfLink);
if (response.status === 404) {
context.baseContext.setStatus(GUIDE_NOT_AVAILABLE_MESSAGE);
return;
}
window.open(pdfLink, "_blank", "width=1000,height=800");
}
const headerButtons: HeaderButton[] = [
headerButtonFromURL(URLs["chip"], "memory"),
headerButtonFromURL(URLs["cpu"], "developer_board"),
headerButtonFromURL(URLs["asm"], "list_alt"),
headerButtonFromURL(URLs["vm"], "computer"),
// TODO(https://github.com/nand2tetris/web-ide/issues/349)
// reenable when this is resolved for Firefox and safari
// https://caniuse.com/?search=showDirectoryPicker
headerButtonFromURL(URLs["compiler"], "code"),
headerButtonFromURL(URLs["bitmap"], "grid_on"),
headerButtonFromURL(URLs["util"], "function", "Converter Tool"),
{
onClick: openGuide,
tooltip: `Guide`,
icon: "menu_book",
},
{
href: "https://github.com/nand2tetris/web-ide/issues/new/choose",
icon: "bug_report",
tooltip: "Bug Report",
},
{
onClick: (context) => {
context.appContext.settings.open();
},
icon: "settings",
tooltip: "Settings",
},
headerButtonFromURL(URLs["about"], "info", "About"),
];
const Header = () => {
const appContext = useContext(AppContext);
const baseContext = useContext(BaseContext);
const { title, setTool } = useContext(PageContext);
const { setStatus } = useContext(BaseContext);
const redirectRefs: Record<string, RefObject<HTMLAnchorElement>> = {};
for (const button of headerButtons) {
if (button.href) {
redirectRefs[button.href] = useRef<HTMLAnchorElement>(null);
}
}
const pathname = useLocation().pathname.replaceAll("/", "");
return (
<header>
<nav style={{ width: "100%" }}>
<ul>
<li>
<strong>
<a
href="https://nand2tetris.org"
target="_blank"
rel="noreferrer"
>
NAND2Tetris
</a>
</strong>
{TOOLS[pathname] && ` / ${TOOLS[pathname]}`}
{title && ` / ${title}`}
</li>
</ul>
<ul className="icon-list">
{headerButtons.map(
({ href, icon, onClick, tooltip, target, tool }) => {
return (
<li
key={icon}
data-tooltip={tooltip}
data-placement="bottom"
onClick={() => {
setTool(tool);
setStatus("");
if (onClick) {
onClick?.({ appContext, baseContext, pathname });
} else {
if (href) {
if (target) {
localStorage.setItem(LAST_ROUTE_KEY, href);
}
redirectRefs[href].current?.click();
}
}
}}
>
<Icon name={icon}></Icon>
{href &&
(target ? (
<Link
to={href}
ref={redirectRefs[href]}
style={{ display: "none" }}
/>
) : (
<a
href={href}
target="new"
ref={redirectRefs[href]}
style={{ display: "none" }}
/>
))}
</li>
);
},
)}
</ul>
</nav>
</header>
);
};
export default Header;
+15
View File
@@ -0,0 +1,15 @@
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
const publicUrl = (href: string) => {
href = href.replace("%25PUBLIC_URL%25", process.env.PUBLIC_URL);
return defaultUrlTransform(href);
};
const Markdown = ({ children }: { children: string }) => (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={publicUrl}>
{children}
</ReactMarkdown>
);
export default Markdown;
+46
View File
@@ -0,0 +1,46 @@
import { CSSProperties, ReactNode } from "react";
import "./../pico/accordion.scss";
export const Panel = (props: {
children: ReactNode;
header?: ReactNode;
footer?: ReactNode;
className?: string;
style?: CSSProperties;
isEditorPanel?: boolean;
}) => {
return (
<article
className={[
"panel",
props.className ?? "",
props.isEditorPanel ? "editor" : "",
].join(" ")}
>
{props.header && <header>{props.header}</header>}
<main>{props.children}</main>
{props.footer && <footer>{props.footer}</footer>}
</article>
);
};
export const Accordian = (props: {
children: ReactNode;
summary: ReactNode;
className?: string;
style?: CSSProperties;
open?: boolean;
}) => {
return (
<details
className={props.className ?? ""}
open={props.open}
style={props.style}
>
<summary>
<div className="flex row align-baseline">{props.summary}</div>
</summary>
{props.children}
</details>
);
};
+41
View File
@@ -0,0 +1,41 @@
.settings-dialog {
max-width: 900px;
min-height: 80vh;
[role="button"] {
width: 100%;
}
.storage-mode-selection {
display: flex;
flex-direction: column;
gap: var(--spacing);
}
.storage-option {
border: var(--border-width) solid #888;
border-radius: var(--border-radius);
padding: var(--spacing);
display: flex;
flex-direction: column;
gap: calc(var(--spacing) / 2);
}
}
dialog {
.message {
margin: var(--spacing);
}
.actions {
display: flex;
justify-content: space-around;
margin-top: var(--block-spacing-vertical);
gap: var(--spacing);
flex-wrap: wrap;
>button {
width: 100px;
}
}
}
+489
View File
@@ -0,0 +1,489 @@
import { i18n } from "@lingui/core";
import { Trans, t } from "@lingui/macro";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import { useContext, useEffect, useMemo, useState } from "react";
import { AppContext } from "../App.context";
import { useDialog } from "@nand2tetris/components/dialog";
import { PageContext } from "src/Page.context";
import "../pico/button-group.scss";
import "../pico/property.scss";
import "./settings.scss";
import { TrackingDisclosure } from "../tracking";
import { getVersion, setVersion } from "../versions";
const showUpgradeFs = true;
export const Settings = () => {
const { stores } = useContext(PageContext);
const {
fs,
setStatus,
canUpgradeFs,
upgradeFs,
closeFs,
localFsRoot,
permissionPrompt,
requestPermission,
loadFs,
} = useContext(BaseContext);
const { settings, monaco, theme, setTheme, tracking } =
useContext(AppContext);
const [storageMode, setStorageMode] = useState<"browser" | "pc">(
localFsRoot ? "pc" : "browser",
);
const [upgrading, setUpgrading] = useState(false);
useEffect(() => {
if (localFsRoot) {
setStorageMode("pc");
} else {
setStorageMode("browser");
}
}, [localFsRoot]);
const upgradeFsAction = async (createFiles?: boolean) => {
setUpgrading(true);
try {
await upgradeFs(localFsRoot != undefined, createFiles);
// If after upgrade attempt we still don't have a root (canceled), revert to browser
} catch (err) {
if ((err as Error).name === "AbortError") {
// User cancelled
setStorageMode("browser");
} else {
console.error("Failed to upgrade FS", { err });
setStatus(t`Failed to load local file system.`);
setStorageMode("browser");
}
} finally {
setUpgrading(false);
// If we finished and still don't have a root (e.g. cancelled without error if that's possible, or just didn't select),
// we might want to ensure we are consistent.
// However, since upgradeFs is async, we can't easily know the *result* state here immediately if it relies on context propagation.
// But usually if it fails/cancels, we want to be in browser mode.
}
};
const writeLocale = useMemo(
() => (locale: string) => {
if (localFsRoot) return;
i18n.activate(locale);
fs.writeFile("/locale", locale);
},
[fs],
);
useEffect(() => {
if (localFsRoot) return;
fs.readFile("/locale")
.then((locale) => i18n.activate(locale))
.catch(() => writeLocale("en"));
}, [fs, writeLocale]);
const resetWarning = useDialog();
const resetConfirm = useDialog();
const resetFiles = async () => {
const version = getVersion();
localStorage.clear();
setVersion(version);
localStorage["/chip/project"] = "01";
localStorage["/chip/chip"] = "Not";
const loaders = await import("@nand2tetris/projects/loader.js");
await loaders.resetFiles(fs);
stores.cpu.actions.clear();
stores.asm.actions.clear();
stores.vm.actions.initialize();
stores.compiler.actions.reset();
};
const permissionPromptDialog = (
<dialog open={permissionPrompt.isOpen}>
<article>
<main>
<div className="dialog-message">
{"Please grant permissions to use your local projects folder"}
</div>
<div className="dialog-actions">
<button
className="dialog-button"
onClick={async () => {
await requestPermission();
loadFs();
permissionPrompt.close();
}}
>
Ok
</button>
<button
className="dialog-button"
onClick={() => {
permissionPrompt.close();
}}
>
Cancel
</button>
</div>
</main>
</article>
</dialog>
);
const resetWarningDialog = (
<dialog open={resetWarning.isOpen}>
<article>
<main>
<div className="dialog-message">
{
'The "reset browser storage files" results in erasing all the project files stored in the browser memory (but not on your PC), replacing them with a fresh set of skeletal files. You may want to back-up your edited project files before resetting.'
}
</div>
<div className="dialog-actions">
<button
className="dialog-button"
onClick={async () => {
await resetFiles();
resetWarning.close();
resetConfirm.open();
}}
>
<Trans>Reset</Trans>
</button>
<button
className="dialog-button"
onClick={() => {
resetWarning.close();
}}
>
<Trans>Cancel</Trans>
</button>
</div>
</main>
</article>
</dialog>
);
const resetConfirmDialog = (
<dialog open={resetConfirm.isOpen}>
<article>
<header>
<Trans>Your files were reset</Trans>
</header>
<main>
<button onClick={resetConfirm.close}>
<Trans>Ok</Trans>
</button>
</main>
</article>
</dialog>
);
const closeWarning = useDialog();
const handleClose = () => {
if (storageMode === "pc" && !localFsRoot) {
closeWarning.open();
} else {
settings.close();
}
};
const closeWarningDialog = (
<dialog open={closeWarning.isOpen}>
<article>
<header>
<Trans>Incomplete Setup</Trans>
</header>
<main>
<div className="dialog-message">
<Trans>You chose use pc storage but didn't select a folder in your PC</Trans>
</div>
<div className="dialog-actions">
<button
onClick={() => {
closeWarning.close();
// Optionally trigger the file picker here? (Right now click himself...)
// The user might just want to go back to the settings to click it themselves.
// Let's just close the warning so they can click "Select Projects Folder".
}}
>
<Trans>Select Folder</Trans>
</button>
<button
className="secondary"
onClick={() => {
setStorageMode("browser");
closeWarning.close();
settings.close();
}}
>
<Trans>Use Browser Storage</Trans>
</button>
</div>
</main>
</article>
</dialog>
);
return (
<>
<dialog open={settings.isOpen}>
<article className="settings-dialog">
<header>
<p>
<Trans>Settings</Trans>
</p>
<a
className="close"
href="#root"
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
</header>
<main>
<dl>
{/* Storage Mode Selection */}
<dt>
<Trans>
Project files
</Trans>
<div style={{ marginTop: "3rem" }}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
window.open(
process.env.PUBLIC_URL + "/user_guide/file_system.pdf",
"_blank",
"width=1000,height=800"
);
}}
style={{ fontSize: "0.9rem" }}
>
<Trans>File System User Guide</Trans>
</a>
</div>
</dt>
<dd>
<div className="storage-mode-selection">
<div className="storage-option">
<label>
<input
type="radio"
name="storage-mode"
checked={storageMode === "browser"}
onChange={async () => {
setStorageMode("browser");
if (localFsRoot) {
await closeFs();
}
}}
/>
<Trans>Use browser storage</Trans>
</label>
</div>
<div className="storage-option">
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<label style={{ width: "auto" }}>
<input
type="radio"
name="storage-mode"
checked={storageMode === "pc"}
onChange={() => {
setStorageMode("pc");
// Do NOT trigger upgradeFsAction here anymore
}}
/>
<Trans>Use PC storage</Trans>
</label>
</div>
<div style={{ fontSize: "0.85rem", color: "#888", marginLeft: "1.8rem", marginTop: "-0.5rem", marginBottom: "0.5rem" }}>
<Trans>Works on Chrome, Edge, Opera, and other Chromium-based browsers</Trans>
</div>
<div
className="folder-location-row"
style={{
opacity: storageMode === "pc" ? 1 : 0.5,
pointerEvents: storageMode === "pc" ? "auto" : "none",
marginLeft: "2rem",
marginTop: "0.5rem",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", flexWrap: "wrap" }}>
<span>
<Trans>Projects folder location (on your PC)</Trans>:
</span>
{localFsRoot ? (
<code style={{ flex: "1", minWidth: "200px" }}>{localFsRoot}</code>
) : (
<span style={{ flex: "1", minWidth: "200px", fontStyle: "italic" }}>
<Trans>Not selected</Trans>
</span>
)}
{showUpgradeFs && canUpgradeFs && (
<button
disabled={upgrading}
onClick={async () => {
// Check if we are already in PC mode but no folder selected, or changing folder
// If we are in browser mode (shouldn't happen due to pointerEvents), switch to PC first?
// No, pointerEvents handles it.
try {
await upgradeFsAction();
// After action, check if we have a root.
// Note: localFsRoot might not be updated immediately in this closure.
// We rely on the catch block in upgradeFsAction to handle cancellation.
} catch (e) {
// Should be caught inside upgradeFsAction
}
}}
data-tooltip={t`Select the folder where the projects are stored on your PC`}
data-placement="bottom"
style={{ marginLeft: "auto" }}
>
{localFsRoot ? (
<Trans>Change folder</Trans>
) : (
<Trans>Select Projects Folder</Trans>
)}
</button>
)}
</div>
</div>
{/* Download Projects Folder */}
<div style={{
marginTop: "1rem",
marginLeft: "2rem",
opacity: storageMode === "pc" ? 1 : 0.5,
pointerEvents: storageMode === "pc" ? "auto" : "none",
}}>
<button
onClick={() => window.open("https://drive.google.com/open?id=1oD0WMJRq1UPEFEXWphKXR6paFwWpBS4o", "_blank")}
data-tooltip={t`Download (one time) before using PC Storage`}
data-placement="bottom"
style={{ width: "100%" }}
>
<Trans>Download the projects folder</Trans>
</button>
</div>
</div>
</div>
</dd>
<dt>
<Trans>Editor</Trans>
</dt>
<dd>
<label>
<input
type="checkbox"
name="switch"
role="switch"
checked={monaco.wants}
disabled={!monaco.canUse}
onChange={(e) => monaco.toggle(e.target.checked)}
/>
<Trans>Use Monaco Editor</Trans>
</label>
</dd>
{/* Theme Section */}
<dt>
<Trans>Theme</Trans>
</dt>
<dd>
<fieldset role="group">
<label role="button" aria-current={theme === "light"}>
<input
type="radio"
name="theme"
value="light"
checked={theme === "light"}
onChange={() => setTheme("light")}
/>
<Trans>Light</Trans>
</label>
<label role="button" aria-current={theme === "dark"}>
<input
type="radio"
name="theme"
value="dark"
checked={theme === "dark"}
onChange={() => setTheme("dark")}
/>
<Trans>Dark</Trans>
</label>
<label role="button" aria-current={theme === "system"}>
<input
type="radio"
name="theme"
value="system"
checked={theme === "system"}
onChange={() => setTheme("system")}
/>
<Trans>System</Trans>
</label>
</fieldset>
</dd>
{/* Divider for bottom section */}
<div style={{ borderTop: "1px solid var(--contrast-lower)", margin: "0.5rem 0 1rem 0" }} />
{/* Bottom Section - Utility Items */}
{!localFsRoot && (
<>
<dt>
<Trans>Browser Storage Reset</Trans>
</dt>
<dd>
<button
onClick={async () => {
resetWarning.open();
}}
>
<Trans>Reset browser storage files</Trans>
</button>
</dd>
</>
)}
<dt>
<Trans>References</Trans>
</dt>
<dd>
<div>
<a
href="https://nand2tetris.org"
target="_blank"
rel="noreferrer"
>
nand2tetris.org (course website)
</a>
</div>
<div>
<a
href="https://github.com/davidsouther/nand2tetris"
target="_blank"
rel="noreferrer"
>
nand2tetris/web-IDE on Github (open source)
</a>
</div>
</dd>
</dl>
</main>
</article>
</dialog>
{permissionPromptDialog}
{resetWarningDialog}
{resetConfirmDialog}
{closeWarningDialog}
</>
);
};
@@ -0,0 +1,33 @@
:root[data-theme="light"] {
--status-info-bg-color: inherit;
--status-success-bg-color: #aceebb;
--status-warning-bg-color: #f4e5b1;
--status-error-bg-color: #ffc1c0;
}
:root[data-theme="dark"] {
--status-info-bg-color: inherit;
--status-success-bg-color: #33792f;
--status-warning-bg-color: #7a6500;
--status-error-bg-color: #8e201a;
}
.StatusLine {
padding: 0.25rem 0.5rem;
&.status-info {
background-color: var(--status-info-bg-color);
}
&.status-success {
background-color: var(--status-success-bg-color);
}
&.status-warning {
background-color: var(--status-warning-bg-color);
}
&.status-error {
background-color: var(--status-error-bg-color);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { useContext } from "react";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import "./statusline.scss";
function StatusLine() {
const { status } = useContext(BaseContext);
const statusClass = `StatusLine status-${status.severity.toLowerCase()}`;
return <div className={statusClass}>{status.message}</div>;
}
export default StatusLine;
+98
View File
@@ -0,0 +1,98 @@
[role="tablist"] {
--border-style: solid;
--border: var(--border-width) var(--border-style) var(--card-border-color);
--border-empty: var(--border-width) var(--border-style)
var(--card-background-color);
--tab-count: 5;
--spacing-tab-edge: calc(var(--block-spacing-horizontal) / 2);
display: grid;
grid-template:
min-content 1fr /
var(--spacing-tab-edge) repeat(var(--tab-count), min-content) 1fr;
height: 100%;
}
[role="tablist"]::after,
[role="tablist"]::before {
content: "";
display: block;
border-bottom: var(--border);
min-width: var(--spacing-tab-edge);
grid-row: 1;
}
[role="tablist"]::before {
order: -1;
}
[role="tablist"]::after {
order: 1;
// flex: 1;
grid-column-end: -1;
}
[role="tab"] {
border: var(--border-empty);
border-bottom: var(--border);
order: 0;
height: min-content;
grid-row: 1;
white-space: nowrap;
}
label[role="tab"],
[role="tab"] label {
/* Ensure the padding is on the label, so the entire area is clickable */
padding-top: var(--form-element-spacing-vertical);
padding-bottom: calc(var(--form-element-spacing-vertical) / 2);
padding-left: var(--form-element-spacing-horizontal);
padding-right: var(--form-element-spacing-horizontal);
}
[role="tab"]:hover {
--border-color: var(--secondary-color);
border: var(--border);
}
[role="tab"]:has(:focus) {
--border-style: dotted;
}
[role="tab"]:has(:active) {
--border-style: dotted;
}
[role="tab"] [type="radio"] {
opacity: 0; // keep the radio button focusable, but not visible, to allow switching tabs with the arrow keys
margin-inline: -7px; // offset the gap created by the invisible radio button (7px value was adjusted experimentally)
}
[role="tab"][aria-selected="true"] {
border-top: var(--border);
border-left: var(--border);
border-right: var(--border);
border-bottom: var(--border-empty);
}
[role="tab"]:has(:checked) {
border-top: var(--border);
border-left: var(--border);
border-right: var(--border);
border-bottom: var(--border-empty);
}
[role="tabpanel"] {
display: none;
// order: 2;
grid-area: 2 / 1 / span 1 / -1;
}
[role="tab"][aria-selected="true"] + [role="tabpanel"] {
display: block;
flex-basis: 100%;
}
[role="tab"]:has(:checked) + [role="tabpanel"] {
display: block;
flex-basis: 100%;
}
+75
View File
@@ -0,0 +1,75 @@
import {
CSSProperties,
Children,
Dispatch,
PropsWithChildren,
ReactNode,
SetStateAction,
cloneElement,
useId,
useState,
} from "react";
export const Tab = (
props: PropsWithChildren<{
title: ReactNode;
style?: CSSProperties;
parent?: string;
checked?: boolean;
onSelect?: () => void;
}>,
) => {
const id = useId();
const tab = `tab-${id}`;
const panel = `panel-${id}`;
return (
<>
<div role="tab" id={tab} aria-controls={panel} style={props.style}>
<label>
{props.title}
<input
type="radio"
name={props.parent}
aria-controls={panel}
value={tab}
checked={props.checked}
onChange={(e) => e.target.checked == true && props.onSelect?.()}
/>
</label>
</div>
<div role="tabpanel" id={panel} aria-labelledby={tab}>
{props.children}
</div>
</>
);
};
export const TabList = (props: {
children: ReturnType<typeof Tab>[];
tabIndex?: { value: number; set: Dispatch<SetStateAction<number>> };
}) => {
const id = useId();
const [localSelectedIndex, localSetSelectedIndex] = useState(0);
const selectedIndex = props.tabIndex?.value ?? localSelectedIndex;
const setSelectedIndex = props.tabIndex?.set ?? localSetSelectedIndex;
return (
<section
role="tablist"
style={{ "--tab-count": props.children.length } as React.CSSProperties}
>
{Children.map(props.children, (child, index) =>
cloneElement(child, {
checked: index === selectedIndex,
parent: id,
idx: index,
onSelect: () => {
setSelectedIndex(index);
child.props?.onSelect?.();
},
}),
)}
</section>
);
};
+320
View File
@@ -0,0 +1,320 @@
import { isErr, unwrap } from "@davidsouther/jiffies/lib/esm/result";
import { Trans } from "@lingui/macro";
import {
DecorationType,
DiffDisplay,
generateDiffs,
} from "@nand2tetris/components/compare.js";
import { useDialog } from "@nand2tetris/components/dialog";
import { loadTestFiles } from "@nand2tetris/components/file_utils";
import { useStateInitializer } from "@nand2tetris/components/react";
import { RunSpeed, Runbar } from "@nand2tetris/components/runbar.js";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import { Span } from "@nand2tetris/simulator/languages/base";
import { CMP } from "@nand2tetris/simulator/languages/cmp.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { Timer } from "@nand2tetris/simulator/timer.js";
import {
Dispatch,
ReactNode,
RefObject,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AppContext } from "../App.context";
import { Editor } from "./editor";
import { isPath } from "./file_select";
import { Panel } from "./panel";
import { Tab, TabList } from "./tabs";
const WARNING_KEY = "skipTestEditWarning";
export const TestPanel = ({
runner: baseRunner,
tst: [tst, setTst, tstHighlight],
cmp: [cmp, setCmp],
out: [out],
tstName,
setPath,
disabled = false,
defaultTst,
defaultCmp,
showName = false,
showLoad = true,
showClear = false,
speed,
onSpeedChange,
prefix,
}: {
runner: RefObject<Timer | undefined>;
tst: [string, Dispatch<string>, Span | undefined];
cmp: [string, Dispatch<string>];
out: [string, Dispatch<string>];
tstName?: string;
setPath?: Dispatch<string>;
defaultTst?: string;
defaultCmp?: string;
showName?: boolean;
showLoad?: boolean;
showClear?: boolean;
disabled?: boolean;
speed?: RunSpeed;
onSpeedChange?: (speed: number) => void;
prefix?: ReactNode;
}) => {
const { fs, setStatus } = useContext(BaseContext);
const { filePicker, tracking } = useContext(AppContext);
const [showHighlight, setShowHighlight] = useState(true);
const runner = useRef<Timer>();
useEffect(() => {
runner.current = new (class ChipTimer extends Timer {
async reset(): Promise<void> {
await baseRunner.current?.reset();
setShowHighlight(true);
}
override finishFrame(): void {
super.finishFrame();
baseRunner.current?.finishFrame();
}
async tick(): Promise<boolean> {
setShowHighlight(true);
return (await baseRunner.current?.tick()) ?? false;
}
toggle(): void {
baseRunner.current?.toggle();
}
})();
return () => {
runner.current?.stop();
};
}, [baseRunner]);
const setSelectedTestTab = useCallback(
(tab: "tst" | "cmp" | "out" | "diff") => {
tracking.trackEvent("tab", "change", tab);
},
[tracking],
);
const [skipWarning, setSkipWarning] = useState(false);
const editDialog = useDialog();
const onChange = (test: string) => {
setTst(test);
setShowHighlight(false);
};
const clear = () => {
setTst(defaultTst ?? "");
setCmp(defaultCmp ?? "");
};
const [name, setName] = useStateInitializer(tstName ?? "");
const loadTest = useCallback(async () => {
const file = await filePicker.selectAllowLocal({
suffix: [".tst", ".cmp"]
});
if (isPath(file)) {
const files = await loadTestFiles(fs, file.path);
if (isErr(files)) {
setStatus(`Failed to load test`);
return;
}
setPath?.(file.path);
setName(file.path.split("/").pop() ?? "");
const { tst, cmp } = unwrap(files);
setTst?.(tst);
setCmp?.(cmp ?? "");
} else { // File uploaded
const selectedFiles = Array.isArray(file) ? file : [file];
const tstFile = selectedFiles.find(f => f.name.endsWith('.tst'));
const cmpFile = selectedFiles.find(f => f.name.endsWith('.cmp'));
if (tstFile) {
setTst?.(tstFile.content);
setName(tstFile.name);
setPath?.(tstFile.name);
}
if (cmpFile) {
setCmp?.(cmpFile.content);
}
}
}, [filePicker, setStatus, fs, setPath, setName, setTst, setCmp]);
const [diffDisplay, setDiffDisplay] = useState<DiffDisplay>();
useEffect(() => {
setDiffDisplay(generateDiffs(cmp, out));
}, [out, cmp]);
const editWarning = (
<dialog open={editDialog.isOpen}>
<article>
<header>Warning</header>
<main>
<p>
The test script can be edited during this IDE session. In the next
session, the original script will be restored.
<br />
</p>
<div style={{ display: "flex", flexDirection: "row" }}>
<input
type="checkbox"
checked={skipWarning}
onChange={(e) => {
setSkipWarning(e.target.checked);
}}
/>
<p>Do not show this again</p>
</div>
<p>
<br />
</p>
<button
onClick={() => {
if (skipWarning) {
localStorage.setItem(WARNING_KEY, "true");
}
editDialog.close();
}}
>
Ok
</button>
</main>
</article>
</dialog>
);
return (
<Panel
className="_test_panel"
header={
<>
<div>
<Trans>Test</Trans>
{showName && (name == "" ? ": Default" : `: ${name}`)}
</div>
{editWarning}
<div className="flex-1">
{runner.current && (
<Runbar
prefix={
<>
{prefix}
{showClear && (
<button className="flex-0" onClick={clear}>
Clear
</button>
)}
{showLoad && (
<button
className="flex-0"
onClick={loadTest}
data-tooltip="Load a test script"
data-placement="bottom"
>
📂
</button>
)}
</>
}
runner={runner.current}
disabled={disabled}
speed={speed}
onSpeedChange={onSpeedChange}
/>
)}
</div>
</>
}
>
<TabList>
<Tab title="Test Script" onSelect={() => setSelectedTestTab("tst")}>
<Editor
value={tst}
path={`${tstName??'test'}.tst`}
onChange={onChange}
grammar={TST.parser}
language={"tst"}
disabled={true}
highlight={showHighlight ? tstHighlight : undefined}
/>
</Tab>
<Tab title="Compare File" onSelect={() => setSelectedTestTab("cmp")}>
<Editor
value={cmp}
path={`${tstName??'test'}.cmp`}
onChange={setCmp}
grammar={CMP.parser}
language={"cmp"}
lineNumberTransform={(_) => ""}
disabled={true}
/>
</Tab>
<Tab title="Output File" onSelect={() => setSelectedTestTab("out")}>
{out == "" && <p>Execute test script to generate output.</p>}
<Editor
value={out}
onChange={() => {
return;
}}
language={"cmp"}
path={`${tstName??'test'}.out`}
disabled={true}
lineNumberTransform={(_) => ""}
/>
</Tab>
<Tab title="Diff Table" onSelect={() => setSelectedTestTab("diff")}>
{out == "" && <p>Execute test script to compare output.</p>}
{(diffDisplay?.failureNum ?? 0) > 0 && (
<p>
{diffDisplay?.failureNum} comparison failure
{diffDisplay?.failureNum === 1 ? "" : "s"}. Scroll down for
details
</p>
)}
<Editor
value={diffDisplay?.text ?? ""}
onChange={() => {
return;
}}
language={""}
path="test.txt"
disabled={true}
lineNumberTransform={(i) => diffDisplay?.lineNumbers[i - 1] ?? ""}
customDecorations={diffDisplay?.decorations.map((decoration) => {
return {
span: decoration.span,
cssClass: decorationTypeToCss(decoration.type),
};
})}
/>
</Tab>
</TabList>
</Panel>
);
};
function decorationTypeToCss(type: DecorationType) {
switch (type) {
case "error-line":
return "diff-highlight-error-line";
case "error-cell":
return "diff-highlight-error-cell";
case "correct-line":
return "diff-highlight-correct-line";
case "correct-cell":
return "diff-highlight-correct-cell";
default:
return "";
}
}

Some files were not shown because too many files have changed in this diff Show More