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 c22eef7..f1b8a12 100644 --- a/web/root/src/automata.ts +++ b/web/root/src/automata.ts @@ -1,6 +1,6 @@ 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)); @@ -26,16 +26,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 +49,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 +75,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 +108,7 @@ export type SymbolInfo = { definition: Span }; export type FaTransFrom = { state: State; - letter: Letter|null; + letter: Letter | null; }; export type FaTransTo = { @@ -132,14 +133,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 +163,10 @@ export type Pda = { final_states: Map | null; transitions: Map; - transitions_components: Map>>; + transitions_components: Map< + State, + Map> + >; edges: Map; }; @@ -196,13 +200,3 @@ export type Tm = { edges: Map; }; - - -export type FaState = { - state: State, - position: number -} - -export class FaSim{ - -} \ No newline at end of file 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 58a6f38..ef8a8c0 100644 --- a/web/root/src/controls.ts +++ b/web/root/src/controls.ts @@ -1,56 +1,70 @@ -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; - -function stepSimulation(): void { - console.log("step"); -} - -function resetSimulation(): void { - console.log("reset"); -} - -// ---- 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; @@ -81,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); } @@ -100,18 +113,3 @@ function setRunning(on: boolean) { } playPauseBtn.onclick = () => setRunning(!running); - -stepBtn.onclick = () => { - stepSimulation(); -}; - -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..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,12 +17,10 @@ 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 { sharedText } from "./share.ts"; import { examples } from "./examples.ts"; +import { bus } from "./bus.ts"; function tokenize(text: string) { @@ -45,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; @@ -101,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) => @@ -136,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; @@ -187,7 +179,7 @@ export function getText(): string{ } const state = EditorState.create({ - doc: sharedText() ?? getSaved() ?? examples[0].machine, + doc: "", extensions: [ lineNumbers(), highlightActiveLineGutter(), @@ -198,9 +190,8 @@ const state = EditorState.create({ closeBrackets(), keymap.of([...defaultKeymap, ...historyKeymap]), - analysisField, + eventBusConnection, diagHover, - terminalPlugin, EditorView.lineWrapping, ], @@ -209,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 7987d8e..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", @@ -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 @@ -143,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 dc0daaf..8c34b15 100644 --- a/web/root/src/visualizer.ts +++ b/web/root/src/visualizer.ts @@ -1,13 +1,119 @@ // deno-lint-ignore-file no-unversioned-import - // 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 { getText } from "./editor.ts"; -export const nodes = new vis.DataSet(); -export const edges = new vis.DataSet(); +import { bus } from "./bus.ts"; +import type { Sim } from "./simulation.ts"; +import type { Machine } from "./automata.ts"; + + +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 }); + } +}); + +bus.on("automata/sim/after_step", _ => { + network.redraw(); +}); + +let simulation: Sim | null = null; +bus.on("automata/sim/update", ({simulation: sim}) => { + simulation = sim; + network.redraw(); +}); + +let automaton: Machine + +bus.on("automata/update", ({automaton: auto}) => { + automaton = auto; + // Populate nodes + for (const state of automaton.states.keys()) { + + const size = measureTextWidth(state, getGraphTheme().node_font)/2+10 + if (nodes.get(state)) { + nodes.update({ + id: state, + label: state, + size + }); + } else { + nodes.add({ + id: state, + label: state, + size + }); + } + } + + // Populate 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); + const font = { + vadjust, + bold: { + vadjust + } + }; + if (edges.get(edge_id)) { + edges.update({ + id: edge_id, + font, + from: to_from[0], + to: to_from[1], + label: transitions.map(i => i.repr).join(automaton.type=="fa"?",":"\n"), + }); + } else { + edges.add({ + id: edge_id, + font, + from: to_from[0], + to: to_from[1], + label: transitions.map(i => i.repr).join(automaton.type=="fa"?",":"\n"), + }); + } + } + + // 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 = { @@ -37,12 +143,6 @@ type GraphTheme = { 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) @@ -82,8 +182,7 @@ function getGraphTheme(): GraphTheme { return _graphTheme; } -export function updateGraphTheme() { - invalidateGraphThemeCache(); +function updateGraphTheme() { const gt = getGraphTheme(); network.setOptions({ @@ -122,37 +221,13 @@ 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(), - }; + network.redraw(); } let _measureCanvas: HTMLCanvasElement | null = null; -export function measureTextWidth(text: string, font: string): number { +function measureTextWidth(text: string, font: string): number { if (!_measureCanvas) { _measureCanvas = document.createElement("canvas"); } @@ -163,71 +238,6 @@ export function measureTextWidth(text: string, font: string): number { return ctx.measureText(text).width; } -export function setAutomaton(auto: Machine) { - automaton = auto; - - // Populate nodes - for (const state of automaton.states.keys()) { - - const size = measureTextWidth(state, getGraphTheme().node_font)/2+10 - if (nodes.get(state)) { - nodes.update({ - id: state, - label: state, - size - }); - } else { - nodes.add({ - id: state, - label: state, - size - }); - } - } - - // Populate edges - for (const [edge_id, transitions] of auto.edges) { - const to_from = edge_id.split("#"); - const vadjust = -getGraphTheme().edge_font_size * - Math.floor(transitions.length / 2); - const font = { - vadjust, - bold: { - vadjust - } - }; - if (edges.get(edge_id)) { - edges.update({ - id: edge_id, - font, - from: to_from[0], - to: to_from[1], - label: transitions.map(i => i.repr).join(auto.type=="fa"?",":"\n"), - }); - } else { - edges.add({ - id: edge_id, - font, - from: to_from[0], - to: to_from[1], - label: transitions.map(i => i.repr).join(auto.type=="fa"?",":"\n"), - }); - } - } - - for (const edge_id of edges.getIds()) { - if (!auto.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)) { - nodes.remove(node_id); - } - } -} - function chosen_edge( _: vis.ChosenNodeValues, id: vis.IdType, @@ -244,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")!; @@ -306,6 +316,7 @@ function createGraph(): vis.Network { return network; } + function renderNode({ ctx, id, @@ -324,7 +335,7 @@ function renderNode({ const isFinal = automaton.final_states ? automaton.final_states.has(id) : false; - const isActive = 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; @@ -366,34 +377,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 = simulation?.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; 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, } }