diff --git a/README.md b/README.md index 02a23f2..e2e703b 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,8 @@ Every non-headless session shows a small panel bottom-left of the browser view: * a pause/play button (`||`/`▶`) — also bound to the spacebar; * a realtime-speed selector (Max/1x/0.5x/0.25x) — `1x` matches `env.launch(realtime=True)`, `Max` matches the default (`realtime=False`, uncapped). `env.launch(realtime=0.5)` (a specific float, not just `True`/`False`) sets an initial speed directly. +Pressing `s` anywhere in the browser tab (outside a text input) saves a screenshot of the current view, named `swift-YYYY-MM-DD_HH-MM-SS.png` — the same mechanism as `env.screenshot()`, just without a Python round-trip. + ## Recording video Any scene can be recorded straight from Python — call `env.start_recording(...)` around the part you want captured, `env.stop_recording()` when done. The `.webm` file downloads automatically once encoding finishes: diff --git a/docs/source/intro.rst b/docs/source/intro.rst index ac2eb7d..5f7cefc 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -79,6 +79,11 @@ view: (``realtime=False``, uncapped). ``env.launch(realtime=0.5)`` (a specific float, not just ``True``/``False``) sets an initial speed directly. +Pressing ``s`` anywhere in the browser tab (outside a text input) saves a +screenshot of the current view, named ``swift-YYYY-MM-DD_HH-MM-SS.png`` -- +the same mechanism as :meth:`~swift.Swift.Swift.screenshot`, just without a +Python round-trip. + See `Swift's own README `_ for a full set of worked examples of increasing complexity -- moving shapes with sliders, robots diff --git a/src/swift/public/js/main.js b/src/swift/public/js/main.js index 4ebea15..00005df 100644 --- a/src/swift/public/js/main.js +++ b/src/swift/public/js/main.js @@ -4,6 +4,7 @@ import { Slider, Button, Label, Select, Checkbox, Radio } from "./ui.js"; import { WebSocketTransport, portFromLocation, SWIFT_JS_VERSION } from "./comms.js"; import { Recorder } from "./recording.js"; import { FPS, SimTime } from "./hud.js"; +import { saveScreenshot, timestampedScreenshotName } from "./screenshot.js"; const { scene, camera, renderer, controls, axesHelper, ground, groundMaterial, lights } = createScene(); @@ -27,24 +28,24 @@ const UI_CLASSES = { slider: Slider, button: Button, label: Label, select: Selec // connects (see Swift.py's launch(browser_timeout=)). null means never. let autoCloseDelay = null; -function saveScreenshot(fileName) { - const link = document.createElement("a"); - link.download = `${fileName}.png`; - link.href = renderer.domElement.toDataURL("image/png"); - link.click(); -} - // launch()'s _add_controls() always adds Pause/Realtime/Render as the // first three elements, right after connecting and before any user code // runs -- so id 0 is reliably the pause button. Space just simulates a // click on it, reusing the exact same click -> "changed" -> shape_poses // response path a mouse click would take. window.addEventListener("keydown", (e) => { - if (e.code !== "Space" || e.target.tagName === "INPUT") return; - const pauseButton = uiElements.find((el) => el.id === 0); - if (pauseButton?.button) { - e.preventDefault(); - pauseButton.button.click(); + if (e.target.tagName === "INPUT") return; + + if (e.code === "Space") { + const pauseButton = uiElements.find((el) => el.id === 0); + if (pauseButton?.button) { + e.preventDefault(); + pauseButton.button.click(); + } + } else if (e.code === "KeyS" && !e.ctrlKey && !e.metaKey && !e.altKey) { + // Bare 's' -- Ctrl/Cmd+S is left alone so it still triggers the + // browser's own "Save Page As", rather than fighting it. + saveScreenshot(renderer.domElement, timestampedScreenshotName()); } }); @@ -186,7 +187,7 @@ transport.onMessage((func, data) => { break; } case "screenshot": { - saveScreenshot(data[0]); + saveScreenshot(renderer.domElement, data[0]); transport.send(0); break; } diff --git a/src/swift/public/js/screenshot.js b/src/swift/public/js/screenshot.js new file mode 100644 index 0000000..8a8c03d --- /dev/null +++ b/src/swift/public/js/screenshot.js @@ -0,0 +1,20 @@ +/** Screenshot saving: canvas -> PNG download, plus the auto-generated + * filename used by the 's' hotkey (env.screenshot()'s own file_name + * argument covers the explicit-name case). */ + +// Colons aren't valid in Windows filenames, so the timestamp uses dashes +// throughout rather than the `HH:MM:SS` grouping a clock display would use. +export function timestampedScreenshotName() { + const pad = (n) => String(n).padStart(2, "0"); + const now = new Date(); + const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const time = `${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; + return `swift-${date}_${time}`; +} + +export function saveScreenshot(canvas, fileName) { + const link = document.createElement("a"); + link.download = `${fileName}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); +} diff --git a/src/swift/public/js/screenshot.test.js b/src/swift/public/js/screenshot.test.js new file mode 100644 index 0000000..d611bac --- /dev/null +++ b/src/swift/public/js/screenshot.test.js @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { saveScreenshot, timestampedScreenshotName } from "./screenshot.js"; + +test("timestampedScreenshotName matches swift-YYYY-MM-DD_HH-MM-SS, no colons", (t) => { + const name = timestampedScreenshotName(); + assert.match(name, /^swift-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/); +}); + +test("saveScreenshot encodes the canvas as a PNG and clicks a download link", (t) => { + const originalDocument = globalThis.document; + let created; + globalThis.document = { + createElement: () => { + created = { clicked: false, click() { this.clicked = true; } }; + return created; + }, + }; + t.after(() => { + globalThis.document = originalDocument; + }); + + const canvas = { toDataURL: (type) => `data:${type};base64,stub` }; + saveScreenshot(canvas, "swift-2026-08-22_10-00-00"); + + assert.equal(created.download, "swift-2026-08-22_10-00-00.png"); + assert.equal(created.href, "data:image/png;base64,stub"); + assert.equal(created.clicked, true); +});