From 91b7598b22f89319f64054daf42c950de3eb6451 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Wed, 7 Jan 2026 19:29:30 -0800 Subject: Adding some of my favorite toys --- src/toys/turing/js/ui.js | 386 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 src/toys/turing/js/ui.js (limited to 'src/toys/turing/js/ui.js') 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 = `Error: ${error.message}`; + } + } + + 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 = `Error: ${error.message}`; + } + } + + this.loadProgram(this.currentProgramIndex); + } + + step() { + if (!this.machine) { + try { + this.compile(); + } catch (error) { + this.elements.stateText.innerHTML = `Error: ${error.message}`; + 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 = `Error: ${error.message}`; + 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 = `Accept(${status})`; + } else if (this.machine.isRejecting()) { + this.elements.stateText.innerHTML = `Reject(${status})`; + } else { + this.elements.stateText.innerHTML = `Halt(${status})`; + } + 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")); + } +} -- cgit v1.2.3-70-g09d2