This commit is contained in:
Chris Wanstrath 2025-11-04 19:09:57 -08:00
commit 6e065febaf
7 changed files with 641 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store

71
README.md Normal file
View File

@ -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
```

60
bun.lock Normal file
View File

@ -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=="],
}
}

152
index.html Normal file
View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🐢 Honu</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
background: #1e1e1e;
color: #d4d4d4;
}
header {
padding: 1rem;
background: #252526;
border-bottom: 1px solid #3e3e42;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: #4ec9b0;
}
.subtitle {
font-size: 0.875rem;
color: #858585;
margin-left: 1rem;
}
.container {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
}
.left-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid #3e3e42;
}
.right-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
}
#canvas {
width: 100%;
height: 100%;
}
.editor-container {
flex: 1;
overflow: auto;
}
.cm-editor {
height: 100%;
}
.controls {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
background: #0e639c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.2s;
}
button:hover {
background: #1177bb;
}
button:active {
background: #0d5689;
}
.status {
font-size: 0.75rem;
color: #858585;
padding: 0.25rem 0.5rem;
background: #1e1e1e;
border-radius: 4px;
}
.status.success {
color: #4ec9b0;
}
.status.error {
color: #f48771;
}
</style>
</head>
<body>
<header>
<div style="display: flex; align-items: center;">
<h1>🐢 Honu</h1>
</div>
<div class="controls">
<button id="run-btn">Run (Cmd+Enter)</button>
<button id="clear-btn">Clear</button>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;">
<input type="checkbox" id="slow-mode" style="cursor: pointer;">
Slow Draw
</label>
<div class="status" id="status"></div>
</div>
</header>
<div class="container">
<div class="left-pane">
<div class="editor-container" id="editor"></div>
</div>
<div class="right-pane" id="right-pane" style="position: relative;">
<canvas id="canvas"></canvas>
<canvas id="turtle-canvas" style="position: absolute; top: 0; left: 0; pointer-events: none;"></canvas>
</div>
</div>
<script type="module" src="dist/app.js"></script>
</body>
</html>

17
package.json Normal file
View File

@ -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"
}
}

318
src/app.ts Normal file
View File

@ -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()

20
tsconfig.json Normal file
View File

@ -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"]
}