summaryrefslogtreecommitdiff
path: root/src/toys/turing/js/ui.js
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2026-01-07 19:29:30 -0800
committerElizabeth Hunt <me@liz.coffee>2026-01-07 19:29:30 -0800
commit91b7598b22f89319f64054daf42c950de3eb6451 (patch)
treeb337ad01c75e7ee88f287eda05522e72dd9a8dd5 /src/toys/turing/js/ui.js
parent49012297ea792a69501b74d8d83bd4be44d177da (diff)
downloadlizdotcoffee-91b7598b22f89319f64054daf42c950de3eb6451.tar.gz
lizdotcoffee-91b7598b22f89319f64054daf42c950de3eb6451.zip
Adding some of my favorite toys
Diffstat (limited to 'src/toys/turing/js/ui.js')
-rw-r--r--src/toys/turing/js/ui.js386
1 files changed, 386 insertions, 0 deletions
diff --git a/src/toys/turing/js/ui.js b/src/toys/turing/js/ui.js
new file mode 100644
index 0000000..ae01a4b
--- /dev/null
+++ b/src/toys/turing/js/ui.js
@@ -0,0 +1,386 @@
+import { Tape } from "./tape.js";
+import { TuringMachine } from "./machine.js";
+import { parseInstructionSet } from "./parser.js";
+import { EXAMPLE_PROGRAMS } from "./samples.js";
+
+const SCROLL_THRESHOLD = 3;
+
+export class TuringMachineUI {
+ constructor() {
+ this.machine = null;
+ this.editor = null;
+ this.intervalId = null;
+ this.isRunning = false;
+ this.initialTapeSize = 50;
+ this.blankSymbol = "B";
+ this.currentProgramIndex = 0;
+ this.loadedFromURL = false;
+ this.urlTapeState = "";
+ this.lastScrollPosition = 0;
+ this.renderedTapeLength = 0;
+ this.simulationInterval = 200;
+
+ this.elements = {
+ tape: document.getElementById("tape"),
+ stateText: document.getElementById("state-text"),
+ runBtn: document.getElementById("run-btn"),
+ stepBtn: document.getElementById("step-btn"),
+ resetBtn: document.getElementById("reset-btn"),
+ copyBtn: document.getElementById("copy-btn"),
+ programSelect: document.getElementById("program-select")
+ };
+
+ this.init();
+ }
+
+ init() {
+ this.setupEditor();
+ this.populateProgramSelect();
+ this.setupEventListeners();
+ this.loadFromURL();
+ }
+
+ setupEditor() {
+ this.editor = adelieEditor.init("#code-editor", {
+ language: "javascript"
+ });
+
+ this.editor.dom.addEventListener("input", () => {
+ this.machine = null;
+ });
+ }
+
+ populateProgramSelect() {
+ if (!this.elements.programSelect) {
+ return;
+ }
+
+ this.elements.programSelect.innerHTML = "";
+ EXAMPLE_PROGRAMS.forEach((program, index) => {
+ const option = document.createElement("option");
+ option.value = index.toString();
+ option.textContent = program.name;
+ this.elements.programSelect.appendChild(option);
+ });
+ }
+
+ setupEventListeners() {
+ this.elements.runBtn.addEventListener("click", () => this.toggleRun());
+ this.elements.stepBtn.addEventListener("click", () => this.step());
+ this.elements.resetBtn.addEventListener("click", () => this.reset());
+ this.elements.copyBtn.addEventListener("click", () => this.copyState());
+
+ this.elements.programSelect.addEventListener("change", (event) => {
+ this.loadProgram(parseInt(event.target.value, 10));
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (event.ctrlKey && event.key === "Enter") {
+ event.preventDefault();
+ this.reset();
+ setTimeout(() => this.run(), 0);
+ }
+ });
+
+ this.elements.tape.addEventListener("input", (event) => this.handleTapeInput(event));
+ this.elements.tape.addEventListener("focusin", () => {
+ if (this.isRunning) {
+ this.pause();
+ }
+ });
+ }
+
+ handleTapeInput(event) {
+ if (!this.machine) {
+ return;
+ }
+ const target = event.target;
+ if (!(target instanceof HTMLInputElement)) {
+ return;
+ }
+
+ const parentCell = target.closest(".cell");
+ if (!parentCell) {
+ return;
+ }
+
+ const index = Number(parentCell.dataset.index);
+ if (Number.isNaN(index)) {
+ return;
+ }
+
+ const sanitized = (target.value || this.blankSymbol).slice(0, 1);
+ target.value = sanitized;
+ this.machine.tape.writeAt(index, sanitized || this.blankSymbol);
+ }
+
+ loadFromURL() {
+ const urlParams = new URLSearchParams(window.location.search);
+ const startState = urlParams.get("start") ?? "";
+ const instructions = urlParams.get("instructions");
+
+ if (!instructions) {
+ this.loadProgram(0);
+ return;
+ }
+
+ try {
+ const code = atob(instructions);
+ this.setEditorContent(code);
+ this.loadedFromURL = true;
+ this.urlTapeState = startState;
+ this.compile(this.urlTapeState);
+ } catch (error) {
+ console.error("Failed to load from URL", error);
+ this.loadedFromURL = false;
+ this.loadProgram(0);
+ }
+ }
+
+ setEditorContent(content) {
+ const length = this.editor.state.doc.toString().length;
+ this.editor.dispatch({
+ changes: { from: 0, to: length, insert: content }
+ });
+ }
+
+ getEditorContent() {
+ return this.editor.state.doc.toString();
+ }
+
+ loadProgram(index = 0) {
+ const program = EXAMPLE_PROGRAMS[index];
+ if (!program) {
+ return;
+ }
+
+ this.loadedFromURL = false;
+ this.currentProgramIndex = index;
+ this.elements.programSelect.value = index.toString();
+ this.setEditorContent(program.code);
+
+ try {
+ this.compile(program.initialTape);
+ } catch (error) {
+ this.elements.stateText.innerHTML = `<span class="text-error">Error: ${error.message}</span>`;
+ }
+ }
+
+ compile(initialTape = "") {
+ this.pause();
+
+ const code = this.getEditorContent();
+ const instructionSet = parseInstructionSet(code);
+ const tapeSeed = initialTape || EXAMPLE_PROGRAMS[this.currentProgramIndex]?.initialTape || "";
+
+ const tape = new Tape({
+ initialContent: tapeSeed,
+ blankSymbol: this.blankSymbol,
+ minLength: Math.max(this.initialTapeSize, tapeSeed.length + 40)
+ });
+
+ this.machine = new TuringMachine({
+ tape,
+ rules: instructionSet.rules,
+ startState: instructionSet.startState,
+ acceptStates: instructionSet.acceptStates,
+ rejectStates: instructionSet.rejectStates
+ });
+
+ this.renderedTapeLength = 0;
+ this.lastScrollPosition = tape.getHeadIndex();
+ this.updateStateDisplay();
+ this.updateTape(true);
+ }
+
+ reset() {
+ this.pause();
+
+ if (this.loadedFromURL) {
+ try {
+ this.compile(this.urlTapeState);
+ return;
+ } catch (error) {
+ this.elements.stateText.innerHTML = `<span class="text-error">Error: ${error.message}</span>`;
+ }
+ }
+
+ this.loadProgram(this.currentProgramIndex);
+ }
+
+ step() {
+ if (!this.machine) {
+ try {
+ this.compile();
+ } catch (error) {
+ this.elements.stateText.innerHTML = `<span class="text-error">Error: ${error.message}</span>`;
+ return;
+ }
+ }
+
+ const canContinue = this.machine.step();
+ this.updateStateDisplay();
+ this.updateTape();
+
+ if (!canContinue) {
+ this.pause();
+ }
+ }
+
+ toggleRun() {
+ if (this.isRunning) {
+ this.pause();
+ } else {
+ this.run();
+ }
+ }
+
+ run() {
+ if (this.isRunning) {
+ return;
+ }
+
+ if (!this.machine) {
+ try {
+ this.compile();
+ } catch (error) {
+ this.elements.stateText.innerHTML = `<span class="text-error">Error: ${error.message}</span>`;
+ return;
+ }
+ }
+
+ this.isRunning = true;
+ this.elements.runBtn.textContent = "⏸ Pause";
+ this.elements.runBtn.classList.remove("primary");
+
+ this.intervalId = setInterval(() => {
+ const canContinue = this.machine.step();
+ this.updateStateDisplay();
+ this.updateTape();
+ if (!canContinue) {
+ this.pause();
+ }
+ }, this.simulationInterval);
+ }
+
+ pause() {
+ if (!this.isRunning) {
+ return;
+ }
+
+ this.isRunning = false;
+ clearInterval(this.intervalId);
+ this.intervalId = null;
+
+ this.elements.runBtn.textContent = "▶ Run (Ctrl + Enter)";
+ this.elements.runBtn.classList.add("primary");
+ }
+
+ updateStateDisplay() {
+ if (!this.machine) {
+ this.elements.stateText.textContent = "State: _, Step: 0";
+ return;
+ }
+
+ const status = this.machine.getStateStatus();
+
+ if (!this.machine.canStep()) {
+ if (this.machine.isAccepting()) {
+ this.elements.stateText.innerHTML = `<span class="text-success">Accept(${status})</span>`;
+ } else if (this.machine.isRejecting()) {
+ this.elements.stateText.innerHTML = `<span class="text-error">Reject(${status})</span>`;
+ } else {
+ this.elements.stateText.innerHTML = `<span class="text-error">Halt(${status})</span>`;
+ }
+ return;
+ }
+
+ this.elements.stateText.textContent = status;
+ }
+
+ updateTape(forceRender = false) {
+ if (!this.machine) {
+ return;
+ }
+
+ const tape = this.machine.tape;
+ if (forceRender || this.renderedTapeLength !== tape.length) {
+ this.renderTape();
+ }
+
+ const cells = this.elements.tape.querySelectorAll(".cell");
+ const headIndex = tape.getHeadIndex();
+
+ cells.forEach((cell, index) => {
+ cell.dataset.index = index.toString();
+ const input = cell.querySelector("input");
+ const value = tape.getCell(index);
+ if (input.value !== value) {
+ input.value = value;
+ }
+
+ if (index === headIndex) {
+ cell.classList.add("active");
+ this.maybeScrollIntoView(cell, headIndex, forceRender);
+ } else {
+ cell.classList.remove("active");
+ }
+ });
+ }
+
+ renderTape() {
+ const fragment = document.createDocumentFragment();
+ const tape = this.machine.tape;
+
+ for (let i = 0; i < tape.length; i++) {
+ fragment.appendChild(this.createCell(i, tape.getCell(i)));
+ }
+
+ this.elements.tape.innerHTML = "";
+ this.elements.tape.appendChild(fragment);
+ this.renderedTapeLength = tape.length;
+ }
+
+ createCell(index, value) {
+ const cell = document.createElement("div");
+ cell.classList.add("cell");
+ cell.dataset.index = index.toString();
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.maxLength = 1;
+ input.value = value;
+
+ cell.appendChild(input);
+ return cell;
+ }
+
+ maybeScrollIntoView(cell, headIndex, forceImmediate = false) {
+ if (forceImmediate) {
+ cell.scrollIntoView({ behavior: "auto", block: "nearest", inline: "center" });
+ this.lastScrollPosition = headIndex;
+ return;
+ }
+
+ if (Math.abs(headIndex - this.lastScrollPosition) < SCROLL_THRESHOLD) {
+ return;
+ }
+
+ cell.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
+ this.lastScrollPosition = headIndex;
+ }
+
+ copyState() {
+ if (!this.machine) {
+ return;
+ }
+
+ const tapeState = this.machine.tape.getContents();
+ const instructions = btoa(this.getEditorContent());
+ const url = `${window.location.href.split("?")[0]}?start=${tapeState}&instructions=${instructions}`;
+
+ navigator.clipboard.writeText(url)
+ .then(() => alert("State copied to clipboard!"))
+ .catch(() => alert("Failed to copy to clipboard"));
+ }
+}