diff --git a/.github/workflows/publish-thurtle.yml b/.github/workflows/publish-thurtle.yml new file mode 100644 index 0000000..4fdb301 --- /dev/null +++ b/.github/workflows/publish-thurtle.yml @@ -0,0 +1,25 @@ +name: Publish Thurtle + +on: + workflow_dispatch: + +jobs: + build: + uses: ./.github/workflows/build.yml + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./.github/actions/setup + - run: sudo apt-get install awscli + - run: yarnpkg build + - name: aws s3 sync + run: | + aws configure set region eu-central-1 + aws configure set aws_access_key_id ${{secrets.AWS_ACCESS_KEY_ID}} + aws configure set aws_secret_access_key ${{secrets.AWS_SECRET_ACCESS_KEY}} + aws s3 sync public/thurtle/ s3://${{secrets.AWS_SITE_BUCKET}}/thurtle/ + aws s3 sync public/waforth/dist/ s3://${{secrets.AWS_SITE_BUCKET}}/waforth/dist/ --exclude "*" --include "thurtle-*" --include "logo-*.svg" --include "turtle-*.svg" + diff --git a/.gitignore b/.gitignore index 60d7cc0..458f4d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store node_modules/ public/waforth/ +public/thurtle/ src/waforth.bulkmem.wat src/waforth.vanilla.wat src/web/benchmarks/sieve/sieve-c.js diff --git a/README.md b/README.md index a234f41..137c553 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ WAForth is a small bootstrapping Forth interpreter and dynamic compiler for [WebAssembly](https://webassembly.org). You can see it in action -[here](https://mko.re/waforth/). +[in an interactive Forth console](https://mko.re/waforth/), and in [a Logo-like Turtle graphics language](https://mko.re/thurtle/). It is [entirely written in (raw) WebAssembly](https://github.com/remko/waforth/blob/master/src/waforth.wat), and diff --git a/build-web.js b/build-web.js index dc856b4..a6b5b1d 100755 --- a/build-web.js +++ b/build-web.js @@ -30,9 +30,7 @@ function withWatcher(config, handleBuildFinished = () => {}, port = 8880) { if (error) { console.error(error); } else { - // Doing this first, because this may do some ES5 transformations await handleBuildFinished(result); - watchClients.forEach((res) => res.write("data: update\n\n")); watchClients.length = 0; } @@ -62,16 +60,20 @@ let buildConfig = { path.join(__dirname, "src", "web", "tests", "tests"), path.join(__dirname, "src", "web", "benchmarks", "benchmarks"), path.join(__dirname, "src", "web", "examples", "prompt", "prompt"), + path.join(__dirname, "src", "web", "thurtle", "thurtle"), ], entryNames: dev ? "[name]" : "[name]-c$[hash]", assetNames: "[name]-c$[hash]", // target: "es6", outdir: path.join(__dirname, "public/waforth/dist"), + publicPath: "/waforth/dist", external: ["fs", "stream", "util", "events"], minify: !dev, loader: { ".wasm": "binary", ".js": "jsx", + ".fs": "text", + ".svg": "file", }, define: { WAFORTH_VERSION: watch @@ -118,9 +120,19 @@ async function handleBuildFinished(result) { ["tests", "public/waforth/tests"], ["benchmarks", "public/waforth/benchmarks"], ["prompt", "public/waforth/examples/prompt"], + ["thurtle", "public/thurtle", true], ]; - for (const [base, outpath] of indexes) { + for (const [base, outpath, bs] of indexes) { let index = INDEX_TEMPLATE.replace(/\$BASE/g, base); + if (bs) { + index = index.replace( + "", + ` + + +` + ); + } for (const [out] of Object.entries(result.metafile.outputs)) { const outfile = path.basename(out); const sourcefile = outfile.replace(/-c\$[^.]+\./, "."); @@ -145,7 +157,14 @@ if (watch) { } try { const data = await fs.promises.readFile(f); - res.writeHead(200); + res.writeHead( + 200, + req.url.endsWith(".svg") + ? { + "Content-Type": "image/svg+xml", + } + : undefined + ); res.end(data); } catch (err) { res.writeHead(404); diff --git a/src/web/thurtle/examples.ts b/src/web/thurtle/examples.ts new file mode 100644 index 0000000..5f51ded --- /dev/null +++ b/src/web/thurtle/examples.ts @@ -0,0 +1,83 @@ +export type Example = { + name: string; + program: string; +}; + +export default [ + { + name: "Square", + program: ` +200 FORWARD +90 RIGHT +200 FORWARD +90 RIGHT +200 FORWARD +90 RIGHT +200 FORWARD +90 RIGHT +`, + }, + { + name: "Square (w/ LOOP)", + program: ` +: SQUARE ( n -- ) + 4 0 DO + DUP FORWARD + 90 RIGHT + LOOP +; + +250 SQUARE +`, + }, + { + name: "Seeker", + program: ` +: SEEKER ( n -- ) + 4 0 DO + DUP FORWARD + PENUP + DUP FORWARD + PENDOWN + DUP FORWARD + 90 RIGHT + LOOP +; + +100 SEEKER +`, + }, + { + name: "Flower", + program: ` +: SQUARE ( n -- ) +4 0 DO + DUP FORWARD + 90 RIGHT +LOOP +; + +: FLOWER ( n -- ) + 12 0 DO + DUP SQUARE + 30 RIGHT + LOOP +; + +250 FLOWER +`, + }, + { + name: "Spiral (Recursive)", + program: ` +: SPIRAL ( n -- ) + DUP 1 < IF EXIT THEN + DUP FORWARD + 20 RIGHT + 95 100 */ RECURSE +; + +140 SPIRAL +`, + }, +].map((e) => ({ ...e, program: e.program.trimStart() })); diff --git a/src/web/thurtle/thurtle.css b/src/web/thurtle/thurtle.css new file mode 100644 index 0000000..2facf83 --- /dev/null +++ b/src/web/thurtle/thurtle.css @@ -0,0 +1,52 @@ +.root { + height: 100vh; +} + +.main { + height: calc(100% - 40px); +} + +textarea { + flex: 1; +} + +.navbar { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.world { + border: thin solid rgb(171, 208, 166); + border-radius: 0.5em; + background-color: rgb(221, 248, 221); + width: 100%; + max-height: 350px; +} + +.world path { + stroke: black; + fill: white; + fill-opacity: 0; +} + +textarea.program { + font-family: monospace; + font-size: 0.8em; +} + +.output { + font-size: 0.8em; + overflow: scroll; +} + +label { + font-weight: 500; +} + +.left-pane { + flex: 1; +} + +.right-pane { + flex: 2; +} diff --git a/src/web/thurtle/thurtle.fs b/src/web/thurtle/thurtle.fs new file mode 100644 index 0000000..7423cf9 --- /dev/null +++ b/src/web/thurtle/thurtle.fs @@ -0,0 +1,9 @@ +: FORWARD ( n -- ) S" forward" SCALL ; +: BACKWARD ( n -- ) NEGATE FORWARD ; +: LEFT ( n -- ) S" rotate" SCALL ; +: RIGHT ( n -- ) NEGATE LEFT ; +: PENDOWN ( -- ) 1 S" pen" SCALL ; +: PENUP ( -- ) 0 S" pen" SCALL ; +: HIDETURTLE ( -- ) 0 S" turtle" SCALL ; +: SHOWTURTLE ( -- ) 1 S" turtle" SCALL ; +: SETPENSIZE ( n -- ) S" setpensize" SCALL ; \ No newline at end of file diff --git a/src/web/thurtle/thurtle.ts b/src/web/thurtle/thurtle.ts new file mode 100644 index 0000000..ea28634 --- /dev/null +++ b/src/web/thurtle/thurtle.ts @@ -0,0 +1,264 @@ +import WAForth from "waforth"; +import "./thurtle.css"; +import turtle from "./turtle.svg"; +import logo from "../../../doc/logo.svg"; +import thurtleFS from "./thurtle.fs"; +import examples from "./examples"; + +const rootEl = document.createElement("div"); +rootEl.className = "root"; +rootEl.innerHTML = ` +
+
+

+ Interactive, Logo-like Turtle graphics language, using Forth (powered by + WAForth). +

+
+
+
+ + + +
+
+ + + + + + + +
+
+ +

+          
+
+
+
+
+ + `; +document.body.appendChild(rootEl); + +const turtleEl = document.getElementById("turtle")!; +let pathEl: SVGPathElement; +const patshEl = document.getElementById("paths")!; +const runButtonEl = document.querySelector( + "button[data-hook=run]" +)! as HTMLButtonElement; +const examplesEl = document.querySelector( + "[data-hook=examples]" +)! as HTMLSelectElement; +const programEl = document.querySelector("textarea") as HTMLTextAreaElement; +const outputEl = document.querySelector("pre") as HTMLPreElement; +(document.querySelector("img[data-hook=logo]")! as HTMLImageElement).src = logo; + +enum PenState { + Up = 0, + Down = 1, +} + +let rotation = 0; +let position = { x: 0, y: 0 }; +let pen = PenState.Down; +let visible = true; + +function newPathEl() { + pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path"); + pathEl.setAttribute("stroke-width", "5"); + pathEl.setAttribute("d", "M 0 0"); + patshEl.appendChild(pathEl); +} + +function reset() { + position.x = position.y = 0; + rotation = 270; + pen = PenState.Down; + patshEl.innerHTML = ""; + newPathEl(); + outputEl.innerHTML = ""; + updateTurtle(); +} + +function updateTurtle() { + turtleEl.style.display = visible ? "block" : "none"; + turtleEl.setAttribute( + "transform", + `rotate(${rotation} ${position.x} ${position.y}) translate(${ + position.x - 25 + } ${position.y - 25})` + ); +} + +function rotate(deg: number) { + rotation = rotation + deg; + updateTurtle(); +} + +function forward(d: number) { + const dx = d * Math.cos((rotation * Math.PI) / 180.0); + const dy = d * Math.sin((rotation * Math.PI) / 180.0); + pathEl.setAttribute( + "d", + pathEl.getAttribute("d")! + + " " + + [pen === PenState.Down ? "l" : "m", dx, dy].join(" ") + ); + + position.x += dx; + position.y += dy; + updateTurtle(); +} + +function setPen(s: PenState) { + pen = s; +} + +function setPenSize(s: number) { + newPathEl(); + pathEl.setAttribute("stroke-width", s + ""); +} + +function setVisible(b: boolean) { + visible = b; + updateTurtle(); +} + +function loadExample(name: string) { + programEl.value = examples.find((e) => e.name === name)!.program; + examplesEl.value = name; +} + +for (const ex of examples) { + const option = document.createElement("option"); + option.appendChild(document.createTextNode(ex.name)); + option.value = ex.name; + examplesEl.appendChild(option); +} +examplesEl.addEventListener("change", (ev) => { + loadExample((ev.target! as HTMLSelectElement).value); +}); + +async function run() { + try { + runButtonEl.disabled = true; + reset(); + + const forth = new WAForth(); + await forth.load(); + forth.bind("forward", (stack) => { + forward(stack.pop()); + }); + forth.bind("rotate", (stack) => { + rotate(-stack.pop()); + }); + forth.bind("pen", (stack) => { + setPen(stack.pop()); + }); + forth.bind("turtle", (stack) => { + setVisible(stack.pop() != 0); + }); + forth.bind("setpensize", (stack) => { + setPenSize(stack.pop()); + }); + forth.interpret(thurtleFS); + forth.onEmit = (c) => outputEl.appendChild(document.createTextNode(c)); + forth.interpret(programEl.value); + programEl.focus(); + } catch (e) { + console.error(e); + } finally { + runButtonEl.disabled = false; + } +} + +runButtonEl.addEventListener("click", () => run()); +document.addEventListener("keydown", (ev) => { + if (ev.key == "Enter" && (ev.metaKey || ev.ctrlKey)) { + run(); + } +}); + +reset(); + +loadExample(examples[1].name); diff --git a/src/web/thurtle/turtle.svg b/src/web/thurtle/turtle.svg new file mode 100644 index 0000000..445cdf3 --- /dev/null +++ b/src/web/thurtle/turtle.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/web/types/forth.d.ts b/src/web/types/forth.d.ts new file mode 100644 index 0000000..709a808 --- /dev/null +++ b/src/web/types/forth.d.ts @@ -0,0 +1,4 @@ +declare module "*.fs" { + const value: string; + export = value; +} diff --git a/src/web/types/images.d.ts b/src/web/types/images.d.ts new file mode 100644 index 0000000..8c14eed --- /dev/null +++ b/src/web/types/images.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const value: string; + export = value; +} diff --git a/tsconfig.json b/tsconfig.json index db8a9c4..a7cd698 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "target": "es2015", "typeRoots": ["./src/web/types"], "types": ["node"], - "baseUrl": "./src/web" + "baseUrl": "./src/web", + "allowSyntheticDefaultImports": true }, "include": ["./src/web"], "exclude": ["node_modules", "dist", "build"]