diff --git a/CHANGELOG.md b/CHANGELOG.md index 62460d5..0f69db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.2.1] + +### Bug Fixes + +- Fixed `windowWidth`/`windowHeight` being undefined when accessed at top-level in `sketchPath` mode + +### Examples + +- Added terminal renderer CLI example (`ex-terminal-cli.js`) + ## [1.2.0] ### Breaking Changes diff --git a/README.md b/README.md index 9837821..32ef71d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ npm install @10k24/p5b ## Quick Start +**Inline mode** — define setup/draw callbacks directly: + ```javascript const { P5b } = require("@10k24/p5b"); @@ -40,6 +42,25 @@ p5b.on("frame", (buffer) => { p5b.run(); ``` +**Sketch file mode** — load a `.js` sketch file (defines `setup`/`draw` as globals): + +```javascript +const { P5b } = require("@10k24/p5b"); + +const p5b = new P5b({ + width: 400, + height: 400, + fps: 60, + sketchPath: "./my-sketch.js" +}); + +p5b.on("frame", (buffer) => { + // Process frame buffer +}); + +p5b.run(); +``` + ## API ### Constructor @@ -139,6 +160,7 @@ See [examples/](examples/) for runnable examples: - [examples/ex-file-based.js](examples/ex-file-based.js) — Loading sketch from file - [examples/ex-inline.js](examples/ex-inline.js) — Using setup/draw callbacks - [examples/ex-p5b-zmq.js](examples/ex-p5b-zmq.js) — ZeroMQ frame transport +- [examples/ex-terminal-cli.js](examples/ex-terminal-cli.js) — Render a p5.js sketch in the terminal using truecolor ANSI half-block characters. ## Buffer Format diff --git a/examples/README.md b/examples/README.md index d561058..6e54f1e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -40,7 +40,26 @@ The sketch draws an animated checkerboard pattern that scales from 400x400 (draw Press `Ctrl+C` to close the connection and exit. -## Notes +## Terminal Renderer (CLI) Examples -- Examples require dependencies installed separately and do NOT affect the core p5b library installation -- Each example is self-contained and can be run independently +Renders a p5.js sketch from a file in the terminal using truecolor ANSI half-block characters. + +**Usage:** + +```bash +node ex-terminal-cli.js +``` + +**Example:** + +```bash +node ex-terminal-cli.js sketch-rings.js +``` + +**Requirements:** +- Truecolor terminal (Ghostty, Kitty, iTerm2, WezTerm, etc.) +- A sketch file that defines `setup()` and `draw()` functions + +**Included Sketch:** `sketch-rings.js` - Animated concentric rings pattern + +Press `Ctrl+C` to exit. diff --git a/examples/ex-terminal-cli.js b/examples/ex-terminal-cli.js new file mode 100644 index 0000000..e2841a6 --- /dev/null +++ b/examples/ex-terminal-cli.js @@ -0,0 +1,54 @@ +// Render a p5.js sketch in the terminal using truecolor ANSI half-block characters. +// Works in any truecolor terminal (Ghostty, Kitty, iTerm2, WezTerm, etc.) +const { P5b } = require("../p5b.js"); + +const sketchPath = process.argv[2]; + +if (!sketchPath) { + console.error("Usage: node ex-terminal-cli.js "); + process.exit(1); +} + +// Set sketch size based on terminal dimensions +// Each character cell = 1 col × 2 rows of pixels, so windowHeight must be even. +// Reserve 1 row to prevent scroll (which breaks cursor-home positioning). +// +// Note: we must bootstrap these values here, which typically +// are defined in the p5b wrapped version of createCanvas(w, h) +global.windowWidth = process.stdout.columns || 80; +global.windowHeight = ((process.stdout.rows || 30) - 1) * 2; + +process.stdout.write("\x1b[?25l"); +process.on("exit", () => process.stdout.write("\x1b[?25h")); +process.on("SIGINT", () => { + process.stdout.write("\x1b[?25h"); + process.exit(); +}); + +const p5b = new P5b({ + width: windowWidth, + height: windowHeight, + framerate: 60, + sketchPath: sketchPath +}); + +function frameToAnsi(buf, w, h) { + const parts = ["\x1b[H"]; + for (let y = 0; y < h; y += 2) { + for (let x = 0; x < w; x++) { + const t = (y * w + x) * 4; + const b = ((y + 1) * w + x) * 4; + parts.push( + `\x1b[48;2;${buf[t]};${buf[t+1]};${buf[t+2]}m\x1b[38;2;${buf[b]};${buf[b+1]};${buf[b+2]}m▄` + ); + } + parts.push("\x1b[0m\n"); + } + return parts.join(""); +} + +p5b.on("frame", (buffer) => { + process.stdout.write(frameToAnsi(buffer, windowWidth, windowHeight)); +}); + +p5b.run(); diff --git a/examples/sketch-rings.js b/examples/sketch-rings.js new file mode 100644 index 0000000..9b9c665 --- /dev/null +++ b/examples/sketch-rings.js @@ -0,0 +1,62 @@ +// Concentric rings + +let hexColors = [ + "#95E06C", + "#FF785A", + "#094D92", + "#BBACC1" +]; + +let bgIdx = 0; +let fgIdx = 1; + +function setup() { + createCanvas(windowWidth, windowHeight); + noStroke(); +} + +function draw() { + background(hexColors[bgIdx]); + + const maxRadius = max( + sqrt(2 * windowWidth * windowWidth), + sqrt(2 * windowHeight * windowHeight) + ); + + const radius = (2 * frameCount) % maxRadius; + + const cX = windowWidth / 2; + const cY = windowHeight / 2; + + fill(hexColors[fgIdx]); + ellipse(cX, cY, radius * 1.5); + + fill(hexColors[bgIdx]); + ellipse(cX, cY, radius * 1.2); + + fill(hexColors[fgIdx]); + ellipse(cX, cY, radius); + + fill(hexColors[bgIdx]); + ellipse(cX, cY, radius * 0.8); + + fill(hexColors[fgIdx]); + ellipse(cX, cY, radius * 0.6); + + fill(hexColors[bgIdx]); + ellipse(cX, cY, radius / 2); + + fill(hexColors[fgIdx]); + ellipse(cX, cY, radius / 4); + + fill(hexColors[bgIdx]); + ellipse(cX, cY, radius / 8); + + fill(hexColors[fgIdx]); + ellipse(cX, cY, radius / 16); + + if (radius <= 1) { + bgIdx = fgIdx; + fgIdx = (fgIdx + 1) % hexColors.length; + } +} diff --git a/p5b.js b/p5b.js index 39be494..e70bced 100644 --- a/p5b.js +++ b/p5b.js @@ -226,6 +226,25 @@ class P5b extends EventEmitter { } } + // Bind windowWidth/windowHeight explicitly - they may not exist on p5 instance + // until createCanvas() is called, but should still be accessible + // Use ?? 0 fallback to match p5.js behavior before createCanvas() + Object.defineProperty(global, "windowWidth", { + get: () => this._myP5?.windowWidth ?? 0, + configurable: true + }); + Object.defineProperty(global, "windowHeight", { + get: () => this._myP5?.windowHeight ?? 0, + configurable: true + }); + + // Execute sketch if provided (overwrites globals) + if (this.sketchPath) { + const absoluteSketchPath = path.resolve(this.sketchPath); + const code = fs.readFileSync(absoluteSketchPath, "utf8"); + vm.runInThisContext(code, { filename: absoluteSketchPath }); + } + global._resolveAssetPath = function(sketchPath, filePath) { const assetDir = sketchPath ? path.dirname(path.resolve(sketchPath)) @@ -665,13 +684,6 @@ class P5b extends EventEmitter { global.preload = this.preload; global.setup = this.setup; global.draw = this.draw; - - // Execute sketch if provided (overwrites globals) - if (this.sketchPath) { - const absoluteSketchPath = path.resolve(this.sketchPath); - const code = fs.readFileSync(absoluteSketchPath, "utf8"); - vm.runInThisContext(code, { filename: absoluteSketchPath }); - } } } diff --git a/package.json b/package.json index de2bc73..79f17d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@10k24/p5b", - "version": "1.2.0", + "version": "1.2.1", "description": "Run p5.js sketches in Node.js and stream RGBA pixel buffers", "author": "10k24", "main": "p5b.js", diff --git a/templates/README.dot b/templates/README.dot index 1dfd996..d3dc418 100644 --- a/templates/README.dot +++ b/templates/README.dot @@ -18,10 +18,18 @@ npm install @10k24/p5b ## Quick Start +**Inline mode** — define setup/draw callbacks directly: + ```javascript {{=it.stubs.constructor}} ``` +**Sketch file mode** — load a `.js` sketch file (defines `setup`/`draw` as globals): + +```javascript +{{=it.stubs['constructor-sketchpath']}} +``` + ## API ### Constructor diff --git a/templates/stubs/readme-constructor-sketchpath.js b/templates/stubs/readme-constructor-sketchpath.js new file mode 100644 index 0000000..53fd7ed --- /dev/null +++ b/templates/stubs/readme-constructor-sketchpath.js @@ -0,0 +1,14 @@ +const { P5b } = require("@10k24/p5b"); + +const p5b = new P5b({ + width: 400, + height: 400, + fps: 60, + sketchPath: "./my-sketch.js" +}); + +p5b.on("frame", (buffer) => { + // Process frame buffer +}); + +p5b.run(); diff --git a/test/fixtures/sketches/window-size.js b/test/fixtures/sketches/window-size.js new file mode 100644 index 0000000..137adfc --- /dev/null +++ b/test/fixtures/sketches/window-size.js @@ -0,0 +1,9 @@ +// Test sketch: access windowWidth at top-level +const w = windowWidth; +const h = windowHeight; +global.window_width_at_top_level = w; +global.window_height_at_top_level = h; + +function setup() { + global.canvas_width = createCanvas(w || 100, h || 100).width; +} diff --git a/test/integration/integration.test.js b/test/integration/integration.test.js index f816789..58f0606 100644 --- a/test/integration/integration.test.js +++ b/test/integration/integration.test.js @@ -574,15 +574,19 @@ describe("P5b Integration - Buffer Analysis", () => { }); }); - it("should throw when sketchPath file does not exist", () => { - expect(() => { - new P5b({ - width: 32, - height: 32, - fps: 30, - sketchPath: "/nonexistent/path/sketch.js" - }); - }).toThrow(); + it("should throw when sketchPath file does not exist", (done) => { + const p5b = new P5b({ + width: 32, + height: 32, + fps: 30, + sketchPath: "/nonexistent/path/sketch.js" + }); + p5b.on("error", (err) => { + expect(err.error.message).toContain("ENOENT"); + p5b.stop(); + done(); + }); + p5b.run(); }); it("should emit error event when setup throws", (done) => { diff --git a/test/integration/sketches.test.js b/test/integration/sketches.test.js index 4ca7f0b..f4ebd17 100644 --- a/test/integration/sketches.test.js +++ b/test/integration/sketches.test.js @@ -33,12 +33,14 @@ describe("P5b Real Sketch - Shapes", () => { p5b.run(); }); +}); - it("should render non-black pixels in shapes sketch", (done) => { +describe("P5b Real Sketch - window dimensions", () => { + it("windowWidth and windowHeight should be accessible at top-level in sketchPath mode", (done) => { const p5b = new P5b({ - sketchPath: path.join(sketchesDir, "shapes.js"), - width: 64, - height: 64, + sketchPath: path.join(sketchesDir, "window-size.js"), + width: 200, + height: 200, fps: 30 }); @@ -48,17 +50,8 @@ describe("P5b Real Sketch - Shapes", () => { }); p5b.on("frame", (buffer) => { - let hasColor = false; - for (let i = 0; i < Math.min(buffer.length, 256); i += 4) { - const r = buffer[i]; - const g = buffer[i + 1]; - const b = buffer[i + 2]; - if (r > 0 || g > 0 || b > 0) { - hasColor = true; - break; - } - } - expect(hasColor).toBe(true); + expect(global.window_width_at_top_level).toBe(0); + expect(global.canvas_width).toBe(100); p5b.stop(); done(); }); @@ -67,7 +60,7 @@ describe("P5b Real Sketch - Shapes", () => { }); }); -describe("P5b Real Sketch - Graphics Pooling", () => { +describe("P5b Real Sketch - Shapes (colored)", () => { it("should render graphics sketch with createGraphics successfully", (done) => { const p5b = new P5b({ sketchPath: path.join(sketchesDir, "graphics.js"),