commit 6e065febafd93e6a82a23e6191653d6fb010cc75 Author: Chris Wanstrath Date: Tue Nov 4 19:09:57 2025 -0800 honu diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bdd52e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf1c281 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# 🐢 Honu + +LOGO-style turtle graphics powered by Shrimp. + +## Quickstart + +```bash +bun install +bun dev +``` + +Open http://localhost:3002 + +This builds the app and serves the `dist/` folder with Python's built-in HTTP server. + +## Building + +```bash +bun run build +``` + +Outputs a bundled browser app to `dist/`. The `dist/` folder contains: + +- `index.html` - The main page +- `app.js` - Bundled JavaScript (Parser + Compiler + ReefVM + CodeMirror) + +You can serve the `dist/` folder with any static file server, or even open `index.html` directly in a browser. + +## LOGO Commands + +### Movement +- `forward n` - Move forward n pixels +- `back n` - Move backward n pixels +- `left n` - Turn left n degrees +- `right n` - Turn right n degrees + +### Pen Control +- `penup` - Lift pen (don't draw) +- `pendown` - Lower pen (draw) +- `setcolor n` - Set pen color (0-15) +- `setwidth n` - Set pen width +- `clearscreen` - Clear the canvas + +### Turtle Control +- `home` - Return to center, heading up +- `setheading n` - Set heading to n degrees +- `setpos x y` - Set position to (x, y) +- `position` - Get current position + +### Control Flow +- `repeat n do: ... end` - Repeat commands n times + +### Misc +- `print value` - Print to console +- `wait n` - Wait n milliseconds +- `stop` - Stop execution + +## Usage + +1. Write Shrimp/LOGO code in the editor +2. Press **Run** (or Cmd+Enter) to execute +3. Watch the turtle draw on the canvas + +Example - Draw a square: + +```shrimp +repeat 4 do: + forward 100 + right 90 +end +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..48aefc7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,60 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "fin", + "dependencies": { + "@codemirror/language": "^6.11.3", + "@codemirror/view": "^6.38.3", + "@lezer/highlight": "^1.2.3", + "codemirror": "^6.0.2", + "shrimp": "git+https://git.nose.space/probablycorey/shrimp", + }, + }, + }, + "packages": { + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.19.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w=="], + + "@codemirror/language": ["@codemirror/language@6.11.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ=="], + + "@codemirror/search": ["@codemirror/search@6.5.11", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "crelt": "^1.0.5" } }, "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA=="], + + "@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="], + + "@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="], + + "@lezer/common": ["@lezer/common@1.3.0", "", {}, "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ=="], + + "@lezer/generator": ["@lezer/generator@1.8.0", "", { "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" }, "bin": { "lezer-generator": "src/lezer-generator.cjs" } }, "sha512-/SF4EDWowPqV1jOgoGSGTIFsE7Ezdr7ZYxyihl5eMKVO5tlnpIhFcDavgm1hHY5GEonoOAEnJ0CU0x+tvuAuUg=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/lr": ["@lezer/lr@1.4.3", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="], + + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + + "hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="], + + "reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#0f39e9401eb7a0a7c906e150127f9829458a79b6", { "peerDependencies": { "typescript": "^5" } }, "0f39e9401eb7a0a7c906e150127f9829458a79b6"], + + "shrimp": ["shrimp@git+https://git.nose.space/probablycorey/shrimp#d707ee7e6b074cc0d64179004e5b6cc8250e0c91", { "dependencies": { "@codemirror/view": "^6.38.3", "@lezer/generator": "^1.8.0", "bun-plugin-tailwind": "^0.0.15", "codemirror": "^6.0.2", "hono": "^4.9.8", "reefvm": "git+https://git.nose.space/defunkt/reefvm", "tailwindcss": "^4.1.11" } }, "d707ee7e6b074cc0d64179004e5b6cc8250e0c91"], + + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + + "tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..97904ed --- /dev/null +++ b/index.html @@ -0,0 +1,152 @@ + + + + + + + 🐢 Honu + + + + +
+
+

🐢 Honu

+
+
+ + + +
+
+
+
+
+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a0d56cf --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "fin", + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "bun build src/app.ts --outdir=dist --target=browser && cp index.html dist/index.html && sed -i '' 's|src=\"dist/app.js\"|src=\"app.js\"|g' dist/index.html", + "serve": "cd dist && python3 -m http.server 3002", + "dev": "bun run build && bun run serve" + }, + "dependencies": { + "@codemirror/language": "^6.11.3", + "@codemirror/view": "^6.38.3", + "@lezer/highlight": "^1.2.3", + "codemirror": "^6.0.2", + "shrimp": "git+https://git.nose.space/probablycorey/shrimp" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..fb12abd --- /dev/null +++ b/src/app.ts @@ -0,0 +1,318 @@ +import { EditorView, keymap } from '@codemirror/view' +import { EditorState, Prec } from '@codemirror/state' +import { defaultKeymap } from '@codemirror/commands' +import { basicSetup } from 'codemirror' +import { LRLanguage, LanguageSupport } from '@codemirror/language' +import { styleTags, tags } from '@lezer/highlight' +import { runCode as runShrimpCode, parser } from 'shrimp' + +const highlighting = styleTags({ + Identifier: tags.name, + Number: tags.number, + String: tags.string, + Boolean: tags.bool, + keyword: tags.keyword, + end: tags.keyword, + ':': tags.keyword, + Null: tags.keyword, + Regex: tags.regexp, + Operator: tags.operator, + Word: tags.variableName, + Command: tags.function(tags.variableName), + 'Params/Identifier': tags.definition(tags.variableName), + Paren: tags.paren, +}) + +const language = LRLanguage.define({ + parser: parser.configure({ props: [highlighting] }), +}) + +const shrimpLanguage = new LanguageSupport(language) + +const STORAGE_KEY_CODE = 'logo-turtle-code' +const STORAGE_KEY_SLOW_MODE = 'logo-turtle-slow-mode' + +const DEFAULT_CODE = `# Welcome to Fin - LOGO Turtle Graphics! +# Draw a square + +repeat 4 do: + forward 100 + right 90 +end` + +const loadSavedCode = (): string => { + const saved = localStorage.getItem(STORAGE_KEY_CODE) + return saved !== null ? saved : DEFAULT_CODE +} + +const saveCode = (code: string) => { + localStorage.setItem(STORAGE_KEY_CODE, code) +} + +const runCommand = { + key: 'Mod-Enter', + run: () => { + runCode() + return true + }, +} + +const startState = EditorState.create({ + doc: loadSavedCode(), + extensions: [ + basicSetup, + shrimpLanguage, + Prec.highest(keymap.of([runCommand])), + keymap.of(defaultKeymap), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + saveCode(update.state.doc.toString()) + } + }), + ], +}) + +const view = new EditorView({ + state: startState, + parent: document.getElementById('editor')!, +}) + +// UI elements +const canvas = document.getElementById('canvas') as HTMLCanvasElement +const ctx = canvas.getContext('2d')! +const turtleCanvas = document.getElementById('turtle-canvas') as HTMLCanvasElement +const turtleCtx = turtleCanvas.getContext('2d')! +const statusEl = document.getElementById('status')! +const slowModeCheckbox = document.getElementById('slow-mode') as HTMLInputElement + +// Set canvas size to fill container +const resizeCanvas = () => { + const rect = canvas.getBoundingClientRect() + canvas.width = rect.width + canvas.height = rect.height + turtleCanvas.width = rect.width + turtleCanvas.height = rect.height +} + +resizeCanvas() +window.addEventListener('resize', resizeCanvas) + +const showStatus = (text: string, type: 'success' | 'error' | 'info' = 'info') => { + statusEl.textContent = text + statusEl.className = `status ${type}` +} + +const loadSlowMode = (): boolean => { + const saved = localStorage.getItem(STORAGE_KEY_SLOW_MODE) + return saved === 'true' +} + +let slowMode = loadSlowMode() +slowModeCheckbox.checked = slowMode + +slowModeCheckbox.addEventListener('change', (e) => { + slowMode = (e.target as HTMLInputElement).checked + localStorage.setItem(STORAGE_KEY_SLOW_MODE, slowMode.toString()) +}) + +const maybeWait = async () => { + if (slowMode) { + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +class Turtle { + x: number + y: number + heading: number // degrees + penDown: boolean + color: string + lineWidth: number + + constructor() { + this.x = canvas.width / 2 + this.y = canvas.height / 2 + this.heading = 0 // pointing up + this.penDown = true + this.color = '#000000' + this.lineWidth = 2 + } + + async forward(distance: number) { + const radians = (this.heading - 90) * Math.PI / 180 + const newX = this.x + distance * Math.cos(radians) + const newY = this.y + distance * Math.sin(radians) + + if (this.penDown) { + ctx.beginPath() + ctx.moveTo(this.x, this.y) + ctx.lineTo(newX, newY) + ctx.strokeStyle = this.color + ctx.lineWidth = this.lineWidth + ctx.stroke() + } + + this.x = newX + this.y = newY + + if (slowMode) { + this.drawTurtle() + } + await maybeWait() + } + + async back(distance: number) { + await this.forward(-distance) + } + + async left(degrees: number) { + this.heading -= degrees + if (slowMode) { + this.drawTurtle() + } + await maybeWait() + } + + async right(degrees: number) { + this.heading += degrees + if (slowMode) { + this.drawTurtle() + } + await maybeWait() + } + + drawTurtle() { + // Clear turtle layer + turtleCtx.clearRect(0, 0, turtleCanvas.width, turtleCanvas.height) + + // Draw turtle on separate layer + turtleCtx.save() + turtleCtx.translate(this.x, this.y) + turtleCtx.rotate((this.heading - 90) * Math.PI / 180) + turtleCtx.font = '24px sans-serif' + turtleCtx.textAlign = 'center' + turtleCtx.textBaseline = 'middle' + turtleCtx.fillText('🐢', 0, 0) + turtleCtx.restore() + } + + penup() { + this.penDown = false + } + + pendown() { + this.penDown = true + } + + setcolor(color: number) { + // Simple color palette (0-15) + const colors = [ + '#000000', '#FF0000', '#00FF00', '#0000FF', + '#FFFF00', '#FF00FF', '#00FFFF', '#FFFFFF', + '#800000', '#008000', '#000080', '#808000', + '#800080', '#008080', '#808080', '#C0C0C0' + ] + this.color = colors[color % colors.length]! + } + + setwidth(width: number) { + this.lineWidth = width + } + + clearscreen() { + ctx.clearRect(0, 0, canvas.width, canvas.height) + turtleCtx.clearRect(0, 0, turtleCanvas.width, turtleCanvas.height) + } + + home() { + this.x = canvas.width / 2 + this.y = canvas.height / 2 + this.heading = 0 + } + + setheading(degrees: number) { + this.heading = degrees + } + + setpos(x: number, y: number) { + this.x = x + canvas.width / 2 + this.y = canvas.height / 2 - y + } + + position(): [number, number] { + return [ + this.x - canvas.width / 2, + canvas.height / 2 - this.y + ] + } +} + +let turtle = new Turtle() + +const globals = { + forward: async (n: number) => await turtle.forward(n), + back: async (n: number) => await turtle.back(n), + left: async (n: number) => await turtle.left(n), + right: async (n: number) => await turtle.right(n), + penup: () => turtle.penup(), + pendown: () => turtle.pendown(), + setcolor: (n: number) => turtle.setcolor(n), + setwidth: (n: number) => turtle.setwidth(n), + clearscreen: () => turtle.clearscreen(), + home: () => turtle.home(), + setheading: (n: number) => turtle.setheading(n), + setpos: (x: number, y: number) => turtle.setpos(x, y), + position: () => turtle.position(), + repeat: async (n: number, fn: Function) => { + for (let i = 0; i < n; i++) { + await fn(i) + } + }, + print: (value: any) => console.log(value), + wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)), + stop: () => { throw new Error('STOP') }, + end: () => null, +} + +const runCode = async () => { + const code = view.state.doc.toString() + + try { + showStatus('Parsing...', 'info') + + const tree = parser.parse(code) + + if (tree.cursor().node.type.isError) { + throw new Error('Parse error in code') + } + + showStatus('Compiling...', 'info') + + turtle = new Turtle() + ctx.clearRect(0, 0, canvas.width, canvas.height) + + await runShrimpCode(code, globals) + + showStatus('Running...', 'info') + + turtle.drawTurtle() + + showStatus('✓ Success', 'success') + + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error('Error:', message) + showStatus(`✗ ${message}`, 'error') + } +} + +document.getElementById('run-btn')!.addEventListener('click', runCode) +document.getElementById('clear-btn')!.addEventListener('click', () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + turtleCtx.clearRect(0, 0, turtleCanvas.width, turtleCanvas.height) + turtle = new Turtle() + showStatus('') +}) + +// Initial focus +view.focus() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..427a6eb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "types": ["bun"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}