mirror of
https://github.com/ParkerTenBroeck/hdl_sim.git
synced 2026-06-06 21:24:06 -04:00
862 lines
22 KiB
JavaScript
862 lines
22 KiB
JavaScript
const LS_KEY_VHDL = "circuit_ui:circuit.vhdl";
|
|
const LS_KEY_MODE = "circuit_ui:mode";
|
|
|
|
const EXAMPLE_VHDL_TEXT = `library ieee;
|
|
use ieee.std_logic_1164.all;
|
|
use ieee.numeric_std.all;
|
|
|
|
-- Do not modify the following entity block
|
|
entity circuit is
|
|
port (
|
|
clk: in std_logic; -- 500 Hz, period 2 ms
|
|
btn: in std_logic_vector(31 downto 0);
|
|
sw: in std_logic_vector(31 downto 0);
|
|
led: out std_logic_vector(31 downto 0) := (others => '0');
|
|
seg0: out std_logic_vector(31 downto 0);
|
|
seg1: out std_logic_vector(31 downto 0);
|
|
seg2: out std_logic_vector(31 downto 0);
|
|
seg3: out std_logic_vector(31 downto 0)
|
|
);
|
|
end circuit;
|
|
|
|
|
|
architecture description of circuit is
|
|
signal counter: unsigned(31 downto 0) := x"00000000";
|
|
begin
|
|
led <= std_logic_vector(counter);
|
|
process(clk)
|
|
begin
|
|
counter <= counter+1;
|
|
end process;
|
|
end description;`;
|
|
|
|
function getDomRefs() {
|
|
return {
|
|
statusPill: document.getElementById("statusPill"),
|
|
modeToggle: document.getElementById("modeToggle"),
|
|
connectToggleBtn: document.getElementById("connectToggleBtn"),
|
|
runToggleBtn: document.getElementById("runToggleBtn"),
|
|
|
|
editorSection: document.getElementById("editorSection"),
|
|
vhdlEditor: document.getElementById("vhdlEditor"),
|
|
lineGutter: document.getElementById("lineGutter"),
|
|
loadExampleBtn: document.getElementById("loadExampleBtn"),
|
|
|
|
ledRow: document.getElementById("ledRow"),
|
|
hexRow: document.getElementById("hexRow"),
|
|
|
|
switchGrid: document.getElementById("switchGrid"),
|
|
buttonGrid: document.getElementById("buttonGrid"),
|
|
keypadGrid: document.getElementById("keypadGrid"),
|
|
allSwOffBtn: document.getElementById("allSwOffBtn"),
|
|
allSwOnBtn: document.getElementById("allSwOnBtn"),
|
|
|
|
logView: document.getElementById("logView"),
|
|
clearLogBtn: document.getElementById("clearLogBtn"),
|
|
};
|
|
}
|
|
|
|
function parseBoolean(value) {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value !== "string") return false;
|
|
const normalized = value.trim().toLowerCase();
|
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
}
|
|
|
|
function getWebSocketUrl() {
|
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
return `${proto}://${location.host}`;
|
|
}
|
|
|
|
function u32BitGet(value, bitIndex) {
|
|
return ((value >>> bitIndex) & 1) === 1;
|
|
}
|
|
|
|
function u32BitSet(value, bitIndex, enabled) {
|
|
if (enabled) return (value | (1 << bitIndex)) >>> 0;
|
|
return (value & ~(1 << bitIndex)) >>> 0;
|
|
}
|
|
|
|
function parseHexDigits(hexU32) {
|
|
return [
|
|
(hexU32 >>> 0) & 0xff,
|
|
(hexU32 >>> 8) & 0xff,
|
|
(hexU32 >>> 16) & 0xff,
|
|
(hexU32 >>> 24) & 0xff,
|
|
];
|
|
}
|
|
|
|
function parseSegRowBytes(rawValue) {
|
|
// Accept [b0..b7], bigint, number, or numeric string (decimal / 0x-prefixed).
|
|
if (Array.isArray(rawValue)) {
|
|
const out = new Uint8Array(8);
|
|
for (let i = 0; i < 8 && i < rawValue.length; i += 1) {
|
|
out[i] = Number(rawValue[i]) & 0xff;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
let valueBigInt = null;
|
|
|
|
if (typeof rawValue === "bigint") {
|
|
valueBigInt = rawValue;
|
|
} else if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
|
valueBigInt = BigInt(Math.trunc(rawValue));
|
|
} else if (typeof rawValue === "string") {
|
|
const text = rawValue.trim();
|
|
if (text.length === 0) return null;
|
|
try {
|
|
valueBigInt = BigInt(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
const out = new Uint8Array(8);
|
|
for (let i = 0; i < 8; i += 1) {
|
|
out[i] = Number((valueBigInt >> BigInt(i * 8)) & 0xffn);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function isUnitMessage(msg, name) {
|
|
if (msg === name) return true;
|
|
if (msg && typeof msg === "object" && msg[name] !== undefined) return true;
|
|
return false;
|
|
}
|
|
|
|
class LogController {
|
|
constructor({ logView, clearLogBtn }) {
|
|
this.logView = logView;
|
|
this.clearLogBtn = clearLogBtn;
|
|
}
|
|
|
|
init() {
|
|
this.clearLogBtn.addEventListener("click", () => this.clear());
|
|
}
|
|
|
|
append(stream, line) {
|
|
const prefix = stream === "stderr" ? "[stderr]" : "[stdout]";
|
|
this.logView.textContent += `${prefix} ${line}\n`;
|
|
this.logView.scrollTop = this.logView.scrollHeight;
|
|
}
|
|
|
|
clear() {
|
|
this.logView.textContent = "";
|
|
}
|
|
}
|
|
|
|
class EditorController {
|
|
constructor({ editorSection, vhdlEditor, lineGutter, loadExampleBtn, enabled, externalFiles }) {
|
|
this.editorSection = editorSection;
|
|
this.vhdlEditor = vhdlEditor;
|
|
this.lineGutter = lineGutter;
|
|
this.loadExampleBtn = loadExampleBtn;
|
|
|
|
this.enabled = Boolean(enabled);
|
|
this.externalFiles = externalFiles && typeof externalFiles === "object" ? externalFiles : null;
|
|
this.saveTimer = null;
|
|
this.initialized = false;
|
|
}
|
|
|
|
init() {
|
|
this.setEnabled(this.enabled);
|
|
}
|
|
|
|
setEnabled(enabled) {
|
|
this.enabled = Boolean(enabled);
|
|
|
|
if (!this.enabled) {
|
|
this.editorSection.classList.add("is-hidden");
|
|
return;
|
|
}
|
|
|
|
this.initializeIfNeeded();
|
|
this.editorSection.classList.remove("is-hidden");
|
|
this.updateLineNumbers();
|
|
}
|
|
|
|
initializeIfNeeded() {
|
|
if (this.initialized) return;
|
|
this.initialized = true;
|
|
|
|
const saved = this.loadFromLocalStorage();
|
|
this.vhdlEditor.value = saved !== null ? saved : EXAMPLE_VHDL_TEXT;
|
|
|
|
this.loadExampleBtn.addEventListener("click", () => {
|
|
this.vhdlEditor.value = EXAMPLE_VHDL_TEXT;
|
|
this.saveToLocalStorageDebounced();
|
|
this.updateLineNumbers();
|
|
});
|
|
|
|
this.vhdlEditor.addEventListener("input", () => {
|
|
this.saveToLocalStorageDebounced();
|
|
this.updateLineNumbers();
|
|
});
|
|
|
|
this.vhdlEditor.addEventListener("scroll", () => {
|
|
this.lineGutter.scrollTop = this.vhdlEditor.scrollTop;
|
|
});
|
|
}
|
|
|
|
getFilesPayload() {
|
|
if (!this.enabled) {
|
|
return this.externalFiles ? { ...this.externalFiles } : {};
|
|
}
|
|
|
|
return {
|
|
"circuit.vhdl": this.vhdlEditor.value ?? "",
|
|
};
|
|
}
|
|
|
|
updateLineNumbers() {
|
|
const text = this.vhdlEditor.value || "";
|
|
const lineCount = text.length ? text.split("\n").length : 1;
|
|
|
|
let gutterText = "";
|
|
for (let i = 1; i <= lineCount; i += 1) {
|
|
gutterText += `${i}\n`;
|
|
}
|
|
|
|
this.lineGutter.textContent = gutterText;
|
|
}
|
|
|
|
saveToLocalStorageDebounced() {
|
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
|
|
this.saveTimer = setTimeout(() => {
|
|
try {
|
|
localStorage.setItem(LS_KEY_VHDL, this.vhdlEditor.value ?? "");
|
|
} catch {
|
|
// Ignore localStorage failures.
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
loadFromLocalStorage() {
|
|
try {
|
|
const saved = localStorage.getItem(LS_KEY_VHDL);
|
|
if (saved !== null) return saved;
|
|
} catch {
|
|
// Ignore localStorage failures.
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class OutputController {
|
|
constructor({ ledRow, hexRow }) {
|
|
this.ledRow = ledRow;
|
|
this.hexRow = hexRow;
|
|
|
|
this.ledEls = Array.from(this.ledRow.querySelectorAll(".led[data-bit]"));
|
|
this.segDisplays = Array.from(this.hexRow.querySelectorAll(".sevenSeg[data-digit]"));
|
|
|
|
this.segBytes = new Uint8Array(this.segDisplays.length);
|
|
this.segMap = { a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, dp: 7 };
|
|
}
|
|
|
|
init() {
|
|
this.resetVisuals();
|
|
}
|
|
|
|
resetVisuals() {
|
|
this.setLeds(0);
|
|
this.segBytes.fill(0);
|
|
this.renderAllSegments();
|
|
}
|
|
|
|
handleMessage(parsed) {
|
|
if (parsed.led !== undefined) {
|
|
const value = (parsed.led ?? parsed.value ?? parsed[0] ?? parsed["0"] ?? 0) >>> 0;
|
|
this.setLeds(value);
|
|
return true;
|
|
}
|
|
|
|
let handledSegment = false;
|
|
|
|
// Row mapping:
|
|
// seg0 -> displays 0..7
|
|
// seg1 -> displays 8..15
|
|
// seg2 -> displays 16..23
|
|
// seg3 -> displays 24..31
|
|
for (let row = 0; row < 4; row += 1) {
|
|
const key = `seg${row}`;
|
|
if (parsed[key] === undefined) continue;
|
|
|
|
const rowBytes = parseSegRowBytes(parsed[key]);
|
|
if (!rowBytes) continue;
|
|
|
|
this.setSegRow(row, rowBytes);
|
|
handledSegment = true;
|
|
}
|
|
|
|
// Backward-compat path for a single 32-bit value (fills first 4 displays).
|
|
if (!handledSegment && parsed.seg !== undefined) {
|
|
const bytes = parseHexDigits(Number(parsed.seg) >>> 0);
|
|
this.setSegRow(0, bytes);
|
|
handledSegment = true;
|
|
}
|
|
|
|
if (handledSegment) {
|
|
this.renderAllSegments();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
setLeds(bitsU32) {
|
|
for (const led of this.ledEls) {
|
|
const bit = Number(led.dataset.bit);
|
|
led.classList.toggle("on", u32BitGet(bitsU32, bit));
|
|
}
|
|
}
|
|
|
|
renderAllSegments() {
|
|
for (let i = 0; i < this.segDisplays.length; i += 1) {
|
|
const display = this.segDisplays[i];
|
|
const byte = this.segBytes[i] & 0xff;
|
|
|
|
for (const segEl of display.querySelectorAll(".seg")) {
|
|
const segName = segEl.dataset.seg;
|
|
const bit = this.segMap[segName];
|
|
const on = ((byte >>> bit) & 1) === 1;
|
|
segEl.classList.toggle("on", on);
|
|
}
|
|
}
|
|
}
|
|
|
|
setSegRow(rowIndex, bytes) {
|
|
const base = rowIndex * 8;
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const dst = base + i;
|
|
if (dst >= this.segBytes.length) break;
|
|
this.segBytes[dst] = bytes[i] & 0xff;
|
|
}
|
|
}
|
|
}
|
|
|
|
class InputController {
|
|
constructor({ switchGrid, buttonGrid, keypadGrid, allSwOffBtn, allSwOnBtn, sendClientInput }) {
|
|
this.switchGrid = switchGrid;
|
|
this.buttonGrid = buttonGrid;
|
|
this.keypadGrid = keypadGrid;
|
|
this.allSwOffBtn = allSwOffBtn;
|
|
this.allSwOnBtn = allSwOnBtn;
|
|
this.sendClientInput = sendClientInput;
|
|
|
|
this.switches = 0 >>> 0;
|
|
this.btn = 0 >>> 0;
|
|
this.matrixPressCounts = new Map();
|
|
}
|
|
|
|
init() {
|
|
this.bindSwitches();
|
|
this.bindStandardButtons();
|
|
this.bindKeypadMatrix();
|
|
|
|
this.allSwOffBtn.addEventListener("click", () => {
|
|
this.switches = 0 >>> 0;
|
|
this.syncSwitchesUI();
|
|
this.publishInput();
|
|
});
|
|
|
|
this.allSwOnBtn.addEventListener("click", () => {
|
|
this.switches = 0xffff_ffff >>> 0;
|
|
this.syncSwitchesUI();
|
|
this.publishInput();
|
|
});
|
|
}
|
|
|
|
getInputPayload() {
|
|
return {
|
|
switch: this.switches >>> 0,
|
|
buttons: this.btn >>> 0,
|
|
};
|
|
}
|
|
|
|
publishInput() {
|
|
this.sendClientInput(this.getInputPayload());
|
|
}
|
|
|
|
bindSwitches() {
|
|
const toggles = this.switchGrid.querySelectorAll(".toggle[data-bit]");
|
|
|
|
for (const toggle of toggles) {
|
|
toggle.addEventListener("click", () => {
|
|
const bit = Number(toggle.dataset.bit);
|
|
const nextValue = !u32BitGet(this.switches, bit);
|
|
this.switches = u32BitSet(this.switches, bit, nextValue);
|
|
toggle.classList.toggle("on", nextValue);
|
|
this.publishInput();
|
|
});
|
|
}
|
|
}
|
|
|
|
syncSwitchesUI() {
|
|
const toggles = this.switchGrid.querySelectorAll(".toggle[data-bit]");
|
|
|
|
for (const toggle of toggles) {
|
|
const bit = Number(toggle.dataset.bit);
|
|
toggle.classList.toggle("on", u32BitGet(this.switches, bit));
|
|
}
|
|
}
|
|
|
|
bindStandardButtons() {
|
|
const buttons = this.buttonGrid.querySelectorAll(".momentary[data-bit]");
|
|
|
|
for (const button of buttons) {
|
|
let isPressed = false;
|
|
|
|
const press = () => {
|
|
if (isPressed) return;
|
|
isPressed = true;
|
|
const bit = Number(button.dataset.bit);
|
|
this.btn = u32BitSet(this.btn, bit, true);
|
|
button.classList.add("down");
|
|
this.publishInput();
|
|
};
|
|
|
|
const release = () => {
|
|
if (!isPressed) return;
|
|
isPressed = false;
|
|
const bit = Number(button.dataset.bit);
|
|
this.btn = u32BitSet(this.btn, bit, false);
|
|
button.classList.remove("down");
|
|
this.publishInput();
|
|
};
|
|
|
|
button.addEventListener("mousedown", (event) => {
|
|
event.preventDefault();
|
|
press();
|
|
});
|
|
button.addEventListener("mouseup", (event) => {
|
|
event.preventDefault();
|
|
release();
|
|
});
|
|
button.addEventListener("mouseleave", (event) => {
|
|
event.preventDefault();
|
|
release();
|
|
});
|
|
|
|
button.addEventListener(
|
|
"touchstart",
|
|
(event) => {
|
|
event.preventDefault();
|
|
press();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
button.addEventListener(
|
|
"touchend",
|
|
(event) => {
|
|
event.preventDefault();
|
|
release();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
button.addEventListener(
|
|
"touchcancel",
|
|
(event) => {
|
|
event.preventDefault();
|
|
release();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
}
|
|
}
|
|
|
|
bumpMatrixBit(bit, delta) {
|
|
const current = this.matrixPressCounts.get(bit) ?? 0;
|
|
const next = Math.max(0, current + delta);
|
|
this.matrixPressCounts.set(bit, next);
|
|
|
|
const active = next > 0;
|
|
this.btn = u32BitSet(this.btn, bit, active);
|
|
}
|
|
|
|
bindKeypadMatrix() {
|
|
const keys = this.keypadGrid.querySelectorAll(".keypadBtn[data-row-bit][data-col-bit]");
|
|
|
|
for (const keyButton of keys) {
|
|
const rowBit = Number(keyButton.dataset.rowBit);
|
|
const colBit = Number(keyButton.dataset.colBit);
|
|
let isPressed = false;
|
|
|
|
const press = () => {
|
|
if (isPressed) return;
|
|
isPressed = true;
|
|
this.bumpMatrixBit(rowBit, +1);
|
|
this.bumpMatrixBit(colBit, +1);
|
|
keyButton.classList.add("down");
|
|
this.publishInput();
|
|
};
|
|
|
|
const release = () => {
|
|
if (!isPressed) return;
|
|
isPressed = false;
|
|
this.bumpMatrixBit(rowBit, -1);
|
|
this.bumpMatrixBit(colBit, -1);
|
|
keyButton.classList.remove("down");
|
|
this.publishInput();
|
|
};
|
|
|
|
keyButton.addEventListener("mousedown", (event) => {
|
|
event.preventDefault();
|
|
press();
|
|
});
|
|
keyButton.addEventListener("mouseup", (event) => {
|
|
event.preventDefault();
|
|
release();
|
|
});
|
|
keyButton.addEventListener("mouseleave", (event) => {
|
|
event.preventDefault();
|
|
release();
|
|
});
|
|
|
|
keyButton.addEventListener(
|
|
"touchstart",
|
|
(event) => {
|
|
event.preventDefault();
|
|
press();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
keyButton.addEventListener(
|
|
"touchend",
|
|
(event) => {
|
|
event.preventDefault();
|
|
release();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
keyButton.addEventListener(
|
|
"touchcancel",
|
|
(event) => {
|
|
event.preventDefault();
|
|
release();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class ConnectionController {
|
|
constructor({ connectToggleBtn, logger, onOpen, onMessage, onClose, onBeforeConnect, wsUrlFactory }) {
|
|
this.connectToggleBtn = connectToggleBtn;
|
|
this.logger = logger;
|
|
this.onOpen = onOpen;
|
|
this.onMessage = onMessage;
|
|
this.onClose = onClose;
|
|
this.onBeforeConnect = onBeforeConnect;
|
|
this.wsUrlFactory = wsUrlFactory;
|
|
|
|
this.ws = null;
|
|
this.connected = false;
|
|
}
|
|
|
|
init() {
|
|
this.setStatus(false);
|
|
this.connectToggleBtn.addEventListener("click", () => this.toggleConnect());
|
|
}
|
|
|
|
isConnected() {
|
|
return this.connected;
|
|
}
|
|
|
|
setStatus(connected) {
|
|
this.connected = connected;
|
|
|
|
this.connectToggleBtn.textContent = connected ? "Disconnect" : "Connect";
|
|
this.connectToggleBtn.classList.toggle("secondary", connected);
|
|
}
|
|
|
|
send(data) {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
this.ws.send(JSON.stringify(data));
|
|
}
|
|
|
|
toggleConnect() {
|
|
if (this.isConnected()) {
|
|
this.disconnect();
|
|
return;
|
|
}
|
|
|
|
this.connect();
|
|
}
|
|
|
|
connect() {
|
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
this.onBeforeConnect();
|
|
|
|
const url = this.wsUrlFactory();
|
|
this.ws = new WebSocket(url);
|
|
|
|
this.ws.addEventListener("open", () => {
|
|
this.setStatus(true);
|
|
this.logger.append("stdout", "WebSocket connected.");
|
|
this.onOpen();
|
|
});
|
|
|
|
this.ws.addEventListener("message", (event) => {
|
|
let parsed = null;
|
|
try {
|
|
parsed = JSON.parse(event.data);
|
|
} catch {
|
|
this.logger.append("stderr", String(event.data));
|
|
return;
|
|
}
|
|
|
|
this.onMessage(parsed, event.data);
|
|
});
|
|
|
|
this.ws.addEventListener("close", () => {
|
|
this.logger.append("stderr", "WebSocket closed.");
|
|
this.setStatus(false);
|
|
this.onClose();
|
|
});
|
|
|
|
this.ws.addEventListener("error", () => {
|
|
this.logger.append("stderr", "WebSocket error.");
|
|
this.setStatus(false);
|
|
});
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
class CircuitUiApp {
|
|
constructor(config) {
|
|
this.config = config;
|
|
this.dom = getDomRefs();
|
|
this.mode = config.initialMode;
|
|
this.isRunning = false;
|
|
this.reconnectTimer = null;
|
|
|
|
this.logs = new LogController(this.dom);
|
|
|
|
this.editor = new EditorController({
|
|
...this.dom,
|
|
enabled: config.initialMode === "local",
|
|
externalFiles: config.externalFiles,
|
|
});
|
|
|
|
this.outputs = new OutputController(this.dom);
|
|
|
|
this.inputs = new InputController({
|
|
...this.dom,
|
|
sendClientInput: (payload) => {
|
|
this.connection.send({ input: payload });
|
|
},
|
|
});
|
|
|
|
this.connection = new ConnectionController({
|
|
connectToggleBtn: this.dom.connectToggleBtn,
|
|
logger: this.logs,
|
|
wsUrlFactory: () => `${getWebSocketUrl()}/ws/${this.mode}`,
|
|
onBeforeConnect: () => {
|
|
this.logs.clear();
|
|
this.outputs.resetVisuals();
|
|
},
|
|
onOpen: () => {
|
|
if (this.mode === "local") {
|
|
this.connection.send(this.editor.getFilesPayload());
|
|
}
|
|
this.connection.send({ input: this.inputs.getInputPayload() });
|
|
this.setRunButtonEnabled(true);
|
|
this.updateStatusIndicator();
|
|
},
|
|
onMessage: (parsed, raw) => {
|
|
if (isUnitMessage(parsed, "start")) {
|
|
this.logs.clear();
|
|
this.outputs.resetVisuals();
|
|
this.setRunning(true);
|
|
return;
|
|
}
|
|
|
|
if (isUnitMessage(parsed, "stop")) {
|
|
this.setRunning(false);
|
|
return;
|
|
}
|
|
|
|
if (parsed.log !== undefined) {
|
|
this.logs.append(parsed.log.stream ?? "stdout", parsed.log.line ?? "");
|
|
return;
|
|
}
|
|
|
|
if (this.outputs.handleMessage(parsed)) {
|
|
return;
|
|
}
|
|
|
|
this.logs.append("stderr", `Unknown msg: ${raw}`);
|
|
},
|
|
onClose: () => {
|
|
this.setRunButtonEnabled(false);
|
|
this.setRunning(false);
|
|
this.updateStatusIndicator();
|
|
if (this.mode === "remote") {
|
|
this.scheduleReconnect();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
init() {
|
|
this.logs.init();
|
|
this.editor.init();
|
|
this.outputs.init();
|
|
this.inputs.init();
|
|
this.connection.init();
|
|
this.wireModeControls();
|
|
this.wireRunControls();
|
|
this.applyMode(this.mode, true);
|
|
}
|
|
|
|
wireRunControls() {
|
|
this.setRunning(false);
|
|
this.setRunButtonEnabled(false);
|
|
this.updateStatusIndicator();
|
|
|
|
this.dom.runToggleBtn.addEventListener("click", () => {
|
|
if (!this.connection.isConnected()) return;
|
|
|
|
if (this.isRunning) {
|
|
this.connection.send({ stop: null });
|
|
} else {
|
|
this.connection.send({ start: null });
|
|
}
|
|
});
|
|
}
|
|
|
|
wireModeControls() {
|
|
this.dom.modeToggle.addEventListener("change", () => {
|
|
const nextMode = this.dom.modeToggle.checked ? "remote" : "local";
|
|
this.applyMode(nextMode);
|
|
});
|
|
}
|
|
|
|
applyMode(nextMode, fromInit = false) {
|
|
const mode = nextMode === "remote" ? "remote" : "local";
|
|
const changed = this.mode !== mode;
|
|
|
|
if (!fromInit && changed && this.connection.isConnected()) {
|
|
this.connection.disconnect();
|
|
}
|
|
|
|
this.mode = mode;
|
|
try {
|
|
localStorage.setItem(LS_KEY_MODE, mode);
|
|
} catch {}
|
|
|
|
const isRemote = mode === "remote";
|
|
this.dom.modeToggle.checked = isRemote;
|
|
this.editor.setEnabled(!isRemote);
|
|
this.dom.connectToggleBtn.classList.toggle("is-hidden", isRemote);
|
|
this.dom.runToggleBtn.classList.toggle("is-hidden", !isRemote);
|
|
|
|
if (isRemote) {
|
|
this.scheduleReconnect(0);
|
|
} else {
|
|
this.cancelReconnect();
|
|
}
|
|
|
|
this.updateStatusIndicator();
|
|
}
|
|
|
|
setRunning(running) {
|
|
this.isRunning = Boolean(running);
|
|
this.dom.runToggleBtn.textContent = this.isRunning ? "Stop" : "Start";
|
|
this.dom.runToggleBtn.classList.toggle("secondary", this.isRunning);
|
|
this.updateStatusIndicator();
|
|
}
|
|
|
|
setRunButtonEnabled(enabled) {
|
|
this.dom.runToggleBtn.disabled = !enabled;
|
|
this.updateStatusIndicator();
|
|
}
|
|
|
|
scheduleReconnect(delayMs = 800) {
|
|
if (this.reconnectTimer !== null) return;
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
if (!this.connection.isConnected()) {
|
|
this.connection.connect();
|
|
}
|
|
}, delayMs);
|
|
}
|
|
|
|
cancelReconnect() {
|
|
if (this.reconnectTimer === null) return;
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
|
|
updateStatusIndicator() {
|
|
const pill = this.dom.statusPill;
|
|
const connected = this.connection.isConnected();
|
|
const running = connected && this.isRunning;
|
|
|
|
pill.classList.remove("state-disabled", "state-connected", "state-running");
|
|
|
|
if (!connected) {
|
|
pill.textContent = "DISABLED";
|
|
pill.classList.add("state-disabled");
|
|
return;
|
|
}
|
|
|
|
if (running) {
|
|
pill.textContent = "RUNNING";
|
|
pill.classList.add("state-running");
|
|
return;
|
|
}
|
|
|
|
pill.textContent = "CONNECTED";
|
|
pill.classList.add("state-connected");
|
|
}
|
|
}
|
|
|
|
function resolveConfig() {
|
|
const config = window.VHDL_UI_CONFIG ?? {};
|
|
const query = new URLSearchParams(location.search);
|
|
const queryMode = (query.get("mode") ?? "").toLowerCase();
|
|
|
|
let storedMode = "";
|
|
try {
|
|
storedMode = (localStorage.getItem(LS_KEY_MODE) ?? "").toLowerCase();
|
|
} catch {}
|
|
|
|
let initialMode = "local";
|
|
if (queryMode === "local" || queryMode === "remote") {
|
|
initialMode = queryMode;
|
|
} else if (storedMode === "local" || storedMode === "remote") {
|
|
initialMode = storedMode;
|
|
} else if (config.mode === "local" || config.mode === "remote") {
|
|
initialMode = config.mode;
|
|
} else if (query.has("externalEditor")) {
|
|
initialMode = parseBoolean(query.get("externalEditor")) ? "remote" : "local";
|
|
} else if (parseBoolean(config.externalEditor)) {
|
|
initialMode = "remote";
|
|
}
|
|
|
|
return {
|
|
initialMode,
|
|
externalFiles: config.externalFiles ?? null,
|
|
};
|
|
}
|
|
|
|
(function bootstrap() {
|
|
const app = new CircuitUiApp(resolveConfig());
|
|
app.init();
|
|
})();
|