diff --git a/web/root/src/bus.ts b/web/root/src/bus.ts index 351e6e8..1c56cd0 100644 --- a/web/root/src/bus.ts +++ b/web/root/src/bus.ts @@ -1,6 +1,7 @@ // deno-lint-ignore-file import type { Machine } from "./automata.ts"; +import type { Example } from "./examples.ts"; import type { Sim, SimStepResult } from "./simulation.ts"; import type wasm from "./wasm.ts"; import type { Text } from "npm:@codemirror/state"; @@ -73,13 +74,16 @@ type AppEvents = { "automata/sim/after_step": { simulation: Sim, result: SimStepResult }; "automata/update": { automaton: Machine }; - "controls/physics": {enabled: boolean}, - "controls/reset_network": void, - + "example/selected": {example: Example}; - "controls/step_simulation": void, - "controls/reload_simulation": void, - "controls/clear_simulation": void, + "controls/editor/set_text": {text: string}; + + "controls/vis/physics": {enabled: boolean}; + "controls/vis/reset_network": void; + + "controls/sim/step": void; + "controls/sim/reload": void; + "controls/sim/clear": void; "theme/update": void; }; diff --git a/web/root/src/controls.ts b/web/root/src/controls.ts index ef8a8c0..16980f3 100644 --- a/web/root/src/controls.ts +++ b/web/root/src/controls.ts @@ -15,59 +15,6 @@ const speedLabel = document.getElementById("speedSimLabel") as HTMLSpanElement; const reloadSimBtn = document.getElementById("reloadSim") as HTMLButtonElement; const clearSimBtn = document.getElementById("clearSim") as HTMLButtonElement; -bus.on("controls/physics", ({ enabled }) => { - togglePhysicsBtn.classList.toggle("active", enabled); - togglePhysicsBtn.textContent = enabled ? "Physics: ON" : "Physics: OFF"; -}); - -togglePhysicsBtn.onclick = () => { - const enabled = !togglePhysicsBtn.classList.contains("active"); - bus.emit("controls/physics", { enabled }); -}; - -bus.emit("controls/physics", { - enabled: togglePhysicsBtn.classList.contains("active"), -}); - -resetLayoutBtn.onclick = () => bus.emit("controls/reset_network", undefined); - -clearSimBtn.onclick = () => bus.emit("controls/clear_simulation", undefined); - -stepBtn.onclick = () => { - bus.emit("controls/step_simulation", undefined); -}; - -reloadSimBtn.onclick = () => bus.emit("controls/reload_simulation", undefined); - -function updateButtons() { - stepBtn.disabled = !simulation_active || running; - playPauseBtn.disabled = !simulation_active; - clearSimBtn.disabled = !simulation_active; -} - -bus.on("controls/reload_simulation", (_) => { - if (running) setRunning(false); - updateButtons(); -}); - -bus.on("automata/sim/update", ({ simulation }) => { - simulation_active = !!simulation; - if (!simulation) { - if (running) setRunning(false); - } - updateButtons(); -}); - -bus.on("automata/sim/after_step", ({ result }) => { - if (result !== "pending") { - if (running) setRunning(false); - } -}); - -let simulation_active = false; -let running = false; -let timer: number | null = null; - // speed slider is "steps per second" function getStepsPerSecond() { return Math.max(1, Math.min(60, Number(speedSlider.value) || 10)); @@ -77,39 +24,82 @@ function updateSpeedUI() { } updateSpeedUI(); -speedSlider.addEventListener("input", () => { - updateSpeedUI(); - if (running) restartTimer(); -}); +class Controls { + static simulation_active = false; + static running = false; + static timer: number | null = null; -function stopTimer() { - if (timer !== null) { - clearInterval(timer); - timer = null; + static updateButtons() { + stepBtn.disabled = !Controls.simulation_active || Controls.running; + playPauseBtn.disabled = !Controls.simulation_active; + clearSimBtn.disabled = !Controls.simulation_active; + } + static setRunning(on: boolean) { + Controls.running = on; + playPauseBtn.textContent = Controls.running ? "⏸ Pause" : "▶ Play"; + playPauseBtn.classList.toggle("btn-primary", !Controls.running); + playPauseBtn.classList.toggle("btn-secondary", Controls.running); + + if (Controls.running) Controls.restartTimer(); + else Controls.stopTimer(); + Controls.updateButtons(); + } + static stop() { + if (Controls.running) Controls.setRunning(false); + } + static stopTimer() { + if (Controls.timer !== null) { + clearInterval(Controls.timer); + Controls.timer = null; + } + } + + static restartTimer() { + Controls.stopTimer(); + const sps = getStepsPerSecond(); + const intervalMs = Math.round(1000 / sps); + + Controls.timer = globalThis.window.setInterval(() => { + bus.emit("controls/sim/step", undefined); + }, intervalMs); + } + + static { + speedSlider.addEventListener("input", () => { + updateSpeedUI(); + if (Controls.running) Controls.restartTimer(); + }); + playPauseBtn.onclick = () => Controls.setRunning(!Controls.running); + resetLayoutBtn.onclick = () => + bus.emit("controls/vis/reset_network", undefined); + clearSimBtn.onclick = () => bus.emit("controls/sim/clear", undefined); + stepBtn.onclick = () => bus.emit("controls/sim/step", undefined); + reloadSimBtn.onclick = () => bus.emit("controls/sim/reload", undefined); + togglePhysicsBtn.onclick = () => { + const enabled = !togglePhysicsBtn.classList.contains("active"); + bus.emit("controls/vis/physics", { enabled }); + }; + + bus.on("controls/vis/physics", ({ enabled }) => { + togglePhysicsBtn.classList.toggle("active", enabled); + togglePhysicsBtn.textContent = enabled ? "Physics: ON" : "Physics: OFF"; + }); + + bus.on("controls/sim/reload", (_) => { + if (Controls.running) Controls.setRunning(false); + }); + + bus.on("automata/sim/update", ({ simulation }) => { + Controls.simulation_active = !!simulation; + if (!simulation) Controls.stop(); + }); + + bus.on("automata/sim/after_step", ({ result }) => { + if (result !== "pending") Controls.stop(); + }); + + bus.emit("controls/vis/physics", { + enabled: togglePhysicsBtn.classList.contains("active"), + }); } } - -function restartTimer() { - stopTimer(); - const sps = getStepsPerSecond(); - const intervalMs = Math.round(1000 / sps); - - timer = globalThis.window.setInterval(() => { - bus.emit("controls/step_simulation", undefined); - }, intervalMs); -} - -function setRunning(on: boolean) { - running = on; - playPauseBtn.textContent = running ? "⏸ Pause" : "▶ Play"; - playPauseBtn.classList.toggle("btn-primary", !running); - playPauseBtn.classList.toggle("btn-secondary", running); - - // Disable step while running (optional, but feels nice) - stepBtn.disabled = running; - - if (running) restartTimer(); - else stopTimer(); -} - -playPauseBtn.onclick = () => setRunning(!running); diff --git a/web/root/src/editor.ts b/web/root/src/editor.ts index e9d925f..d3594be 100644 --- a/web/root/src/editor.ts +++ b/web/root/src/editor.ts @@ -1,67 +1,71 @@ // deno-lint-ignore-file import { - EditorView, - keymap, - hoverTooltip, Decoration, - lineNumbers, + EditorView, + highlightActiveLine, highlightActiveLineGutter, - highlightActiveLine + hoverTooltip, + keymap, + lineNumbers, } from "npm:@codemirror/view"; import { EditorState, StateField, Text } from "npm:@codemirror/state"; -import { defaultKeymap, history, historyKeymap } from "npm:@codemirror/commands"; +import { + defaultKeymap, + history, + historyKeymap, +} from "npm:@codemirror/commands"; import { bracketMatching, indentOnInput } from "npm:@codemirror/language"; import { closeBrackets } from "npm:@codemirror/autocomplete"; +import wasm from "./wasm.ts"; -import wasm from "./wasm.ts" - -import { sharedText } from "./share.ts"; +import { Share } from "./share.ts"; import { examples } from "./examples.ts"; import { bus } from "./bus.ts"; - -function tokenize(text: string) { +function tokenize(text: string): wasm.Tok[] { try { return wasm.lex(text); } catch (e) { - console.log(e) - return [] + console.log(e); + return []; } } -function compile(text: string): wasm.CompileResult { +function compile( + text: string, +): { log: wasm.CompileLog[]; ansi_log: string; machine: string | undefined } { try { return wasm.compile(text); } catch (e) { console.log(e); - // @ts-expect-error wasm defines extra cleanup - return {log: [], log_formatted: "", graph: ""}; + return { log: [], ansi_log: "", machine: "" }; } } const eventBusConnection = StateField.define({ create(state) { const text = state.doc.toString(); - bus.emit("editor/change", {text, doc: state.doc}); + bus.emit("editor/change", { text, doc: state.doc }); return buildAnalysis(text, state.doc); }, update(value, tr) { if (!tr.docChanged) return value; const text = tr.state.doc.toString(); - bus.emit("editor/change", {text, doc: state.doc}); + bus.emit("editor/change", { text, doc: state.doc }); return buildAnalysis(text, tr.state.doc); }, provide: (f) => EditorView.decorations.from(f, (v) => v.deco), }); function buildAnalysis(text: string, doc: Text) { + save(text); const tokens = tokenize(text); const { log, ansi_log, machine } = compile(text); - bus.emit("compiled", {log, ansi_log, machine}) + bus.emit("compiled", { log, ansi_log, machine }); const marks = []; const docLen = doc.length; @@ -88,7 +92,9 @@ function buildAnalysis(text: string, doc: Text) { marks.push(Decoration.mark({ class: cls }).range(start, end)); } else { const end = Math.min(docLen, start + 1); - if (end > start) marks.push(Decoration.mark({ class: cls }).range(start, end)); + if (end > start) { + marks.push(Decoration.mark({ class: cls }).range(start, end)); + } } } @@ -96,8 +102,7 @@ function buildAnalysis(text: string, doc: Text) { return { tokens, log, ansi_log, deco }; } -const tokenClass = (t: string) => -({ +const tokenClass = (t: string) => ({ comment: "tok-comment", keyword: "tok-keyword", error: "tok-error", @@ -113,7 +118,6 @@ const tokenClass = (t: string) => rbracket: "rb-", }[t] || "tok-ident"); - function severityClass(sev: string) { const s = (sev || "error").toLowerCase(); if (s === "warning") return "cm-diag-warning"; @@ -129,10 +133,16 @@ function sevRank(sev: string) { // ===================== Hover tooltip (uses cached diags) ===================== const diagHover = hoverTooltip((view, pos) => { const { log } = view.state.field(eventBusConnection); - const hits = log.filter((d) => d.start !== undefined && d.end !== undefined && pos >= d.start && pos <= d.end); + const hits = log.filter((d) => + d.start !== undefined && d.end !== undefined && pos >= d.start && + pos <= d.end + ); if (hits.length === 0) return null; - const top = hits.reduce((a, b) => (sevRank(b.level) > sevRank(a.level) ? b : a), hits[0]); + const top = hits.reduce( + (a, b) => (sevRank(b.level) > sevRank(a.level) ? b : a), + hits[0], + ); return { pos, @@ -144,8 +154,9 @@ const diagHover = hoverTooltip((view, pos) => { const title = document.createElement("div"); title.className = `tipTitle ${top.level}`; - title.textContent = - hits.length === 1 ? top.level.toUpperCase() : `${top.level.toUpperCase()} (${hits.length})`; + title.textContent = hits.length === 1 + ? top.level.toUpperCase() + : `${top.level.toUpperCase()} (${hits.length})`; const body = document.createElement("div"); body.className = "tipBody"; @@ -162,24 +173,20 @@ const diagHover = hoverTooltip((view, pos) => { }; }); -function save(text: string){ +function save(text: string) { globalThis.localStorage.save = text; } -function getSaved(): string | undefined{ +function getSaved(): string | undefined { return globalThis.localStorage.save; } -export function setText(text: string){ - editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: text } }); -} - -export function getText(): string{ - return editor.state.doc.toString() +function defaultText(): string { + return Share.sharedText() ?? getSaved() ?? examples[0].machine; } const state = EditorState.create({ - doc: "", + doc: defaultText(), extensions: [ lineNumbers(), highlightActiveLineGutter(), @@ -202,4 +209,17 @@ const editor = new EditorView({ parent: document.getElementById("editor")!, }); -bus.on("begin", _ => setText(sharedText() ?? getSaved() ?? examples[0].machine)) \ No newline at end of file +bus.on( + "begin", + (_) => bus.emit("controls/editor/set_text", { text: defaultText() }), +); + +bus.on("controls/editor/set_text", ({ text }) => { + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: text }, + }); +}); + +bus.on("example/selected", ({ example }) => { + bus.emit("controls/editor/set_text", { text: example.machine }); +}); diff --git a/web/root/src/examples.ts b/web/root/src/examples.ts index eb9234b..6745845 100644 --- a/web/root/src/examples.ts +++ b/web/root/src/examples.ts @@ -1,4 +1,4 @@ -import { setText } from "./editor.ts"; +import { bus } from "./bus.ts"; export type Category = | "Tutorial" @@ -241,6 +241,6 @@ function buildExamplesDropdown( } const selectEl = document.getElementById("exampleSelect") as HTMLSelectElement; -buildExamplesDropdown(selectEl, examples, (picked) => { - setText(picked.machine); +buildExamplesDropdown(selectEl, examples, (example) => { + bus.emit("example/selected", {example}); }); diff --git a/web/root/src/share.ts b/web/root/src/share.ts index d9eaba4..715b3ce 100644 --- a/web/root/src/share.ts +++ b/web/root/src/share.ts @@ -1,41 +1,49 @@ -import { getText } from "./editor.ts"; +import { bus } from "./bus.ts"; -const btn = document.getElementById("shareBtn")!; -const toast = document.getElementById("shareToast")!; +export class Share { + private static readonly btn: HTMLButtonElement = document.getElementById( + "shareBtn", + )! as HTMLButtonElement; + private static readonly toast: HTMLElement = document.getElementById( + "shareToast", + )!; -function generateShareLink() { - return `${globalThis.window.location.href}?share=${encodeURIComponent(btoa(getText()))}`; -} + private static docText: string; + private static shareText: string; -async function copy(text: string) { - await navigator.clipboard.writeText(text); -} + static { + bus.on("editor/change", ({ text }) => Share.docText = text); -btn.addEventListener("click", async () => { - await copy(generateShareLink()); + Share.btn.onclick = async (_) => { + const link = `${globalThis.window.location.href}?share=${ + encodeURIComponent(btoa(Share.docText)) + }`; + await navigator.clipboard.writeText(link); - toast.classList.remove("show"); - void toast.offsetWidth; - toast.classList.add("show"); -}); + Share.toast.classList.remove("show"); + void Share.toast.offsetWidth; + Share.toast.classList.add("show"); + }; - -export function sharedText(): string|null { - try{ - const url = new URL(globalThis.window.location.href); - let text: string | null = url.searchParams.get("share"); - if (text !== null) { - text = atob(text); - url.searchParams.delete("share"); - globalThis.window.history.replaceState( - {}, - document.title, - url.pathname + url.search + url.hash - ); + try { + const url = new URL(globalThis.window.location.href); + let text: string | null = url.searchParams.get("share"); + if (text !== null) { + text = atob(text); + url.searchParams.delete("share"); + globalThis.window.history.replaceState( + {}, + document.title, + url.pathname + url.search + url.hash, + ); + Share.shareText = text; + } + } catch (e) { + console.log(e); } - return text; - }catch(e){ - console.log(e) } - return null; -} \ No newline at end of file + + public static sharedText(): string | null { + return Share.shareText; + } +} diff --git a/web/root/src/simulation.ts b/web/root/src/simulation.ts index 083885f..7e78a02 100644 --- a/web/root/src/simulation.ts +++ b/web/root/src/simulation.ts @@ -1,13 +1,13 @@ import { bus } from "./bus.ts"; -import { +import type { Fa, Machine, - parse_machine_from_json, Pda, State, Symbol, Tm, } from "./automata.ts"; +import {parse_machine_from_json} from "./automata.ts"; export type SimStepResult = "pending" | "accept" | "reject"; export type Sim = FaSim | PdaSim | TmSim; @@ -26,7 +26,7 @@ let automaton: Machine = { bus.on("compiled", ({ machine }) => { if (machine) { try { - bus.emit("controls/clear_simulation", undefined); + bus.emit("controls/sim/clear", undefined); automaton = parse_machine_from_json(machine); bus.emit("automata/update", { automaton }); } catch (e) { @@ -34,11 +34,11 @@ bus.on("compiled", ({ machine }) => { } } }); -bus.on("controls/clear_simulation", (_) => { +bus.on("controls/sim/clear", (_) => { simulation = null; bus.emit("automata/sim/update", { simulation: null }); }); -bus.on("controls/step_simulation", (_) => { +bus.on("controls/sim/step", (_) => { if (simulation) { bus.emit("automata/sim/before_step", { simulation }); bus.emit("automata/sim/after_step", { @@ -51,10 +51,10 @@ const machineInput = document.getElementById("machineInput") as HTMLInputElement machineInput.addEventListener("input", () => bus.emit("automata/sim/update", {simulation: null})); machineInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { - bus.emit("controls/reload_simulation", undefined) + bus.emit("controls/sim/reload", undefined) } }); -bus.on("controls/reload_simulation", (_) => { +bus.on("controls/sim/reload", (_) => { const input = machineInput.value; switch (automaton.type) { case "fa": diff --git a/web/root/src/visualizer.ts b/web/root/src/visualizer.ts index 8c34b15..0c4bae0 100644 --- a/web/root/src/visualizer.ts +++ b/web/root/src/visualizer.ts @@ -7,11 +7,11 @@ import type { Sim } from "./simulation.ts"; import type { Machine } from "./automata.ts"; -bus.on("controls/physics", ({enabled}) => { +bus.on("controls/vis/physics", ({enabled}) => { network.setOptions({ physics: { enabled } }); network.setOptions({edges: {smooth: enabled}}); }); -bus.on("controls/reset_network", _ => { +bus.on("controls/vis/reset_network", _ => { try { nodes.forEach((n) => { n.physics = true; @@ -285,17 +285,16 @@ function createGraph(): vis.Network { nodes: { shape: "custom", size: 18, - // // @ts-expect-error bad library - // chosen: { - // node: chosen_node, - // }, // @ts-expect-error bad library + chosen: { + node: chosen_node, + }, ctxRenderer: renderNode, }, edges: { chosen: { - // // @ts-expect-error bad library - // edge: chosen_edge, + // @ts-expect-error bad library + edge: chosen_edge, }, arrowStrikethrough: false, arrows: "to", @@ -304,9 +303,8 @@ function createGraph(): vis.Network { ); vis.DataSet; - network.on("doubleClick", (params: any) => { + network.on("doubleClick", (params: {nodes: string[]}) => { for (const node_id of params.nodes) { - // @ts-expect-error bad library const node: vis.Node = nodes.get(node_id)!; node.physics = !node.physics; nodes.update(node); @@ -325,7 +323,7 @@ function renderNode({ state: { selected, hover }, style, label, -}: {ctx: CanvasRenderingContext2D, id: string, x: number, y: number, state: {selected: boolean, hover: boolean}, style: any, label: string}) { +}: {ctx: CanvasRenderingContext2D, id: string, x: number, y: number, state: {selected: boolean, hover: boolean}, style: vis.NodeOptions, label: string}) { return { drawNode() { const t = getGraphTheme();