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")); } }