From 22ef00912275f6ca91cb7ba889f1ddea4176f6e7 Mon Sep 17 00:00:00 2001 From: Parker TenBroeck <51721964+ParkerTenBroeck@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:29:08 -0500 Subject: [PATCH 1/3] started adding simulators --- web/root/src/automata.ts | 168 ++++++++++++++++++++++++++++++++----- web/root/src/controls.ts | 14 +--- web/root/src/editor.ts | 3 +- web/root/src/examples.ts | 33 +++++++- web/root/src/visualizer.ts | 40 ++------- 5 files changed, 190 insertions(+), 68 deletions(-) diff --git a/web/root/src/automata.ts b/web/root/src/automata.ts index c22eef7..e291461 100644 --- a/web/root/src/automata.ts +++ b/web/root/src/automata.ts @@ -1,3 +1,5 @@ +import { updateVisualization } from "./visualizer.ts"; + export type Machine = Fa | Pda | Tm; export function machine_from_json(json: string): Machine { @@ -26,16 +28,16 @@ export function machine_from_json(json: string): Machine { for (const [from, tos] of machine.transitions) { for (const to of tos) { const layer_0 = machine.transitions_components; - if(!layer_0.has(from.state)) layer_0.set(from.state, new Map()); + if (!layer_0.has(from.state)) layer_0.set(from.state, new Map()); const layer_1 = machine.transitions_components.get(from.state)!; - if(!layer_1.has(from.letter)) layer_1.set(from.letter, []); + if (!layer_1.has(from.letter)) layer_1.set(from.letter, []); const layer_2 = layer_1.get(from.letter)!; layer_2.push(to); const edge = from.state + "#" + to.state; if (!machine.edges.has(edge)) machine.edges.set(edge, []); machine.edges.get(edge)?.push({ - repr: from.letter?from.letter:"ε", + repr: from.letter ? from.letter : "ε", function: to.function, transition: to.transition, }); @@ -49,18 +51,19 @@ export function machine_from_json(json: string): Machine { for (const [from, tos] of machine.transitions) { for (const to of tos) { const layer_0 = machine.transitions_components; - if(!layer_0.has(from.state)) layer_0.set(from.state, new Map()); + if (!layer_0.has(from.state)) layer_0.set(from.state, new Map()); const layer_1 = machine.transitions_components.get(from.state)!; - if(!layer_1.has(from.symbol)) layer_1.set(from.symbol, new Map()); + if (!layer_1.has(from.symbol)) layer_1.set(from.symbol, new Map()); const layer_2 = layer_1.get(from.symbol)!; - if(!layer_2.has(from.letter)) layer_2.set(from.letter, []); + if (!layer_2.has(from.letter)) layer_2.set(from.letter, []); const layer_3 = layer_2.get(from.letter)!; layer_3.push(to); const edge = from.state + "#" + to.state; if (!machine.edges.has(edge)) machine.edges.set(edge, []); machine.edges.get(edge)?.push({ - repr: (from.letter?from.letter:"ε")+","+from.symbol+"->["+to.stack+"]", + repr: (from.letter ? from.letter : "ε") + "," + from.symbol + + "->[" + to.stack + "]", function: to.function, transition: to.transition, }); @@ -74,16 +77,16 @@ export function machine_from_json(json: string): Machine { for (const [from, tos] of machine.transitions) { for (const to of tos) { const layer_0 = machine.transitions_components; - if(!layer_0.has(from.state)) layer_0.set(from.state, new Map()); + if (!layer_0.has(from.state)) layer_0.set(from.state, new Map()); const layer_1 = machine.transitions_components.get(from.state)!; - if(!layer_1.has(from.symbol)) layer_1.set(from.symbol, []); + if (!layer_1.has(from.symbol)) layer_1.set(from.symbol, []); const layer_2 = layer_1.get(from.symbol)!; layer_2.push(to); const edge = from.state + "#" + to.state; if (!machine.edges.has(edge)) machine.edges.set(edge, []); machine.edges.get(edge)?.push({ - repr: from.symbol+"->"+to.symbol+","+to.direction, + repr: from.symbol + "->" + to.symbol + "," + to.direction, function: to.function, transition: to.transition, }); @@ -107,7 +110,7 @@ export type SymbolInfo = { definition: Span }; export type FaTransFrom = { state: State; - letter: Letter|null; + letter: Letter | null; }; export type FaTransTo = { @@ -132,14 +135,14 @@ export type Fa = { final_states: Map; transitions: Map; - transitions_components: Map>; + transitions_components: Map>; edges: Map; }; export type PdaTransFrom = { state: State; - letter: Letter|null; + letter: Letter | null; symbol: Symbol; }; @@ -162,7 +165,10 @@ export type Pda = { final_states: Map | null; transitions: Map; - transitions_components: Map>>; + transitions_components: Map< + State, + Map> + >; edges: Map; }; @@ -197,12 +203,136 @@ export type Tm = { edges: Map; }; - export type FaState = { - state: State, - position: number + state: State; + position: number; +}; + +export class FaSim { + step(): string { + return ""; + } } -export class FaSim{ +export type PdaState = { + state: State; + stack: Symbol[]; + position: number; +}; -} \ No newline at end of file +export class PdaSim { + machine: Pda; + paths: PdaState[]; + input: string; + + constructor(machine: Pda, input: string) { + this.machine = machine; + this.paths = [{ + state: machine.initial_state, + stack: [machine.initial_stack], + position: 0, + }]; + this.input = input; + } + + step(): string { + const paths = []; + console.log(this.paths); + for (const path of this.paths) { + if ( + path.position == this.input.length && this.machine.final_states && + this.machine.final_states.has(path.state) + ) return "accept"; + if ( + path.position == this.input.length && !this.machine.final_states && + path.stack.length == 1 && path.stack[0] == this.machine.initial_stack + ) return "accept"; + + const stack = path.stack.pop()!; + const letter_map = this.machine.transitions_components.get(path.state) + ?.get(stack); + if (!letter_map) continue; + + for (const to of letter_map.get(null) ?? []) { + paths.push({ + state: to.state, + position: path.position, + stack: path.stack.concat(to.stack), + }); + } + + if (path.position >= this.input.length) continue; + + const char = this.input.charAt(path.position); + + for (const to of letter_map.get(char) ?? []) { + paths.push({ + state: to.state, + position: path.position + 1, + stack: path.stack.concat(to.stack), + }); + } + } + this.paths = paths; + return paths.length == 0 ? "reject" : "pending"; + } +} + +export type Sim = FaSim | PdaSim | null +export let sim: Sim = null; + +export let automaton: Machine = { + type: "fa", + alphabet: new Map(), + final_states: new Map(), + initial_state: "", + states: new Map(), + transitions: new Map(), + transitions_components: new Map(), + edges: new Map(), +}; + +export function clearSimulation(){ + setSimulation(null); +} + +export function setSimulation(sim_: Sim){ + sim = sim_; +} + +export function setAutomaton(auto: Machine) { + automaton = auto; + sim = null; + updateVisualization() +} + +export function clearAutomaton() { + setAutomaton({ + type: "fa", + alphabet: new Map(), + final_states: new Map(), + initial_state: "", + states: new Map(), + transitions: new Map(), + transitions_components: new Map(), + edges: new Map(), + }); +} + +export function stepSimulation(): void { + if (sim) { + console.log(sim.step()); + } +} + +export function resetSimulation(): void { + switch (automaton.type) { + case "fa": + break; + case "pda": + setSimulation(new PdaSim(automaton as Pda, "aabb")); + break; + case "tm": + break; + } +} diff --git a/web/root/src/controls.ts b/web/root/src/controls.ts index 58a6f38..d54fe85 100644 --- a/web/root/src/controls.ts +++ b/web/root/src/controls.ts @@ -1,3 +1,4 @@ +import { resetSimulation, stepSimulation } from "./automata.ts"; import {nodes, edges, network} from "./visualizer.ts" const togglePhysicsBtn = document.getElementById("togglePhysics") as HTMLButtonElement; @@ -9,14 +10,6 @@ const speedLabel = document.getElementById("speedLabel") as HTMLSpanEle const resetSimBtn = document.getElementById("resetSim") as HTMLButtonElement; -function stepSimulation(): void { - console.log("step"); -} - -function resetSimulation(): void { - console.log("reset"); -} - // ---- Physics toggle (styled label) ---- function setPhysicsButtonUI(enabled: boolean) { togglePhysicsBtn.classList.toggle("active", enabled); @@ -106,12 +99,7 @@ stepBtn.onclick = () => { }; resetSimBtn.onclick = () => { - // Stop if running if (running) setRunning(false); - - // Reset resetSimulation(); - - // Optional: re-enable Step after reset stepBtn.disabled = false; }; \ No newline at end of file diff --git a/web/root/src/editor.ts b/web/root/src/editor.ts index 344ca61..334d0b8 100644 --- a/web/root/src/editor.ts +++ b/web/root/src/editor.ts @@ -20,8 +20,7 @@ import { closeBrackets } from "npm:@codemirror/autocomplete"; import wasm from "./wasm.ts" import { terminalPlugin } from "./terminal.ts"; -import { setAutomaton } from "./visualizer.ts"; -import { machine_from_json } from "./automata.ts"; +import { machine_from_json, setAutomaton } from "./automata.ts"; import { sharedText } from "./share.ts"; import { examples } from "./examples.ts"; diff --git a/web/root/src/examples.ts b/web/root/src/examples.ts index 7987d8e..f9cb9c4 100644 --- a/web/root/src/examples.ts +++ b/web/root/src/examples.ts @@ -104,9 +104,40 @@ d(qeq, b, z0) = (qmb, z0) d(qmb, b, z0) = (qmb, z0)`, ), + new Example( "NPDA", - "unequal", + "palindrome", + `type=NPDA +Q = {q0, q1} // states +E = {a, b} // alphabet +T = {z0, A, B} // stack +q0 = q0 +z0 = z0 + +// push letters we see to stack +d(q0, a, z0) = (q0, [A z0]) +d(q0, b, z0) = (q0, [B z0]) + +d(q0, a, A) = (q0, [A A]) +d(q0, b, A) = (q0, [B A]) + +d(q0, a, B) = (q0, [A B]) +d(q0, b, B) = (q0, [B B]) + +// transition to q1 +d(q0, epsilon, z0) = { (q1, z0) } +d(q0, epsilon, A) = { (q1, A) } +d(q0, epsilon, B) = { (q1, B) } + +// consume stack until empty +d(q1, a, A) = { (q1, epsilon) } +d(q1, b, B) = { (q1, epsilon) }`, + ), + + new Example( + "NPDA", + "kleen star stack", `type=NPDA Q = {q0, q1} // states E = {a, b} // alphabet diff --git a/web/root/src/visualizer.ts b/web/root/src/visualizer.ts index dc0daaf..292776c 100644 --- a/web/root/src/visualizer.ts +++ b/web/root/src/visualizer.ts @@ -3,7 +3,7 @@ // deno-lint-ignore no-import-prefix import * as vis from "npm:vis-network/standalone"; import { StateEffect } from "npm:@codemirror/state"; -import { Machine } from "./automata.ts"; +import { automaton, Machine, setAutomaton } from "./automata.ts"; import { getText } from "./editor.ts"; export const nodes = new vis.DataSet(); @@ -125,30 +125,6 @@ export function updateGraphTheme() { setAutomaton(automaton) } -let automaton: Machine = { - type: "fa", - alphabet: new Map(), - final_states: new Map(), - initial_state: "", - states: new Map(), - transitions: new Map(), - transitions_components: new Map(), - edges: new Map(), -}; - -export function clearAutomaton() { - automaton = { - type: "fa", - alphabet: new Map(), - final_states: new Map(), - initial_state: "", - states: new Map(), - transitions: new Map(), - transitions_components: new Map(), - edges: new Map(), - }; -} - let _measureCanvas: HTMLCanvasElement | null = null; @@ -163,9 +139,7 @@ export function measureTextWidth(text: string, font: string): number { return ctx.measureText(text).width; } -export function setAutomaton(auto: Machine) { - automaton = auto; - +export function updateVisualization() { // Populate nodes for (const state of automaton.states.keys()) { @@ -186,7 +160,7 @@ export function setAutomaton(auto: Machine) { } // Populate edges - for (const [edge_id, transitions] of auto.edges) { + for (const [edge_id, transitions] of automaton.edges) { const to_from = edge_id.split("#"); const vadjust = -getGraphTheme().edge_font_size * Math.floor(transitions.length / 2); @@ -202,7 +176,7 @@ export function setAutomaton(auto: Machine) { font, from: to_from[0], to: to_from[1], - label: transitions.map(i => i.repr).join(auto.type=="fa"?",":"\n"), + label: transitions.map(i => i.repr).join(automaton.type=="fa"?",":"\n"), }); } else { edges.add({ @@ -210,19 +184,19 @@ export function setAutomaton(auto: Machine) { font, from: to_from[0], to: to_from[1], - label: transitions.map(i => i.repr).join(auto.type=="fa"?",":"\n"), + label: transitions.map(i => i.repr).join(automaton.type=="fa"?",":"\n"), }); } } for (const edge_id of edges.getIds()) { - if (!auto.edges.has(edge_id as string)) { + if (!automaton.edges.has(edge_id as string)) { edges.remove(edge_id); } } for (const node_id of nodes.getIds()) { - if (!auto.states.has(node_id as string)) { + if (!automaton.states.has(node_id as string)) { nodes.remove(node_id); } } From c7309a75d976f8aed918e7d85c9eddf37c73e506 Mon Sep 17 00:00:00 2001 From: ParkerTenBroeck <51721964+ParkerTenBroeck@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:14:39 -0500 Subject: [PATCH 2/3] simulation --- web/root/src/automata.ts | 160 ++++++++++++++++++++++++++++--------- web/root/src/visualizer.ts | 53 ++++++------ 2 files changed, 146 insertions(+), 67 deletions(-) diff --git a/web/root/src/automata.ts b/web/root/src/automata.ts index e291461..1fb8349 100644 --- a/web/root/src/automata.ts +++ b/web/root/src/automata.ts @@ -1,4 +1,4 @@ -import { updateVisualization } from "./visualizer.ts"; +import { network, updateVisualization } from "./visualizer.ts"; export type Machine = Fa | Pda | Tm; @@ -203,21 +203,58 @@ export type Tm = { edges: Map; }; -export type FaState = { - state: State; - position: number; +export type SimStepResult = "pending" | "accept" | "reject"; + +export class FaState { + readonly state: State; + + readonly position: number; + readonly input: string; + readonly accepted: boolean = false; + private repr!: string; + + constructor(state: State, position: number, input: string){ + this.state=state; + this.position=position; + this.input = input; + } + + toString(): string{ + if(!this.repr) this.repr = this.state + " " + this.position; + return this.repr + } }; export class FaSim { - step(): string { - return ""; + + current_states: Map = new Map(); + accepted: FaState[] = [] + + step(): SimStepResult { + return "pending"; } } -export type PdaState = { - state: State; - stack: Symbol[]; - position: number; +export class PdaState { + readonly state: State; + readonly stack: Symbol[]; + + readonly position: number; + readonly input: string; + readonly accepted: boolean = false + private repr!: string; + + constructor(state: State, stack: Symbol[], position: number, input: string){ + this.state=state; + this.stack=stack; + this.position=position; + this.input = input; + } + + toString(): string{ + if(!this.repr) this.repr = this.state + " [" + this.stack + "]" + " " + this.position; + return this.repr + } }; export class PdaSim { @@ -225,28 +262,43 @@ export class PdaSim { paths: PdaState[]; input: string; + current_states: Map = new Map(); + accepted: PdaState[] = [] + constructor(machine: Pda, input: string) { this.machine = machine; - this.paths = [{ - state: machine.initial_state, - stack: [machine.initial_stack], - position: 0, - }]; + this.paths = [new PdaState(machine.initial_state, [machine.initial_stack], 0, input)]; + this.current_states.set(machine.initial_state, [this.paths[0]]) this.input = input; } - step(): string { - const paths = []; - console.log(this.paths); + step(): SimStepResult { + if (this.paths.length == 0) return "reject"; + if (this.accepted.length != 0) return "accept"; + + const paths: PdaState[] = []; + this.current_states.clear(); + + const push = (state: PdaState) => { + paths.push(state); + if (!this.current_states.has(state.state)) this.current_states.set(state.state, []); + this.current_states.get(state.state)?.push(state); + + if ( + state.position == this.input.length && this.machine.final_states && + this.machine.final_states.has(state.state) + || + state.position == this.input.length && !this.machine.final_states && + state.stack.length == 1 && state.stack[0] == this.machine.initial_stack + ) { + + // @ts-expect-error sillllyyyy + state.accepted = true + this.accepted.push(state); + } + }; + for (const path of this.paths) { - if ( - path.position == this.input.length && this.machine.final_states && - this.machine.final_states.has(path.state) - ) return "accept"; - if ( - path.position == this.input.length && !this.machine.final_states && - path.stack.length == 1 && path.stack[0] == this.machine.initial_stack - ) return "accept"; const stack = path.stack.pop()!; const letter_map = this.machine.transitions_components.get(path.state) @@ -254,11 +306,7 @@ export class PdaSim { if (!letter_map) continue; for (const to of letter_map.get(null) ?? []) { - paths.push({ - state: to.state, - position: path.position, - stack: path.stack.concat(to.stack), - }); + push(new PdaState(to.state, path.stack.concat(to.stack), path.position, this.input)); } if (path.position >= this.input.length) continue; @@ -266,19 +314,51 @@ export class PdaSim { const char = this.input.charAt(path.position); for (const to of letter_map.get(char) ?? []) { - paths.push({ - state: to.state, - position: path.position + 1, - stack: path.stack.concat(to.stack), - }); + push(new PdaState(to.state, path.stack.concat(to.stack), path.position+1, this.input)); } } this.paths = paths; - return paths.length == 0 ? "reject" : "pending"; + + + if (this.paths.length == 0) return "reject"; + if (this.accepted.length != 0) return "accept"; + return "pending" } } -export type Sim = FaSim | PdaSim | null +export class TmState{ + readonly state: State; + readonly tape: Symbol[]; + + readonly position: number; + readonly input: string; + readonly accepted: boolean = false + private repr!: string; + + constructor(state: State, tape: Symbol[], position: number, input: string){ + this.state=state; + this.tape = tape; + this.position=position; + this.input = input; + } + + toString(): string{ + if(!this.repr) this.repr = this.state + " " + this.position; + return this.repr + } + +} + +export class TmSim { + current_states: Map = new Map(); + accepted: TmState[] = [] + + step(): SimStepResult { + return "pending" + } +} + +export type Sim = FaSim | PdaSim | TmSim | null export let sim: Sim = null; export let automaton: Machine = { @@ -298,6 +378,7 @@ export function clearSimulation(){ export function setSimulation(sim_: Sim){ sim = sim_; + network.redraw() } export function setAutomaton(auto: Machine) { @@ -323,6 +404,7 @@ export function stepSimulation(): void { if (sim) { console.log(sim.step()); } + network.redraw() } export function resetSimulation(): void { @@ -330,7 +412,7 @@ export function resetSimulation(): void { case "fa": break; case "pda": - setSimulation(new PdaSim(automaton as Pda, "aabb")); + setSimulation(new PdaSim(automaton as Pda, "aabbaabbaa")); break; case "tm": break; diff --git a/web/root/src/visualizer.ts b/web/root/src/visualizer.ts index 292776c..4b6de48 100644 --- a/web/root/src/visualizer.ts +++ b/web/root/src/visualizer.ts @@ -2,9 +2,7 @@ // deno-lint-ignore no-import-prefix import * as vis from "npm:vis-network/standalone"; -import { StateEffect } from "npm:@codemirror/state"; -import { automaton, Machine, setAutomaton } from "./automata.ts"; -import { getText } from "./editor.ts"; +import { automaton, setAutomaton, sim } from "./automata.ts"; export const nodes = new vis.DataSet(); export const edges = new vis.DataSet(); @@ -298,7 +296,7 @@ function renderNode({ const isFinal = automaton.final_states ? automaton.final_states.has(id) : false; - const isActive = false; + const isActive = sim?sim.current_states.has(id):false; const fill = selected ? t.bg_2 : hover ? t.bg_1 : t.bg_0; const stroke = isActive ? t.current_node_border : t.node_border; @@ -340,34 +338,33 @@ function renderNode({ drawInitialArrow(ctx, x, y, r, t.edge); } - // const badgeText = "bleh\npee"; - // if (badgeText) { - // const lines = badgeText.split("\n").slice(0, 3); - // const padX = 8; - // const padY = 6; - // const lineH = 14; + if (isActive) { + const paths = sim?.current_states.get(id)!; + const padX = 8; + const padY = 6; + const lineH = 14; - // let w = 0; - // for (const ln of lines) w = Math.max(w, ctx.measureText(ln).width); - // const boxW = w + padX * 2; - // const boxH = lines.length * lineH + padY * 2; + let w = 0; + for (const ln of paths) w = Math.max(w, ctx.measureText(ln.toString()).width); + const boxW = w + padX * 2; + const boxH = paths.length * lineH + padY * 2; - // const bx = x - boxW / 2; - // const by = y - r - 12 - boxH; + const bx = x - boxW / 2; + const by = y - r - 12 - boxH; - // ctx.fillStyle = t.bg_1; - // ctx.strokeStyle = t.bg_2; - // ctx.lineWidth = 1; - // roundRect(ctx, bx, by, boxW, boxH, 8); - // ctx.fill(); - // ctx.stroke(); + ctx.fillStyle = t.bg_1; + ctx.strokeStyle = t.bg_2; + ctx.lineWidth = 1; + roundRect(ctx, bx, by, boxW, boxH, 8); + ctx.fill(); + ctx.stroke(); - // ctx.fillStyle = t.fg_0; - // ctx.textBaseline = "top"; - // for (let i = 0; i < lines.length; i++) { - // ctx.fillText(lines[i], x, by + padY + i * lineH); - // } - // } + ctx.textBaseline = "top"; + for (let i = 0; i < paths.length; i++) { + ctx.fillStyle = paths[i].accepted?t.current_node_border:t.fg_0; + ctx.fillText(paths[i].toString(), x, by + padY + i * lineH); + } + } const node: vis.Node = nodes.get(id)!; const physicsOff = node.physics === false; From 8c8bb103b26bd9a08e3e7f61db7e116d6d16209e Mon Sep 17 00:00:00 2001 From: Parker TenBroeck <51721964+ParkerTenBroeck@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:30:06 -0500 Subject: [PATCH 3/3] moved frontend to event driven bus, added more simulation stuff --- web/root/index.html | 32 ++-- web/root/src/automata.ts | 220 +----------------------- web/root/src/bus.ts | 87 ++++++++++ web/root/src/controls.ts | 100 ++++++----- web/root/src/editor.ts | 32 ++-- web/root/src/examples.ts | 10 +- web/root/src/main.ts | 10 +- web/root/src/simulation.ts | 325 +++++++++++++++++++++++++++++++++++ web/root/src/splitters.ts | 2 +- web/root/src/terminal.ts | 45 ++--- web/root/src/theme.ts | 10 +- web/root/src/visualizer.ts | 305 ++++++++++++++++++-------------- web/root/style/controls.scss | 66 ++++++- web/root/style/editor.scss | 1 - web/root/style/style.scss | 2 + web/root/style/terminal.scss | 1 - web_lib/src/lib.rs | 12 +- 17 files changed, 767 insertions(+), 493 deletions(-) create mode 100644 web/root/src/bus.ts create mode 100644 web/root/src/simulation.ts diff --git a/web/root/index.html b/web/root/index.html index f25eb13..b6602da 100644 --- a/web/root/index.html +++ b/web/root/index.html @@ -30,7 +30,7 @@
-
+ +
+ Status: N/A +
+
+ +
+
@@ -53,7 +61,7 @@
- @@ -63,21 +71,25 @@ - - - +
diff --git a/web/root/src/automata.ts b/web/root/src/automata.ts index 1fb8349..f1b8a12 100644 --- a/web/root/src/automata.ts +++ b/web/root/src/automata.ts @@ -1,8 +1,6 @@ -import { network, updateVisualization } from "./visualizer.ts"; - export type Machine = Fa | Pda | Tm; -export function machine_from_json(json: string): Machine { +export function parse_machine_from_json(json: string): Machine { const machine: Machine = JSON.parse(json); machine.states = new Map(Object.entries(machine.states)); @@ -202,219 +200,3 @@ export type Tm = { edges: Map; }; - -export type SimStepResult = "pending" | "accept" | "reject"; - -export class FaState { - readonly state: State; - - readonly position: number; - readonly input: string; - readonly accepted: boolean = false; - private repr!: string; - - constructor(state: State, position: number, input: string){ - this.state=state; - this.position=position; - this.input = input; - } - - toString(): string{ - if(!this.repr) this.repr = this.state + " " + this.position; - return this.repr - } -}; - -export class FaSim { - - current_states: Map = new Map(); - accepted: FaState[] = [] - - step(): SimStepResult { - return "pending"; - } -} - -export class PdaState { - readonly state: State; - readonly stack: Symbol[]; - - readonly position: number; - readonly input: string; - readonly accepted: boolean = false - private repr!: string; - - constructor(state: State, stack: Symbol[], position: number, input: string){ - this.state=state; - this.stack=stack; - this.position=position; - this.input = input; - } - - toString(): string{ - if(!this.repr) this.repr = this.state + " [" + this.stack + "]" + " " + this.position; - return this.repr - } -}; - -export class PdaSim { - machine: Pda; - paths: PdaState[]; - input: string; - - current_states: Map = new Map(); - accepted: PdaState[] = [] - - constructor(machine: Pda, input: string) { - this.machine = machine; - this.paths = [new PdaState(machine.initial_state, [machine.initial_stack], 0, input)]; - this.current_states.set(machine.initial_state, [this.paths[0]]) - this.input = input; - } - - step(): SimStepResult { - if (this.paths.length == 0) return "reject"; - if (this.accepted.length != 0) return "accept"; - - const paths: PdaState[] = []; - this.current_states.clear(); - - const push = (state: PdaState) => { - paths.push(state); - if (!this.current_states.has(state.state)) this.current_states.set(state.state, []); - this.current_states.get(state.state)?.push(state); - - if ( - state.position == this.input.length && this.machine.final_states && - this.machine.final_states.has(state.state) - || - state.position == this.input.length && !this.machine.final_states && - state.stack.length == 1 && state.stack[0] == this.machine.initial_stack - ) { - - // @ts-expect-error sillllyyyy - state.accepted = true - this.accepted.push(state); - } - }; - - for (const path of this.paths) { - - const stack = path.stack.pop()!; - const letter_map = this.machine.transitions_components.get(path.state) - ?.get(stack); - if (!letter_map) continue; - - for (const to of letter_map.get(null) ?? []) { - push(new PdaState(to.state, path.stack.concat(to.stack), path.position, this.input)); - } - - if (path.position >= this.input.length) continue; - - const char = this.input.charAt(path.position); - - for (const to of letter_map.get(char) ?? []) { - push(new PdaState(to.state, path.stack.concat(to.stack), path.position+1, this.input)); - } - } - this.paths = paths; - - - if (this.paths.length == 0) return "reject"; - if (this.accepted.length != 0) return "accept"; - return "pending" - } -} - -export class TmState{ - readonly state: State; - readonly tape: Symbol[]; - - readonly position: number; - readonly input: string; - readonly accepted: boolean = false - private repr!: string; - - constructor(state: State, tape: Symbol[], position: number, input: string){ - this.state=state; - this.tape = tape; - this.position=position; - this.input = input; - } - - toString(): string{ - if(!this.repr) this.repr = this.state + " " + this.position; - return this.repr - } - -} - -export class TmSim { - current_states: Map = new Map(); - accepted: TmState[] = [] - - step(): SimStepResult { - return "pending" - } -} - -export type Sim = FaSim | PdaSim | TmSim | null -export let sim: Sim = null; - -export let automaton: Machine = { - type: "fa", - alphabet: new Map(), - final_states: new Map(), - initial_state: "", - states: new Map(), - transitions: new Map(), - transitions_components: new Map(), - edges: new Map(), -}; - -export function clearSimulation(){ - setSimulation(null); -} - -export function setSimulation(sim_: Sim){ - sim = sim_; - network.redraw() -} - -export function setAutomaton(auto: Machine) { - automaton = auto; - sim = null; - updateVisualization() -} - -export function clearAutomaton() { - setAutomaton({ - type: "fa", - alphabet: new Map(), - final_states: new Map(), - initial_state: "", - states: new Map(), - transitions: new Map(), - transitions_components: new Map(), - edges: new Map(), - }); -} - -export function stepSimulation(): void { - if (sim) { - console.log(sim.step()); - } - network.redraw() -} - -export function resetSimulation(): void { - switch (automaton.type) { - case "fa": - break; - case "pda": - setSimulation(new PdaSim(automaton as Pda, "aabbaabbaa")); - break; - case "tm": - break; - } -} diff --git a/web/root/src/bus.ts b/web/root/src/bus.ts new file mode 100644 index 0000000..351e6e8 --- /dev/null +++ b/web/root/src/bus.ts @@ -0,0 +1,87 @@ +// deno-lint-ignore-file + +import type { Machine } from "./automata.ts"; +import type { Sim, SimStepResult } from "./simulation.ts"; +import type wasm from "./wasm.ts"; +import type { Text } from "npm:@codemirror/state"; + +type Unsubscribe = () => void; + +export class EventBus> { + private listeners = new Map void>>(); + + on( + event: K, + handler: (payload: Events[K]) => void, + ): Unsubscribe { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(handler as any); + + return () => this.off(event, handler); + } + + once( + event: K, + handler: (payload: Events[K]) => void, + ): Unsubscribe { + const off = this.on(event, (payload) => { + off(); + handler(payload); + }); + return off; + } + + off(event: K, handler: (payload: Events[K]) => void) { + const set = this.listeners.get(event); + if (!set) return; + set.delete(handler as any); + if (set.size === 0) this.listeners.delete(event); + } + + emit(event: K, payload: Events[K]) { + const set = this.listeners.get(event); + if (!set) return; + + // Copy to avoid issues if handlers subscribe/unsubscribe during emit + for (const handler of Array.from(set)) { + try { + (handler as (p: Events[K]) => void)(payload); + } catch (e) { + console.log(e); + } + } + } + + clear(event?: keyof Events) { + if (event) this.listeners.delete(event); + else this.listeners.clear(); + } +} + +type AppEvents = { + "begin": void; + + "editor/change": {text: string, doc: Text}; + "compiled": {log: wasm.CompileLog[], ansi_log: string, machine: string|undefined}; + + "automata/sim/update": { simulation: Sim|null }; + "automata/sim/before_step": { simulation: Sim }; + "automata/sim/after_step": { simulation: Sim, result: SimStepResult }; + "automata/update": { automaton: Machine }; + + "controls/physics": {enabled: boolean}, + "controls/reset_network": void, + + + "controls/step_simulation": void, + "controls/reload_simulation": void, + "controls/clear_simulation": void, + + "theme/update": void; +}; + +export const bus = new EventBus(); diff --git a/web/root/src/controls.ts b/web/root/src/controls.ts index d54fe85..ef8a8c0 100644 --- a/web/root/src/controls.ts +++ b/web/root/src/controls.ts @@ -1,49 +1,70 @@ -import { resetSimulation, stepSimulation } from "./automata.ts"; -import {nodes, edges, network} from "./visualizer.ts" +import { bus } from "./bus.ts"; -const togglePhysicsBtn = document.getElementById("togglePhysics") as HTMLButtonElement; -const resetLayoutBtn = document.getElementById("resetLayout") as HTMLButtonElement; -const playPauseBtn = document.getElementById("playPause") as HTMLButtonElement; -const stepBtn = document.getElementById("step") as HTMLButtonElement; -const speedSlider = document.getElementById("speed") as HTMLInputElement; -const speedLabel = document.getElementById("speedLabel") as HTMLSpanElement; -const resetSimBtn = document.getElementById("resetSim") as HTMLButtonElement; +const togglePhysicsBtn = document.getElementById( + "togglePhysics", +) as HTMLButtonElement; +const resetLayoutBtn = document.getElementById( + "resetLayout", +) as HTMLButtonElement; +const playPauseBtn = document.getElementById( + "playPauseSim", +) as HTMLButtonElement; +const stepBtn = document.getElementById("stepSim") as HTMLButtonElement; +const speedSlider = document.getElementById("speedSim") as HTMLInputElement; +const speedLabel = document.getElementById("speedSimLabel") as HTMLSpanElement; +const reloadSimBtn = document.getElementById("reloadSim") as HTMLButtonElement; +const clearSimBtn = document.getElementById("clearSim") as HTMLButtonElement; - -// ---- Physics toggle (styled label) ---- -function setPhysicsButtonUI(enabled: boolean) { +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"); - setPhysicsButtonUI(enabled); - network.setOptions({ physics: { enabled } }); - network.setOptions({edges: {smooth: enabled}}); + bus.emit("controls/physics", { enabled }); }; -setPhysicsButtonUI(togglePhysicsBtn.classList.contains("active")); +bus.emit("controls/physics", { + enabled: togglePhysicsBtn.classList.contains("active"), +}); -resetLayoutBtn.onclick = () => { - try { - nodes.forEach((n) => { - n.physics = true; - n.x = undefined; - n.y = undefined; - }); - network.setData({ nodes, edges }); - } catch { - // Last resort - network.setData({ nodes, edges }); +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(); +}); - // If physics button is OFF, keep it OFF (don’t surprise the user) - const physicsEnabled = togglePhysicsBtn.classList.contains("active"); - network.setOptions({ physics: { enabled: physicsEnabled } }); -}; +bus.on("automata/sim/after_step", ({ result }) => { + if (result !== "pending") { + if (running) setRunning(false); + } +}); -// ---- Play/Pause + Speed ---- +let simulation_active = false; let running = false; let timer: number | null = null; @@ -74,8 +95,7 @@ function restartTimer() { const intervalMs = Math.round(1000 / sps); timer = globalThis.window.setInterval(() => { - // If your step can throw, keep the interval alive: - try { stepSimulation(); } catch (e) { console.error(e); } + bus.emit("controls/step_simulation", undefined); }, intervalMs); } @@ -93,13 +113,3 @@ function setRunning(on: boolean) { } playPauseBtn.onclick = () => setRunning(!running); - -stepBtn.onclick = () => { - stepSimulation(); -}; - -resetSimBtn.onclick = () => { - if (running) setRunning(false); - resetSimulation(); - stepBtn.disabled = false; -}; \ No newline at end of file diff --git a/web/root/src/editor.ts b/web/root/src/editor.ts index 334d0b8..e9d925f 100644 --- a/web/root/src/editor.ts +++ b/web/root/src/editor.ts @@ -5,7 +5,6 @@ import { keymap, hoverTooltip, Decoration, - ViewPlugin, lineNumbers, highlightActiveLineGutter, highlightActiveLine @@ -18,11 +17,10 @@ import { closeBrackets } from "npm:@codemirror/autocomplete"; import wasm from "./wasm.ts" -import { terminalPlugin } from "./terminal.ts"; -import { machine_from_json, setAutomaton } from "./automata.ts"; import { sharedText } from "./share.ts"; import { examples } from "./examples.ts"; +import { bus } from "./bus.ts"; function tokenize(text: string) { @@ -44,31 +42,26 @@ function compile(text: string): wasm.CompileResult { } } -export const analysisField = StateField.define({ +const eventBusConnection = StateField.define({ create(state) { const text = state.doc.toString(); + 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}); 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, log_formatted, graph } = compile(text); + const { log, ansi_log, machine } = compile(text); - if (graph){ - try{ - setAutomaton(machine_from_json(graph)) - }catch(e){ - console.log(e); - } - } + bus.emit("compiled", {log, ansi_log, machine}) const marks = []; const docLen = doc.length; @@ -100,7 +93,7 @@ function buildAnalysis(text: string, doc: Text) { } const deco = Decoration.set(marks, true); - return { tokens, log, log_formatted, deco }; + return { tokens, log, ansi_log, deco }; } const tokenClass = (t: string) => @@ -135,7 +128,7 @@ function sevRank(sev: string) { // ===================== Hover tooltip (uses cached diags) ===================== const diagHover = hoverTooltip((view, pos) => { - const { log } = view.state.field(analysisField); + const { log } = view.state.field(eventBusConnection); const hits = log.filter((d) => d.start !== undefined && d.end !== undefined && pos >= d.start && pos <= d.end); if (hits.length === 0) return null; @@ -186,7 +179,7 @@ export function getText(): string{ } const state = EditorState.create({ - doc: sharedText() ?? getSaved() ?? examples[0].machine, + doc: "", extensions: [ lineNumbers(), highlightActiveLineGutter(), @@ -197,9 +190,8 @@ const state = EditorState.create({ closeBrackets(), keymap.of([...defaultKeymap, ...historyKeymap]), - analysisField, + eventBusConnection, diagHover, - terminalPlugin, EditorView.lineWrapping, ], @@ -208,4 +200,6 @@ const state = EditorState.create({ const editor = new EditorView({ state, parent: document.getElementById("editor")!, -}); \ No newline at end of file +}); + +bus.on("begin", _ => setText(sharedText() ?? getSaved() ?? examples[0].machine)) \ No newline at end of file diff --git a/web/root/src/examples.ts b/web/root/src/examples.ts index f9cb9c4..eb9234b 100644 --- a/web/root/src/examples.ts +++ b/web/root/src/examples.ts @@ -10,9 +10,9 @@ export type Category = | "NTM"; export class Example { - category: Category; - title: string; - machine: string; + readonly category: Category; + readonly title: string; + readonly machine: string; constructor(category: Category, title: string, machine: string) { this.category = category; @@ -21,7 +21,7 @@ export class Example { } } -export const examples: Example[] = [ +export const examples: readonly Example[] = [ new Example( "Tutorial", "DFA", @@ -174,7 +174,7 @@ const CATEGORY_ORDER: Category[] = [ function buildExamplesDropdown( selectEl: HTMLSelectElement, - examples: Example[], + examples: readonly Example[], onPick?: (ex: Example) => void, ) { // Clear everything except the first placeholder option (if present) diff --git a/web/root/src/main.ts b/web/root/src/main.ts index 4def64a..c8d68a0 100644 --- a/web/root/src/main.ts +++ b/web/root/src/main.ts @@ -1,7 +1,11 @@ -import "./editor.ts" -import "./visualizer.ts" +import { bus } from "./bus.ts"; import "./splitters.ts" import "./controls.ts" import "./theme.ts" import "./share.ts" -import "./examples.ts" \ No newline at end of file +import "./examples.ts" +import "./visualizer.ts" +import "./editor.ts" +import "./simulation.ts" + +bus.emit("begin", undefined); \ No newline at end of file diff --git a/web/root/src/simulation.ts b/web/root/src/simulation.ts new file mode 100644 index 0000000..c37821b --- /dev/null +++ b/web/root/src/simulation.ts @@ -0,0 +1,325 @@ +import { bus } from "./bus.ts"; +import { + Fa, + Machine, + parse_machine_from_json, + Pda, + State, + Symbol, + Tm, +} from "./automata.ts"; + +export type SimStepResult = "pending" | "accept" | "reject"; +export type Sim = FaSim | PdaSim | TmSim; +let simulation: Sim | null = null; +let automaton: Machine = { + type: "fa", + alphabet: new Map(), + final_states: new Map(), + initial_state: "", + states: new Map(), + transitions: new Map(), + transitions_components: new Map(), + edges: new Map(), +}; + +bus.on("compiled", ({ machine }) => { + if (machine) { + try { + bus.emit("controls/clear_simulation", undefined); + automaton = parse_machine_from_json(machine); + bus.emit("automata/update", { automaton }); + } catch (e) { + console.log(e); + } + } +}); +bus.on("controls/clear_simulation", (_) => { + simulation = null; + bus.emit("automata/sim/update", { simulation: null }); +}); +bus.on("controls/step_simulation", (_) => { + if (simulation) { + bus.emit("automata/sim/before_step", { simulation }); + bus.emit("automata/sim/after_step", { + result: simulation.step(), + simulation: simulation, + }); + } +}); +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.on("controls/reload_simulation", (_) => { + const input = machineInput.value; + switch (automaton.type) { + case "fa": + simulation = new FaSim(automaton as Fa, input); + break; + case "pda": + simulation = new PdaSim(automaton as Pda, input); + break; + case "tm": + simulation = new TmSim(automaton as Tm, input); + break; + } + bus.emit("automata/sim/update", { simulation }); +}); +const simulationStatus = document.getElementById("simulationStatus") as HTMLInputElement; +bus.on("automata/sim/update", ({simulation}) => { + if (!simulation){ + simulationStatus.innerText = "N/A" + simulationStatus.style.color = "var(--fg-2)"; + }else{ + simulationStatus.innerText = "Pending" + simulationStatus.style.color = "var(--warning)"; + } +}); +bus.on("automata/sim/after_step", ({result}) => { + if (result === "pending"){ + simulationStatus.innerText = "Pending" + simulationStatus.style.color = "var(--warning)"; + }else if (result==="accept"){ + simulationStatus.innerText = "Accepted" + simulationStatus.style.color = "var(--success)"; + }else if (result==="reject"){ + simulationStatus.innerText = "Rejected" + simulationStatus.style.color = "var(--error)"; + } +}); + +export class FaState { + readonly state: State; + + readonly position: number; + readonly input: string; + readonly accepted: boolean = false; + private repr!: string; + + constructor(state: State, position: number, input: string) { + this.state = state; + this.position = position; + this.input = input; + } + + toString(): string { + if (!this.repr) { + this.repr = this.state + +" >" + this.input.substring(this.position); + } + return this.repr; + } +} + +export class FaSim { + machine: Fa; + paths: FaState[]; + input: string; + + current_states: Map = new Map(); + accepted: FaState[] = []; + + constructor(machine: Fa, input: string) { + this.machine = machine; + this.paths = [new FaState(machine.initial_state, 0, input)]; + this.current_states.set(machine.initial_state, [this.paths[0]]); + this.input = input; + } + + step(): SimStepResult { + if (this.paths.length == 0) return "reject"; + if (this.accepted.length != 0) return "accept"; + + const paths: FaState[] = []; + this.current_states.clear(); + + const push = (state: FaState) => { + paths.push(state); + if (!this.current_states.has(state.state)) { + this.current_states.set(state.state, []); + } + this.current_states.get(state.state)?.push(state); + + if ( + state.position == this.input.length && + this.machine.final_states.has(state.state) + ) { + // @ts-expect-error sillllyyyy + state.accepted = true; + this.accepted.push(state); + } + }; + + for (const path of this.paths) { + const letter_map = this.machine.transitions_components.get(path.state)!; + if (!letter_map) continue; + + for (const to of letter_map.get(null) ?? []) { + push(new FaState(to.state, path.position, this.input)); + } + + if (path.position >= this.input.length) continue; + + const char = this.input.charAt(path.position); + + for (const to of letter_map.get(char) ?? []) { + push(new FaState(to.state, path.position + 1, this.input)); + } + } + this.paths = paths; + + if (this.paths.length == 0) return "reject"; + if (this.accepted.length != 0) return "accept"; + return "pending"; + } +} + +export class PdaState { + readonly state: State; + readonly stack: Symbol[]; + + readonly position: number; + readonly input: string; + readonly accepted: boolean = false; + private repr!: string; + + constructor(state: State, stack: Symbol[], position: number, input: string) { + this.state = state; + this.stack = stack; + this.position = position; + this.input = input; + } + + toString(): string { + if (!this.repr) { + this.repr = this.state + " [" + this.stack + "]" + " >" + + this.input.substring(this.position); + } + return this.repr; + } +} + +export class PdaSim { + machine: Pda; + paths: PdaState[]; + input: string; + + current_states: Map = new Map(); + accepted: PdaState[] = []; + + constructor(machine: Pda, input: string) { + this.machine = machine; + this.paths = [ + new PdaState(machine.initial_state, [machine.initial_stack], 0, input), + ]; + this.current_states.set(machine.initial_state, [this.paths[0]]); + this.input = input; + } + + step(): SimStepResult { + if (this.paths.length == 0) return "reject"; + if (this.accepted.length != 0) return "accept"; + + const paths: PdaState[] = []; + this.current_states.clear(); + + const push = (state: PdaState) => { + paths.push(state); + if (!this.current_states.has(state.state)) { + this.current_states.set(state.state, []); + } + this.current_states.get(state.state)?.push(state); + + if ( + state.position == this.input.length && this.machine.final_states && + this.machine.final_states.has(state.state) || + state.position == this.input.length && !this.machine.final_states && + state.stack.length == 1 && + state.stack[0] == this.machine.initial_stack + ) { + // @ts-expect-error sillllyyyy + state.accepted = true; + this.accepted.push(state); + } + }; + + for (const path of this.paths) { + const stack = path.stack.pop()!; + const letter_map = this.machine.transitions_components.get(path.state) + ?.get(stack); + if (!letter_map) continue; + + for (const to of letter_map.get(null) ?? []) { + push( + new PdaState( + to.state, + path.stack.concat(to.stack), + path.position, + this.input, + ), + ); + } + + if (path.position >= this.input.length) continue; + + const char = this.input.charAt(path.position); + + for (const to of letter_map.get(char) ?? []) { + push( + new PdaState( + to.state, + path.stack.concat(to.stack), + path.position + 1, + this.input, + ), + ); + } + } + this.paths = paths; + + if (this.paths.length == 0) return "reject"; + if (this.accepted.length != 0) return "accept"; + return "pending"; + } +} + +export class TmState { + readonly state: State; + readonly tape: Symbol[]; + + readonly position: number; + readonly input: string; + readonly accepted: boolean = false; + private repr!: string; + + constructor(state: State, tape: Symbol[], position: number, input: string) { + this.state = state; + this.tape = tape; + this.position = position; + this.input = input; + } + + toString(): string { + if (!this.repr) this.repr = this.state + " " + this.position; + return this.repr; + } +} + +export class TmSim { + machine: Tm; + input: string; + current_states: Map = new Map(); + accepted: TmState[] = []; + + constructor(machine: Tm, input: string) { + this.machine = machine; + this.input = input; + } + + step(): SimStepResult { + return "pending"; + } +} diff --git a/web/root/src/splitters.ts b/web/root/src/splitters.ts index 89bc5ba..d6a0e88 100644 --- a/web/root/src/splitters.ts +++ b/web/root/src/splitters.ts @@ -63,7 +63,7 @@ function setFlexFill(pane: HTMLElement) { pane.style.flex = "1 1 auto"; } -export function enableFlexSplitters() { +function enableFlexSplitters() { // Horizontal: A | hSplit | B (top/split/bottom) for (const splitter of document.querySelectorAll(".hSplit:not(.styleOnly)")) { const parent = splitter.parentElement as HTMLElement | null; diff --git a/web/root/src/terminal.ts b/web/root/src/terminal.ts index fd83210..980dc3c 100644 --- a/web/root/src/terminal.ts +++ b/web/root/src/terminal.ts @@ -1,10 +1,14 @@ -// deno-lint-ignore-file +import { bus } from "./bus.ts"; -import { - ViewPlugin, -} from "npm:@codemirror/view"; +bus.on("compiled", ({log, ansi_log}) => { + const term = document.getElementById("terminal"); + if (!term) return; -import { analysisField } from "./editor.ts"; + let s = ""; + s += `\x1b[90m[compile]\x1b[0m ${log.length} diagnostics\n`; + + term.innerHTML = ansiToHtml(s + ansi_log); +}) function escapeHtml(s: string) { return s @@ -77,33 +81,4 @@ function ansiToHtml(input: string) { out += openSpanIfNeeded(input.slice(lastIndex)); return out; -} - - // @ts-expect-error bad library -function formatTerminal(view) { - const term = document.getElementById("terminal"); - if (!term) return; - - const { log, log_formatted } = view.state.field(analysisField); - - let s = ""; - s += `\x1b[90m[compile]\x1b[0m ${log.length} diagnostics\n`; - - term.innerHTML = ansiToHtml(s + log_formatted); -} - -export const terminalPlugin = ViewPlugin.fromClass( - class { - - // @ts-expect-error bad library - constructor(view) { - // @ts-expect-error bad library - this.view = view; - formatTerminal(view); - } - // @ts-expect-error bad library - update(update) { - if (update.docChanged) formatTerminal(update.view); - } - } -); \ No newline at end of file +} \ No newline at end of file diff --git a/web/root/src/theme.ts b/web/root/src/theme.ts index 8dd0244..8debb35 100644 --- a/web/root/src/theme.ts +++ b/web/root/src/theme.ts @@ -1,4 +1,4 @@ -import { updateGraphTheme } from "./visualizer.ts"; +import { bus } from "./bus.ts"; const themeBtn = document.getElementById("themeToggle") as HTMLButtonElement; @@ -22,11 +22,11 @@ function setTheme(theme: Theme) { // update button label themeBtn.textContent = theme === "dark" ? "🌙 Dark" : "☀️ Light"; - updateGraphTheme(); + + bus.emit("theme/update", undefined); } - -setTheme(getPreferredTheme()); +bus.on("begin", _ => setTheme(getPreferredTheme())) themeBtn.addEventListener("click", toggleTheme); function toggleTheme() { @@ -34,8 +34,6 @@ function toggleTheme() { setTheme(current === "dark" ? "light" : "dark"); } - - globalThis.window.matchMedia?.("(prefers-color-scheme: light)") ?.addEventListener("change", () => { if (localStorage.getItem("theme")) return; diff --git a/web/root/src/visualizer.ts b/web/root/src/visualizer.ts index 4b6de48..8c34b15 100644 --- a/web/root/src/visualizer.ts +++ b/web/root/src/visualizer.ts @@ -1,143 +1,44 @@ // deno-lint-ignore-file no-unversioned-import - // deno-lint-ignore no-import-prefix import * as vis from "npm:vis-network/standalone"; -import { automaton, setAutomaton, sim } from "./automata.ts"; -export const nodes = new vis.DataSet(); -export const edges = new vis.DataSet(); - -type Color = string; -type GraphTheme = { - bg_0: Color; - bg_1: Color; - bg_2: Color; - fg_0: Color; - fg_1: Color; - fg_2: Color; - - node_anchor: Color; - node_border: Color; - current_node_border: Color; - - edge: Color; - edge_hover: Color; - edge_active: Color; - - font_face: string - - node_font_size: number; - node_font: string, - node_font_bold: string, - - edge_font_size: number; - edge_font: string, - edge_font_bold: string, -}; - -let _graphTheme: GraphTheme | null = null; - -function invalidateGraphThemeCache() { - _graphTheme = null; -} - -function getGraphTheme(): GraphTheme { - function cssVar(name: string, fallback = ""): string { - return getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim() || fallback; - } - - if (_graphTheme) return _graphTheme; - - _graphTheme = { - bg_0: cssVar("--graph-bg-0"), - bg_1: cssVar("--graph-bg-1"), - bg_2: cssVar("--graph-bg-2"), - fg_0: cssVar("--graph-fg-0"), - fg_1: cssVar("--graph-fg-1"), - fg_2: cssVar("--graph-fg-2"), - - node_anchor: cssVar("--graph-node-anchor"), - node_border: cssVar("--graph-node-border"), - current_node_border: cssVar("--graph-current-node-border"), - - edge: cssVar("--graph-edge"), - edge_hover: cssVar("--graph-edge-hover"), - edge_active: cssVar("--graph-edge-active"), - - font_face: cssVar("--graph-font"), - - node_font_size: Number(cssVar("--graph-node-font-size")), - node_font: `${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`, - node_font_bold: `bold ${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`, - - edge_font_size: Number(cssVar("--graph-edge-font-size")), - edge_font: `${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`, - edge_font_bold: `bold ${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`, - }; - - return _graphTheme; -} - -export function updateGraphTheme() { - invalidateGraphThemeCache(); - const gt = getGraphTheme(); - - network.setOptions({ - nodes: { - labelHighlightBold: false, - font: { - color: gt.fg_0, - bold: { - color: gt.fg_1, - }, - }, - }, - edges: { - labelHighlightBold: true, - font: { - align: "top", - face: gt.font_face, - size: gt.edge_font_size, - color: gt.fg_0, - strokeColor: gt.bg_0, - bold: { - color: gt.fg_1, - face: gt.font_face, - size: gt.edge_font_size, - mod: "bold", - }, - }, - color: { - color: gt.edge, - hover: gt.edge_hover, - highlight: gt.edge_active, - }, - shadow: { - enabled: false, - }, - }, - }); - - setAutomaton(automaton) -} +import { bus } from "./bus.ts"; +import type { Sim } from "./simulation.ts"; +import type { Machine } from "./automata.ts"; -let _measureCanvas: HTMLCanvasElement | null = null; +bus.on("controls/physics", ({enabled}) => { + network.setOptions({ physics: { enabled } }); + network.setOptions({edges: {smooth: enabled}}); +}); +bus.on("controls/reset_network", _ => { + try { + nodes.forEach((n) => { + n.physics = true; + n.x = undefined; + n.y = undefined; + }); + network.setData({ nodes, edges }); + } catch { + // Last resort + network.setData({ nodes, edges }); + } +}); -export function measureTextWidth(text: string, font: string): number { - if (!_measureCanvas) { - _measureCanvas = document.createElement("canvas"); - } +bus.on("automata/sim/after_step", _ => { + network.redraw(); +}); - const ctx = _measureCanvas.getContext("2d")!; - ctx.font = font; +let simulation: Sim | null = null; +bus.on("automata/sim/update", ({simulation: sim}) => { + simulation = sim; + network.redraw(); +}); - return ctx.measureText(text).width; -} +let automaton: Machine -export function updateVisualization() { +bus.on("automata/update", ({automaton: auto}) => { + automaton = auto; // Populate nodes for (const state of automaton.states.keys()) { @@ -187,17 +88,154 @@ export function updateVisualization() { } } + // delete old edges for (const edge_id of edges.getIds()) { if (!automaton.edges.has(edge_id as string)) { edges.remove(edge_id); } } + // delete old nodes for (const node_id of nodes.getIds()) { if (!automaton.states.has(node_id as string)) { nodes.remove(node_id); } } +}); + + +const nodes = new vis.DataSet(); +const edges = new vis.DataSet(); + + +let _graphTheme: GraphTheme | null = null; +bus.on("theme/update", _ => { + _graphTheme = null; + updateGraphTheme() +}); + + +type Color = string; +type GraphTheme = { + bg_0: Color; + bg_1: Color; + bg_2: Color; + fg_0: Color; + fg_1: Color; + fg_2: Color; + + node_anchor: Color; + node_border: Color; + current_node_border: Color; + + edge: Color; + edge_hover: Color; + edge_active: Color; + + font_face: string + + node_font_size: number; + node_font: string, + node_font_bold: string, + + edge_font_size: number; + edge_font: string, + edge_font_bold: string, +}; + +function getGraphTheme(): GraphTheme { + function cssVar(name: string, fallback = ""): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() || fallback; + } + + if (_graphTheme) return _graphTheme; + + _graphTheme = { + bg_0: cssVar("--graph-bg-0"), + bg_1: cssVar("--graph-bg-1"), + bg_2: cssVar("--graph-bg-2"), + fg_0: cssVar("--graph-fg-0"), + fg_1: cssVar("--graph-fg-1"), + fg_2: cssVar("--graph-fg-2"), + + node_anchor: cssVar("--graph-node-anchor"), + node_border: cssVar("--graph-node-border"), + current_node_border: cssVar("--graph-current-node-border"), + + edge: cssVar("--graph-edge"), + edge_hover: cssVar("--graph-edge-hover"), + edge_active: cssVar("--graph-edge-active"), + + font_face: cssVar("--graph-font"), + + node_font_size: Number(cssVar("--graph-node-font-size")), + node_font: `${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`, + node_font_bold: `bold ${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`, + + edge_font_size: Number(cssVar("--graph-edge-font-size")), + edge_font: `${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`, + edge_font_bold: `bold ${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`, + }; + + return _graphTheme; +} + +function updateGraphTheme() { + const gt = getGraphTheme(); + + network.setOptions({ + nodes: { + labelHighlightBold: false, + font: { + color: gt.fg_0, + bold: { + color: gt.fg_1, + }, + }, + }, + edges: { + labelHighlightBold: true, + font: { + align: "top", + face: gt.font_face, + size: gt.edge_font_size, + color: gt.fg_0, + strokeColor: gt.bg_0, + bold: { + color: gt.fg_1, + face: gt.font_face, + size: gt.edge_font_size, + mod: "bold", + }, + }, + color: { + color: gt.edge, + hover: gt.edge_hover, + highlight: gt.edge_active, + }, + shadow: { + enabled: false, + }, + }, + }); + + network.redraw(); +} + + +let _measureCanvas: HTMLCanvasElement | null = null; + +function measureTextWidth(text: string, font: string): number { + if (!_measureCanvas) { + _measureCanvas = document.createElement("canvas"); + } + + const ctx = _measureCanvas.getContext("2d")!; + ctx.font = font; + + return ctx.measureText(text).width; } function chosen_edge( @@ -216,7 +254,7 @@ function chosen_node( ) { } -export const network: vis.Network = createGraph(); +const network: vis.Network = createGraph(); function createGraph(): vis.Network { const container = document.getElementById("graph")!; @@ -278,6 +316,7 @@ function createGraph(): vis.Network { return network; } + function renderNode({ ctx, id, @@ -296,7 +335,7 @@ function renderNode({ const isFinal = automaton.final_states ? automaton.final_states.has(id) : false; - const isActive = sim?sim.current_states.has(id):false; + const isActive = simulation?simulation.current_states.has(id):false; const fill = selected ? t.bg_2 : hover ? t.bg_1 : t.bg_0; const stroke = isActive ? t.current_node_border : t.node_border; @@ -339,7 +378,7 @@ function renderNode({ } if (isActive) { - const paths = sim?.current_states.get(id)!; + const paths = simulation?.current_states.get(id)!; const padX = 8; const padY = 6; const lineH = 14; diff --git a/web/root/style/controls.scss b/web/root/style/controls.scss index 71af4aa..077d6dc 100644 --- a/web/root/style/controls.scss +++ b/web/root/style/controls.scss @@ -52,27 +52,45 @@ } } -.btn-blue { - background: color-mix(in srgb, var(--accent) 14%, transparent); - border-color: color-mix(in srgb, var(--accent) 40%, transparent); +.btn-yellow { + background: color-mix(in srgb, var(--warning) 14%, transparent); + border-color: color-mix(in srgb, var(--warning) 40%, transparent); &:hover { - background: color-mix(in srgb, var(--accent) 22%, transparent); + background: color-mix(in srgb, var(--warning) 22%, transparent); + } +} + +.btn-blue { + background: color-mix(in srgb, var(--focus) 14%, transparent); + border-color: color-mix(in srgb, var(--focus) 40%, transparent); + + &:hover { + background: color-mix(in srgb, var(--focus) 22%, transparent); } } .btn-grey { - background: color-mix(in srgb, var(--accent) 12%, transparent); - border-color: color-mix(in srgb, var(--accent) 28%, transparent); + background: color-mix(in srgb, var(--ansi-fg-90) 12%, transparent); + border-color: color-mix(in srgb, var(--ansi-fg-90) 28%, transparent); &:hover { - background: color-mix(in srgb, var(--accent) 18%, transparent); + background: color-mix(in srgb, var(--ansi-fg-90) 18%, transparent); + } +} + +.btn-red { + background: color-mix(in srgb, var(--error) 12%, transparent); + border-color: color-mix(in srgb, var(--error) 28%, transparent); + + &:hover { + background: color-mix(in srgb, var(--error) 18%, transparent); } } .btn-toggle.active { - background: color-mix(in srgb, var(--warning) 14%, transparent); - border-color: color-mix(in srgb, var(--warning) 30%, transparent); + background: color-mix(in srgb, var(--success) 14%, transparent); + border-color: color-mix(in srgb, var(--success) 30%, transparent); } @@ -97,3 +115,33 @@ text-align: right; opacity: 0.9; } + + + + +.test-input { + width: 100%; + max-width: 420px; + + align-self: center; + + padding: 10px 14px; + border-radius: 12px; + border: 1px solid var(--bg-1); + background: var(--bg-2); + + font: var(--font-ui); + color: var(--fg-0); + + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.test-input::placeholder { + color: var(--fg-0); +} + +.test-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent); +} \ No newline at end of file diff --git a/web/root/style/editor.scss b/web/root/style/editor.scss index a09ed24..6cd740a 100644 --- a/web/root/style/editor.scss +++ b/web/root/style/editor.scss @@ -31,7 +31,6 @@ .cm-lineNumbers .cm-gutterElement { padding: 0 10px 0 6px; font-family: var(--font-mono); - font-size: 12px; } .cm-activeLine { diff --git a/web/root/style/style.scss b/web/root/style/style.scss index c516eae..2534d55 100644 --- a/web/root/style/style.scss +++ b/web/root/style/style.scss @@ -11,6 +11,7 @@ body { margin: 0; color: var(--fg-0); font-family: var(--font-ui); + font-size: 14px; background: #909090; } @@ -103,6 +104,7 @@ body { border-radius: 12px; border: 1px solid var(bg-1); background: var(--bg-2); + font: var(--font-ui) } .share-toast { diff --git a/web/root/style/terminal.scss b/web/root/style/terminal.scss index 1040c5a..ae32e8b 100644 --- a/web/root/style/terminal.scss +++ b/web/root/style/terminal.scss @@ -4,7 +4,6 @@ padding: 1em; margin: 0px; font-family: var(--font-mono); - font-size: 12.5px; line-height: 1.35; white-space: pre-wrap; word-break: break-word; diff --git a/web_lib/src/lib.rs b/web_lib/src/lib.rs index cd29ba7..e9ec072 100644 --- a/web_lib/src/lib.rs +++ b/web_lib/src/lib.rs @@ -152,8 +152,8 @@ pub struct Graph<'a> { #[wasm_bindgen(getter_with_clone)] pub struct CompileResult { pub log: Vec, - pub log_formatted: String, - pub graph: Option, + pub ansi_log: String, + pub machine: Option, } #[wasm_bindgen] @@ -161,10 +161,10 @@ pub fn compile(input: &str) -> CompileResult { let mut ctx = Context::new(input); let result = automata::loader::parse_universal(&mut ctx); - let graph = result.map(|result| serde_json::to_string(&result).unwrap()); + let machine = result.map(|result| serde_json::to_string(&result).unwrap()); use std::fmt::Write; - let log_formatted = ctx.logs_display().fold(String::new(), |mut s, e| { + let ansi_log = ctx.logs_display().fold(String::new(), |mut s, e| { write!(&mut s, "{e}").unwrap(); s }); @@ -190,7 +190,7 @@ pub fn compile(input: &str) -> CompileResult { CompileResult { log, - log_formatted, - graph, + ansi_log, + machine, } }