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, } }