Web-Ide mit aufgenommen
@@ -0,0 +1 @@
|
||||
web-ide
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pico.css
|
||||
@@ -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 |
@@ -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>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 856 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 849 KiB |
|
After Width: | Height: | Size: 905 KiB |
|
After Width: | Height: | Size: 924 KiB |
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 884 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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.");
|
||||
@@ -0,0 +1 @@
|
||||
locales
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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"],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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"],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 project’s
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
{: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.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
Loading a chip with a 16-bit bus changes the pin panels to show a binary representation of the chip.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
Clicking an input bus will increment that bus by one.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
All tests pass!
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <</button>
|
||||
<button onClick={shiftRight}>Shift right ></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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 simulator’s 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 chip’s pin names are displayed in the middle panel, and (iii) the chip’s 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 chip’s interface, and a builtin implementation which is part of the simulator’s software. The builtin version allows users to experiment with the chip’s 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 chip’s 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 chip’s test script. If a pin’s 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} /> : <></>;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
input {
|
||||
font-family: var(--font-family-monospace);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import "./icon.scss";
|
||||
|
||||
export const Icon = ({ name }: { name: string }) => {
|
||||
return <span className="material-symbols-outlined">{name}</span>;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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 {};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import StatusLine from "./statusline";
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="flex row justify-between">
|
||||
<StatusLine />
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||