From 0289d1171fba17361bde1122741fe4046d2b4599 Mon Sep 17 00:00:00 2001 From: Parker TenBroeck <51721964+ParkerTenBroeck@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:06:04 -0400 Subject: [PATCH] added support for local editing with different editor --- conn/src/lib.rs | 2 +- relay/src/build.rs | 53 +- relay/src/local.rs | 79 +-- relay/src/main.rs | 45 +- relay/src/remote.rs | 12 +- relay/ui/app.js | 1286 +++++++++++++++++++++++------------- relay/ui/index.html | 821 +++++++++++++++++++++-- relay/ui/styles.css | 869 ++++++++++++++---------- {examples => src}/cpu.vhdl | 113 ---- src/dram.vhdl | 29 + src/iram.vhdl | 39 ++ 11 files changed, 2252 insertions(+), 1096 deletions(-) rename {examples => src}/cpu.vhdl (74%) create mode 100644 src/dram.vhdl create mode 100644 src/iram.vhdl diff --git a/conn/src/lib.rs b/conn/src/lib.rs index 1b3ebb5..edcef09 100644 --- a/conn/src/lib.rs +++ b/conn/src/lib.rs @@ -12,7 +12,7 @@ pub struct SimState{ } static STATE: SimState = SimState{ - switch: AtomicU32::new(512), + switch: AtomicU32::new(0), button: AtomicU32::new(0), led: AtomicU32::new(0), segs: [const{AtomicU32::new(0)}; 4], diff --git a/relay/src/build.rs b/relay/src/build.rs index 1d5eafa..064f434 100644 --- a/relay/src/build.rs +++ b/relay/src/build.rs @@ -1,10 +1,10 @@ use std::{ - collections::HashMap, - ops::Deref, - path::{Path, PathBuf}, + collections::HashMap, ffi::OsStr, ops::Deref, path::{Path, PathBuf} }; use tokio::process::{Child, Command}; +use crate::HResult; + async fn ensure_ok(child: Child) -> Result<(), Box> { let result = child.wait_with_output().await?; if !result.status.success() { @@ -43,9 +43,9 @@ impl AsRef for TempDir { } } -pub async fn build( +pub async fn copy_and_build( files: HashMap, -) -> Result> { +) -> HResult { use std::hash::*; let mut hasher = std::hash::DefaultHasher::default(); for (key, value) in &files { @@ -56,7 +56,7 @@ pub async fn build( let mut work_dir = std::env::temp_dir(); work_dir.push(format!("ghdl-relay-{hash:x?}")); - _ = std::fs::create_dir(&work_dir); + std::fs::create_dir_all(&work_dir)?; let work_dir = TempDir(work_dir); for (name, contents) in &files { @@ -65,36 +65,51 @@ pub async fn build( std::fs::write(path, contents)?; } - let mut cmd = Command::new("ghdl"); + build(&work_dir, &work_dir).await?; + + Ok(work_dir) +} + + +pub async fn build(path: &Path, src: &Path) -> HResult<()>{ + std::fs::create_dir_all(path)?; + + let mut cmd = Command::new("ghdl"); cmd.kill_on_drop(true); - cmd.args(["-a", "-g", "--std=08"]); - for name in files.keys() { - let mut path = work_dir.clone(); - path.push(name); - cmd.arg(path); + cmd.args(["-i", "-g", "--std=08"]); + + for file in src.read_dir().unwrap().flatten(){ + if Path::new(&file.file_name()).extension() == Some(OsStr::new("vhdl")) { + cmd.arg(file.path()); + } } + cmd.arg(std::fs::canonicalize("../rtl/tb.vhdl")?); cmd.stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - cmd.current_dir(&work_dir); + cmd.current_dir(path); ensure_ok(cmd.spawn()?).await?; + + + + let mut cmd = Command::new("ghdl"); cmd.kill_on_drop(true); - cmd.args(["-e", "--std=08"]); + cmd.args(["-m", "--std=08"]); cmd.arg(format!( "-Wl,{}", - std::fs::canonicalize("../conn/target/release/libvhdl_ui.a")?.display() + std::fs::canonicalize("../target/release/libvhdl_ui.a")?.display() )); cmd.arg("tb"); - cmd.current_dir(&work_dir); + cmd.current_dir(path); cmd.stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); ensure_ok(cmd.spawn()?).await?; - - Ok(work_dir) -} + + Ok(()) +} \ No newline at end of file diff --git a/relay/src/local.rs b/relay/src/local.rs index 7dfeea6..db7576f 100644 --- a/relay/src/local.rs +++ b/relay/src/local.rs @@ -5,43 +5,15 @@ use futures_util::{ SinkExt, StreamExt, stream::{SplitSink, SplitStream}, }; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, path::PathBuf, time::Duration}; +use std::{path::PathBuf, time::Duration}; use tokio::{ io::{AsyncBufReadExt, BufReader, Lines}, - process::{Child, ChildStderr, ChildStdin, ChildStdout}, + process::{Child, ChildStderr, ChildStdin, ChildStdout}, time::Instant, }; -use crate::{build, run}; +use crate::{ClientMsg, ServerMsg, build, run}; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ClientMsg { - Compile(Option>), - Start, - Stop, - Input { - /// bitfield of 32 switches - switch: u32, - /// bitfield of 32 buttons - buttons: u32, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum ServerMsg<'a> { - Log { stream: &'a str, line: &'a str }, - Start, - Stop, - Led(u32), - Seg0(u32), - Seg1(u32), - Seg2(u32), - Seg3(u32), -} - struct Process { process: Child, @@ -50,10 +22,6 @@ struct Process { stdin: ChildStdin, } -enum Mode { - SingleLocal, - Remote, -} struct Handler { sender: SplitSink, @@ -68,11 +36,9 @@ struct Handler { refresh_time: Duration, } -type HResult = Result>; - impl Handler { - async fn local(socket: WebSocket, build: PathBuf, src: PathBuf) -> Self { + fn local(socket: WebSocket, build: PathBuf, src: PathBuf) -> Self { let (sender, receiver) = socket.split(); Self { sender, @@ -108,20 +74,18 @@ impl Handler { _ = self.sender.send(Message::Text(serde_json::to_string(&ServerMsg::Stop).unwrap_or_default().into())).await; } - async fn handle_websocket_msg(&mut self, msg: ClientMsg) -> HResult { + async fn handle_websocket_msg(&mut self, msg: ClientMsg) { match msg{ - ClientMsg::Compile(_) => todo!(), ClientMsg::Start => self.run_program().await, ClientMsg::Stop => self.stop_process().await, ClientMsg::Input { switch, buttons } => { if let Some(process) = &mut self.process{ use tokio::io::AsyncWriteExt; - process.stdin.write_all(format!("btn={}\n", buttons).as_bytes()).await?; - process.stdin.write_all(format!("sw={}\n", switch).as_bytes()).await?; + _ = process.stdin.write_all(format!("btn={}\n", buttons).as_bytes()).await; + _ = process.stdin.write_all(format!("sw={}\n", switch).as_bytes()).await; } }, } - Ok(false) } async fn handle_websocket_receive( @@ -150,13 +114,16 @@ impl Handler { } } - async fn run(&mut self) -> Result<(), Box> { + async fn run(&mut self) { loop { if let Some(process) = &mut self.process { if let Ok(Some(_)) = process.process.try_wait(){ self.stop_process().await; continue; } + + let mut print_deadline = Instant::now(); + tokio::select! { receive = self.receiver.next() => { if self.handle_websocket_receive(receive).await{ @@ -190,7 +157,7 @@ impl Handler { self.eprint(line).await; continue; }; - self.sender.send(Message::Text(serde_json::to_string(&msg)?.into())).await?; + _ = self.sender.send(Message::Text(serde_json::to_string(&msg).unwrap_or_default().into())).await; }, Ok(None) => self.stop_process().await, Err(err) => { @@ -199,8 +166,9 @@ impl Handler { } } } - _ = tokio::time::sleep(self.refresh_time) => { + _ = tokio::time::sleep_until(print_deadline) => { use tokio::io::AsyncWriteExt; + print_deadline += self.refresh_time; _ = process.stdin.write_all("\n".as_bytes()).await; } } @@ -211,20 +179,12 @@ impl Handler { } } } - Ok(()) } - async fn build_program(&mut self) { - let files = if let Some(Ok(Message::Text(msg))) = self.receiver.next().await - && let Ok(files) = serde_json::from_str::<'_, HashMap>(&msg) - { - files - } else { - return; - }; + async fn run_program(&mut self) { - let artifact_dir = match build::build(files).await { - Ok(dir) => dir, + match build::build(&self.build_dir, &self.src_dir).await { + Ok(_) => {}, Err(err) => { _ = self .sender @@ -233,9 +193,7 @@ impl Handler { return; } }; - } - async fn run_program(&mut self) { let process = match run::run(&self.build_dir).await { Ok(process) => process, Err(err) => { @@ -247,6 +205,7 @@ impl Handler { let stderr = BufReader::new(process.stderr).lines(); let stdin = process.stdin; + _ = self.sender.send(Message::Text(serde_json::to_string(&ServerMsg::Start).unwrap_or_default().into())).await; self.process = Some( Process { process: process.child, stderr, stdout, stdin } ) @@ -254,5 +213,5 @@ impl Handler { } pub async fn ws_handler(socket: WebSocket) { - Handler::local(socket, "target".into(), "src".into()); + Handler::local(socket, "../target".into(), "../src".into()).run().await; } diff --git a/relay/src/main.rs b/relay/src/main.rs index 0ae2b39..263e6a2 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -1,21 +1,12 @@ use axum::{ Router, - extract::ws::{Message, WebSocket, WebSocketUpgrade}, + extract::ws::WebSocketUpgrade, routing::get, }; -use futures_util::{ - stream::{SplitSink, SplitStream}, -}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, net::SocketAddr, path::PathBuf, time::Duration}; -use tokio::{ - io::{BufReader, Lines}, - process::{Child, ChildStderr, ChildStdin, ChildStdout}, -}; +use std::net::SocketAddr; use tower_http::services::ServeDir; -use crate::build::TempDir; - pub mod build; pub mod run; pub mod local; @@ -25,9 +16,13 @@ pub mod remote; async fn main() { let app = Router::new() .route( - "/ws", + "/ws/local", get(|ws: WebSocketUpgrade| async move { ws.on_upgrade(remote::ws_handler) }), ) + .route( + "/ws/remote", + get(|ws: WebSocketUpgrade| async move { ws.on_upgrade(local::ws_handler) }), + ) .fallback_service(ServeDir::new("ui")); let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); @@ -39,9 +34,8 @@ async fn main() { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum ClientMsg { - Compile(Option>), Start, Stop, Input { @@ -65,25 +59,4 @@ pub enum ServerMsg<'a> { Seg3(u32), } -struct Process { - process: Child, - - stderr: Lines>, - stdout: Lines>, - stdin: ChildStdin, -} - -struct Handler { - sender: SplitSink, - receiver: SplitStream, - - build_dir: TempDir, - src_dir: PathBuf, - - program: Option, - process: Option, - - refresh_time: Duration, -} - -pub type HResult = Result>; \ No newline at end of file +pub type HResult = Result>; diff --git a/relay/src/remote.rs b/relay/src/remote.rs index 64cd133..14efff2 100644 --- a/relay/src/remote.rs +++ b/relay/src/remote.rs @@ -4,9 +4,9 @@ use axum::{ use futures_util::{ SinkExt, StreamExt, }; -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration}; use tokio::{ - io::{AsyncBufReadExt, BufReader}, + io::{AsyncBufReadExt, BufReader}, time::Instant, }; use crate::{ClientMsg, HResult, ServerMsg, build, run}; @@ -23,7 +23,7 @@ pub async fn ws_handler(socket: WebSocket) { }; - let artifact_dir = match build::build(files).await{ + let artifact_dir = match build::copy_and_build(files).await{ Ok(dir) => dir, Err(err) => { _ = sender.send(Message::Text(format!("Failed to build: {err}").into())).await; @@ -42,6 +42,8 @@ pub async fn ws_handler(socket: WebSocket) { let mut serr = BufReader::new(process.stderr).lines(); let artifact_prefix = artifact_dir.to_str().unwrap_or("\0\0NOPE"); + + let mut print_deadline = Instant::now(); let result: HResult<()> = async { loop{ @@ -52,7 +54,6 @@ pub async fn ws_handler(socket: WebSocket) { Some(Ok(Message::Text(msg))) => { let input = serde_json::from_str::<'_, ClientMsg>(&msg)?; match input{ - ClientMsg::Compile(_) => {}, ClientMsg::Start => {}, ClientMsg::Stop => break, ClientMsg::Input { switch, buttons } => { @@ -110,8 +111,9 @@ pub async fn ws_handler(socket: WebSocket) { } } } - _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => { + _ = tokio::time::sleep_until(print_deadline) => { use tokio::io::AsyncWriteExt; + print_deadline += Duration::from_millis(30); process.stdin.write_all("\n".as_bytes()).await?; } } diff --git a/relay/ui/app.js b/relay/ui/app.js index 6bbcc8d..75fa044 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -1,426 +1,7 @@ -// ----- LocalStorage keys ----- const LS_KEY_VHDL = "circuit_ui:circuit.vhdl"; +const LS_KEY_MODE = "circuit_ui:mode"; -// ----- State ----- -let ws = null; -let isConnected = false; - -let switches = 0 >>> 0; // u32 -let buttons = 0 >>> 0; // u32 - -let autoscroll = true; - -// ----- DOM ----- -const wsPill = document.getElementById("wsPill"); -const wsUrlText = document.getElementById("wsUrlText"); - -const connectToggleBtn = document.getElementById("connectToggleBtn"); -const sendInputsBtn = document.getElementById("sendInputsBtn"); - -const vhdlEditor = document.getElementById("vhdlEditor"); -const lineGutter = document.getElementById("lineGutter"); -const loadExampleBtn = document.getElementById("loadExampleBtn"); - -const ledRow = document.getElementById("ledRow"); -const ledLabels = document.getElementById("ledLabels"); -const hexRow = document.getElementById("hexRow"); - -const switchGrid = document.getElementById("switchGrid"); -const buttonGrid = document.getElementById("buttonGrid"); - -const logView = document.getElementById("logView"); -const clearLogBtn = document.getElementById("clearLogBtn"); -const autoscrollBtn = document.getElementById("autoscrollBtn"); - -const allSwOffBtn = document.getElementById("allSwOffBtn"); -const allSwOnBtn = document.getElementById("allSwOnBtn"); - -// ----- Helpers ----- -function wsUrl() { - const proto = (location.protocol === "https:") ? "wss" : "ws"; - return `${proto}://${location.host}/ws`; -} - -function setStatus(connected) { - isConnected = connected; - wsPill.textContent = connected ? "CONNECTED" : "DISCONNECTED"; - wsPill.style.borderColor = connected ? "rgba(34,197,94,.6)" : "rgba(239,68,68,.6)"; - wsPill.style.background = connected ? "rgba(34,197,94,.14)" : "rgba(239,68,68,.10)"; - - sendInputsBtn.disabled = !connected; - - // single button label - connectToggleBtn.textContent = connected ? "Disconnect" : "Connect"; - connectToggleBtn.classList.toggle("secondary", connected); -} - -function appendLog(stream, line) { - const prefix = stream === "stderr" ? "[stderr]" : "[stdout]"; - logView.textContent += `${prefix} ${line}\n`; - if (autoscroll) { - logView.scrollTop = logView.scrollHeight; - } -} - -function clearLogs() { - logView.textContent = ""; -} - -function resetOutputsVisuals() { - // reset LED/HEX visuals to 0 immediately - setLeds(0); - setSeg(0); -} - -function u32BitGet(x, i) { - return ((x >>> i) & 1) === 1; -} - -function u32BitSet(x, i, on) { - if (on) return (x | (1 << i)) >>> 0; - return (x & ~(1 << i)) >>> 0; -} - -function sendClientInput() { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - - const msg = { - type: "client_input", - switch: switches >>> 0, - buttons: buttons >>> 0, - }; - ws.send(JSON.stringify(msg)); -} - -function parseHexDigits(hexU32) { - const d0 = (hexU32 >>> 0) & 0xFF; - const d1 = (hexU32 >>> 8) & 0xFF; - const d2 = (hexU32 >>> 16) & 0xFF; - const d3 = (hexU32 >>> 24) & 0xFF; - return [d0, d1, d2, d3]; -} - -// ----- Line numbers ----- -function updateLineNumbers() { - const text = vhdlEditor.value || ""; - // count lines: number of '\n' + 1 (even empty text -> 1 line) - const lines = text.length ? (text.split("\n").length) : 1; - - // Build as one string for performance - let out = ""; - for (let i = 1; i <= lines; i++) out += i + "\n"; - lineGutter.textContent = out; - - syncGutterScroll(); -} - -function syncGutterScroll() { - // keep gutter aligned to editor scroll - lineGutter.scrollTop = vhdlEditor.scrollTop; -} - -// ----- LocalStorage save (debounced) ----- -let saveTimer = null; -function saveEditorToLocalStorageDebounced() { - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(() => { - try { - localStorage.setItem(LS_KEY_VHDL, vhdlEditor.value ?? ""); - } catch (e) { - // ignore storage failures (private mode, quota) - } - }, 250); -} - -function loadEditorFromLocalStorage() { - try { - const saved = localStorage.getItem(LS_KEY_VHDL); - if (saved !== null) return saved; - } catch {} - return null; -} - -// ----- UI Builders ----- -function buildLeds() { - ledRow.innerHTML = ""; - ledLabels.innerHTML = ""; - - for (let i = 0; i < 32; i++) { - const el = document.createElement("div"); - el.className = "led"; - el.title = `LED[${i}]`; - el.dataset.bit = String(i); - ledRow.appendChild(el); - - const lab = document.createElement("span"); - lab.textContent = String(i); - ledLabels.appendChild(lab); - } -} - -function setLeds(bitsU32) { - for (const el of ledRow.children) { - const i = Number(el.dataset.bit); - el.classList.toggle("on", u32BitGet(bitsU32 >>> 0, i)); - } -} - -function makeSevenSeg(digitIndex) { - const wrap = document.createElement("div"); - - const disp = document.createElement("div"); - disp.className = "sevenSeg"; - disp.dataset.digit = String(digitIndex); - - const segs = ["a","b","c","d","e","f","g","dp"]; - for (const s of segs) { - const seg = document.createElement("div"); - seg.className = `seg ${s}`; - seg.dataset.seg = s; - disp.appendChild(seg); - } - - const label = document.createElement("div"); - label.className = "digitLabel"; - label.textContent = `HEX[${digitIndex}]`; - - wrap.appendChild(disp); - wrap.appendChild(label); - return wrap; -} - -function buildHex() { - hexRow.innerHTML = ""; - for (let i = 0; i < 4; i++) { - hexRow.appendChild(makeSevenSeg(i)); - } -} - -function setSeg(hexU32, seg) { - const digits = parseHexDigits(hexU32 >>> 0); - - for (let i = 0; i < 4; i++) { - const byte = digits[i] & 0xFF; - const disp = hexRow.querySelector(`.sevenSeg[data-digit="${i}"]`); - if (!disp) continue; - - const map = { a:0, b:1, c:2, d:3, e:4, f:5, g:6, dp:7 }; - - for (const segEl of disp.querySelectorAll(".seg")) { - const name = segEl.dataset.seg; - const bit = map[name]; - const on = ((byte >>> bit) & 1) === 1; - segEl.classList.toggle("on", on); - } - } -} - -function buildSwitches() { - switchGrid.innerHTML = ""; - for (let i = 0; i < 32; i++) { - const cell = document.createElement("div"); - cell.className = "ioCell"; - - const label = document.createElement("label"); - label.textContent = `SW[${i}]`; - - const toggle = document.createElement("div"); - toggle.className = "toggle"; - toggle.dataset.bit = String(i); - toggle.title = `Toggle switch ${i}`; - - toggle.addEventListener("click", () => { - const bit = Number(toggle.dataset.bit); - const now = !u32BitGet(switches, bit); - switches = u32BitSet(switches, bit, now); - toggle.classList.toggle("on", now); - sendClientInput(); - }); - - cell.appendChild(toggle); - cell.appendChild(label); - switchGrid.appendChild(cell); - } -} - -function syncSwitchesUI() { - for (const toggle of switchGrid.querySelectorAll(".toggle")) { - const i = Number(toggle.dataset.bit); - toggle.classList.toggle("on", u32BitGet(switches, i)); - } -} - -function buildButtons() { - buttonGrid.innerHTML = ""; - for (let i = 0; i < 32; i++) { - const cell = document.createElement("div"); - cell.className = "ioCell"; - - const label = document.createElement("label"); - label.textContent = `KEY[${i}]`; - - const btn = document.createElement("button"); - btn.className = "momentary"; - btn.type = "button"; - btn.textContent = "press"; - btn.dataset.bit = String(i); - btn.title = `Momentary button ${i}`; - - const press = () => { - const bit = Number(btn.dataset.bit); - buttons = u32BitSet(buttons, bit, true); - btn.classList.add("down"); - sendClientInput(); - }; - - const release = () => { - const bit = Number(btn.dataset.bit); - buttons = u32BitSet(buttons, bit, false); - btn.classList.remove("down"); - sendClientInput(); - }; - - // Mouse - btn.addEventListener("mousedown", (e) => { e.preventDefault(); press(); }); - btn.addEventListener("mouseup", (e) => { e.preventDefault(); release(); }); - btn.addEventListener("mouseleave",(e) => { e.preventDefault(); release(); }); - - // Touch - btn.addEventListener("touchstart",(e) => { e.preventDefault(); press(); }, {passive:false}); - btn.addEventListener("touchend", (e) => { e.preventDefault(); release(); }, {passive:false}); - btn.addEventListener("touchcancel",(e)=> { e.preventDefault(); release(); }, {passive:false}); - - cell.appendChild(btn); - cell.appendChild(label); - buttonGrid.appendChild(cell); - } -} - -// ----- WebSocket ----- -function connect() { - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; - - // Requirement (3): reset logs + outputs when connect is pressed - clearLogs(); - resetOutputsVisuals(); - - const url = wsUrl(); - wsUrlText.textContent = url; - - ws = new WebSocket(url); - - ws.addEventListener("open", () => { - setStatus(true); - appendLog("stdout", "WebSocket connected."); - - // First message MUST be the file map - const files = { - "circuit.vhdl": vhdlEditor.value ?? "" - }; - ws.send(JSON.stringify(files)); - - // Push initial input state - sendClientInput(); - }); - - ws.addEventListener("message", (ev) => { - let parsed = null; - try { - parsed = JSON.parse(ev.data); - } catch { - appendLog("stderr", String(ev.data)); - return; - } - - - if (parsed.log !== undefined) { - appendLog(parsed.log.stream ?? "stdout", parsed.log.line ?? ""); - return; - } - - if (parsed.led !== undefined) { - const v = (parsed.led ?? parsed.value ?? parsed[0] ?? parsed["0"] ?? 0) >>> 0; - setLeds(v); - return; - } - - if (parsed.seg0 !== undefined) { - const v = (parsed.seg0 ?? parsed.value ?? parsed[0] ?? parsed["0"] ?? 0) >>> 0; - setSeg(v, 0); - return; - } - - if (parsed.seg1 !== undefined) { - const v = (parsed.seg1 ?? parsed.value ?? parsed[0] ?? parsed["0"] ?? 0) >>> 0; - setSeg(v, 1); - return; - } - - if (parsed.seg2 !== undefined) { - const v = (parsed.seg3 ?? parsed.value ?? parsed[0] ?? parsed["0"] ?? 0) >>> 0; - setSeg(v, 2); - return; - } - - if (parsed.seg3 !== undefined) { - const v = (parsed.seg3 ?? parsed.value ?? parsed[0] ?? parsed["0"] ?? 0) >>> 0; - setSeg(v, 3); - return; - } - - appendLog("stderr", `Unknown msg: ${ev.data}`); - }); - - ws.addEventListener("close", () => { - appendLog("stderr", "WebSocket closed."); - setStatus(false); - }); - - ws.addEventListener("error", () => { - appendLog("stderr", "WebSocket error."); - setStatus(false); - }); -} - -function disconnect() { - if (ws) ws.close(); -} - -function toggleConnect() { - if (isConnected) disconnect(); - else connect(); -} - -// ----- Wire up controls ----- -connectToggleBtn.addEventListener("click", toggleConnect); - -sendInputsBtn.addEventListener("click", () => { - appendLog("stdout", `Manual send: sw=${switches >>> 0} key=${buttons >>> 0}`); - sendClientInput(); -}); - -clearLogBtn.addEventListener("click", () => { clearLogs(); }); - -autoscrollBtn.addEventListener("click", () => { - autoscroll = !autoscroll; - autoscrollBtn.textContent = `Autoscroll: ${autoscroll ? "on" : "off"}`; -}); - -allSwOffBtn.addEventListener("click", () => { - switches = 0 >>> 0; - syncSwitchesUI(); - sendClientInput(); -}); - -allSwOnBtn.addEventListener("click", () => { - switches = 0xFFFF_FFFF >>> 0; - syncSwitchesUI(); - sendClientInput(); -}); - -loadExampleBtn.addEventListener("click", () => { - vhdlEditor.value = -`library ieee; +const EXAMPLE_VHDL_TEXT = `library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; @@ -428,10 +9,13 @@ use ieee.numeric_std.all; entity circuit is port ( clk: in std_logic; -- 500 Hz, period 2 ms - key: in std_logic_vector(31 downto 0); -- active low - sw: in std_logic_vector(31 downto 0); -- active high - led: out std_logic_vector(31 downto 0) := (others => '0'); -- active high - hex: out std_logic_vector(31 downto 0) := (others => '0') -- active low + 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; @@ -446,41 +30,833 @@ begin end process; end description;`; - saveEditorToLocalStorageDebounced(); - updateLineNumbers(); -}); +function getDomRefs() { + return { + statusPill: document.getElementById("statusPill"), + modeToggle: document.getElementById("modeToggle"), + connectToggleBtn: document.getElementById("connectToggleBtn"), + runToggleBtn: document.getElementById("runToggleBtn"), -// Editor events: save + line numbers + gutter sync -vhdlEditor.addEventListener("input", () => { - saveEditorToLocalStorageDebounced(); - updateLineNumbers(); -}); + editorSection: document.getElementById("editorSection"), + vhdlEditor: document.getElementById("vhdlEditor"), + lineGutter: document.getElementById("lineGutter"), + loadExampleBtn: document.getElementById("loadExampleBtn"), -vhdlEditor.addEventListener("scroll", () => { - syncGutterScroll(); -}); + ledRow: document.getElementById("ledRow"), + hexRow: document.getElementById("hexRow"), -// ----- Init ----- -(function init() { - wsUrlText.textContent = wsUrl(); + switchGrid: document.getElementById("switchGrid"), + buttonGrid: document.getElementById("buttonGrid"), + keypadGrid: document.getElementById("keypadGrid"), + allSwOffBtn: document.getElementById("allSwOffBtn"), + allSwOnBtn: document.getElementById("allSwOnBtn"), - buildLeds(); - buildHex(); - buildSwitches(); - buildButtons(); + logView: document.getElementById("logView"), + clearLogBtn: document.getElementById("clearLogBtn"), + }; +} - resetOutputsVisuals(); - setStatus(false); +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"; +} - // Load from localStorage if present - const saved = loadEditorFromLocalStorage(); - if (saved !== null) { - vhdlEditor.value = saved; - } else { - vhdlEditor.value = -`-- circuit.vhdl --- Paste your circuit here. The UI will send it on Connect.`; +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; } - updateLineNumbers(); -})(); \ No newline at end of file + 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(); +})(); diff --git a/relay/ui/index.html b/relay/ui/index.html index 4556b5c..a1d5095 100644 --- a/relay/ui/index.html +++ b/relay/ui/index.html @@ -7,50 +7,307 @@ -
-
-
-
-
Circuit Web UI
-
LEDs • HEX • Switches • Buttons • circuit.vhdl
-
-
- -
-
- DISCONNECTED - -
- - - - - -
-
-
- -
+
-
circuit.vhdl
+
VHDL
-
+
-
- Saved locally as you type. On Connect, the UI sends {"circuit.vhdl": "..."} as the first WebSocket message. +
+
+
Inputs
+ +
+ DISABLED + + + +
+ +
+ +
+
+
+ + +
+
Switches (32, latched)
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
Buttons (32, momentary)
+ +
Standard BTN (0-7, 16-31)
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
4x4 Keypad Matrix via BTN (8-15)
+
+ + + + + + + + + + + + + + + + + + + +
+
-
Outputs
@@ -58,55 +315,480 @@
-
LEDs (32)
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
HEX (4 digits, 7-seg + dp)
-
-
- Assumes each digit is 8 bits: bit0=a, 1=b, 2=c, 3=d, 4=e, 5=f, 6=g, 7=dp. +
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[0]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[1]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[2]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[3]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[4]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[5]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[6]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[7]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[8]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[9]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[10]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[11]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[12]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[13]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[14]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[15]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[16]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[17]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[18]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[19]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[20]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[21]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[22]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[23]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[24]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[25]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[26]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[27]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[28]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[29]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[30]
+
+
+
+
+
+
+
+
+
+
+
+
+
SEG[31]
+
+
- -
-
-
Inputs
-
- - -
-
- -
-
-
Switches (32, latched)
-
-
- -
-
Buttons (32, momentary)
-
-
-
- -
- Switches toggle a bit. Buttons set the bit while pressed (mouse/touch) and clear on release. -
-
- -
Logs
-
@@ -114,6 +796,13 @@
+ - \ No newline at end of file + diff --git a/relay/ui/styles.css b/relay/ui/styles.css index 830bf2c..800e2d3 100644 --- a/relay/ui/styles.css +++ b/relay/ui/styles.css @@ -1,396 +1,583 @@ -:root{ - --bg: #0b0f17; - --card: #121a2a; - --card2:#0f1726; - --text: #e6eefc; - --muted:#9fb0d0; - --accent:#58a6ff; - --ok:#22c55e; - --bad:#ef4444; - --warn:#f59e0b; - --line:#1f2a44; - --shadow: 0 12px 30px rgba(0,0,0,.35); +:root { + --bg: #1e1e1e; + --surface: #252526; + --surface-muted: #2a2a2b; + --surface-header: #2d2d30; + --surface-soft: #1f1f20; + --surface-soft-2: #2a2a2b; + + --text: #d4d4d4; + --text-muted: #a9adb3; + + --accent: #0e639c; + --accent-soft: #1177bb; + --accent-muted: #264f78; + --ok: #22c55e; + --bad: #ef4444; + --warn: #f59e0b; + + --border-strong: #4d4d4d; + --border: #3c3c3c; + --border-soft: #343436; + + --button-primary-bg: #0e639c; + --button-primary-bg-hover: #1177bb; + --button-secondary-bg: #33363a; + --button-secondary-bg-hover: #3f4349; + --button-border: #2f5f8f; + + --pill-bg: #323234; + --pill-border: #5e6166; + --pill-connected-bg: #1f2c23; + --pill-connected-border: #407a4d; + --pill-disconnected-bg: #342325; + --pill-disconnected-border: #8d4f56; + + --led-off-bg: #303033; + --led-off-border: #5b5d62; + --led-on-bg: #2a9d58; + --led-on-border: #5ccd88; + --led-group-bg: #242427; + --led-group-border: #4f5258; + + --seg-off-bg: #3a3b3f; + --seg-on-bg: #f59e0b; + + --toggle-off-bg: #3a3d41; + --toggle-off-knob: #c4c7cc; + --toggle-on-bg: #2f7d4d; + --toggle-on-knob: #7be8a5; + + --momentary-bg: #373a3e; + --momentary-border: #60646b; + --momentary-down-bg: #365a7f; + --momentary-down-border: #5f8fc0; + --keypad-op-bg: #44484f; + --transparent: transparent; + --card-shadow: 0 10px 18px rgba(0, 0, 0, 0.35); + --radius: 14px; + --radius-sm: 10px; --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji"; + --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; } -*{ box-sizing:border-box; } -html,body{ height:100%; } -body{ - margin:0; - background: radial-gradient(1000px 600px at 20% -10%, rgba(88,166,255,.20), transparent 60%), - radial-gradient(900px 500px at 90% 0%, rgba(34,197,94,.12), transparent 60%), - var(--bg); - color:var(--text); +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); font-family: var(--sans); } -.topbar{ - position: sticky; - top:0; - z-index: 10; - display:flex; - justify-content:space-between; - align-items:center; - padding:14px 16px; - border-bottom:1px solid rgba(255,255,255,.06); - background: rgba(11,15,23,.8); - backdrop-filter: blur(10px); +button { + padding: 8px 12px; + border: 1px solid var(--button-border); + border-radius: var(--radius-sm); + background: var(--button-primary-bg); + color: var(--text); + cursor: pointer; + transition: background 0.15s ease, transform 0.05s ease; } -.brand{ - display:flex; - gap:12px; - align-items:center; -} -.brand .dot{ - width:12px;height:12px;border-radius:50%; - background: var(--accent); - box-shadow: 0 0 18px rgba(88,166,255,.7); -} -.title{ font-weight:700; letter-spacing:.2px; } -.subtitle{ font-size:12px; color:var(--muted); margin-top:2px; } - -.controls{ - display:flex; - gap:10px; - align-items:center; -} -.status{ - display:flex; - gap:8px; - align-items:center; - margin-right:6px; -} -.pill{ - font-size:12px; - padding:6px 10px; - border-radius:999px; - border:1px solid rgba(255,255,255,.12); - background: rgba(255,255,255,.04); -} -.muted{ color:var(--muted); font-size:12px; } - -button{ - border:1px solid rgba(255,255,255,.14); - background: rgba(88,166,255,.14); - color:var(--text); - padding:8px 12px; - border-radius:10px; - cursor:pointer; - transition: transform .05s ease, background .2s ease; -} -button:hover{ background: rgba(88,166,255,.20); } -button:active{ transform: translateY(1px); } -button.secondary{ - background: rgba(255,255,255,.06); -} -button.secondary:hover{ background: rgba(255,255,255,.10); } -button:disabled{ - opacity:.55; - cursor:not-allowed; +button:hover { + background: var(--button-primary-bg-hover); } -.grid{ - display:grid; - grid-template-columns: 1.2fr 1fr; - grid-template-rows: 420px 1fr; - gap:14px; - padding:14px; +button:active { + transform: translateY(1px); } -.card{ - background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.03)); - border:1px solid rgba(255,255,255,.08); - border-radius: var(--radius); - box-shadow: var(--shadow); - overflow:hidden; - min-height: 0; +button.secondary { + background: var(--button-secondary-bg); } -.cardHeader{ - display:flex; - justify-content:space-between; - align-items:center; - padding:12px 12px; - border-bottom:1px solid rgba(255,255,255,.06); - background: rgba(0,0,0,.15); +button.secondary:hover { + background: var(--button-secondary-bg-hover); } -.cardTitle{ font-weight:700; } -.cardActions{ display:flex; gap:8px; } -.editor{ - display:flex; - flex-direction:column; +button:focus-visible { + outline: 1px solid var(--accent-soft); + outline-offset: 1px; } -textarea{ - width:100%; - height:100%; - padding:12px; - background: rgba(0,0,0,.22); - color:var(--text); - border:0; - outline:none; - resize:none; + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +textarea { + width: 100%; + height: 100%; + padding: 12px; + border: 0; + outline: none; + resize: none; + color: var(--text); + background: var(--surface-soft); font-family: var(--mono); font-size: 13px; line-height: 1.35; } -.hint{ - padding:10px 12px; - border-top:1px solid rgba(255,255,255,.06); - color:var(--muted); - font-size:12px; - background: rgba(0,0,0,.12); -} -.hint.small{ font-size:11px; } -.io .outputs{ - padding:12px; - display:flex; - flex-direction:column; - gap:14px; -} -.blockTitle{ - font-size:12px; - color:var(--muted); - margin-bottom:8px; +.muted { + font-size: 12px; + color: var(--text-muted); } -.ledRow{ - display:grid; - grid-template-columns: repeat(16, 1fr); - gap:6px; +.is-hidden { + display: none !important; } -.led{ + +.grid { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 14px; + padding: 14px; +} + +.card { + flex: 1 1 520px; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--card-shadow); +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 12px; + border-bottom: 1px solid var(--border-soft); + background: var(--surface-header); +} + +.cardHeaderWrap { + flex-wrap: wrap; +} + +.cardTitle { + font-weight: 700; +} + +.cardActions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.panelTopStatus { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-wrap: nowrap; + white-space: nowrap; + min-width: 0; + flex: 0 0 auto; +} + +.pill { + display: inline-flex; + justify-content: center; + align-items: center; + padding: 6px 10px; + font-size: 12px; + border: 1px solid var(--pill-border); + border-radius: 999px; + background: var(--pill-bg); + min-width: 104px; +} + +.pill.state-disabled { + border-color: var(--pill-disconnected-border); + background: var(--pill-disconnected-bg); +} + +.pill.state-connected { + border-color: color-mix(in srgb, var(--accent-soft) 75%, white 25%); + background: color-mix(in srgb, var(--accent-muted) 72%, black 28%); +} + +.pill.state-running { + border-color: var(--pill-connected-border); + background: var(--pill-connected-bg); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ok) 40%, transparent); +} + +.modeToggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted); + user-select: none; + flex: 0 0 auto; +} + +.modeToggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.modeSlider { + width: 36px; + height: 18px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--toggle-off-bg); + position: relative; +} + +.modeSlider::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--toggle-off-knob); + transition: transform 0.12s ease, background 0.12s ease; +} + +.modeToggle input:checked + .modeSlider { + background: var(--accent-muted); +} + +.modeToggle input:checked + .modeSlider::after { + transform: translateX(17px); + background: var(--accent-soft); +} + +.blockTitle { + margin-bottom: 8px; + font-size: 12px; + color: var(--text-muted); +} + +.subBlockTitle { + margin: 10px 0 8px; + font-size: 11px; + color: var(--text-muted); +} + +.hint { + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + color: var(--text-muted); + font-size: 12px; + background: var(--surface-soft-2); +} + +.editor { + display: flex; + flex-direction: column; + min-height: 420px; + flex: 1.4 1 620px; +} + +.editorWrap { + display: flex; width: 100%; - aspect-ratio: 1/1; - border-radius: 999px; - border:1px solid rgba(255,255,255,.14); - background: rgba(255,255,255,.08); - box-shadow: inset 0 0 0 2px rgba(0,0,0,.18); + height: 100%; + min-height: 0; + background: var(--surface-soft); } -.led.on{ - background: rgba(34,197,94,.70); - border-color: rgba(34,197,94,.9); - box-shadow: 0 0 14px rgba(34,197,94,.45); -} -.bitLabelRow{ - display:grid; - grid-template-columns: repeat(16, 1fr); - gap:6px; - margin-top:6px; - color: var(--muted); - font-size: 10px; + +.lineGutter { + width: 54px; + margin: 0; + padding: 12px 10px 12px 12px; + overflow: hidden; + user-select: none; + text-align: right; + border-right: 1px solid var(--border-soft); + color: var(--text-muted); + background: var(--surface-soft-2); font-family: var(--mono); - opacity: .85; -} -.bitLabelRow span{ - text-align:center; + font-size: 13px; + line-height: 1.35; } -.hexRow{ - display:flex; - gap:14px; - flex-wrap:wrap; - align-items:center; +textarea#vhdlEditor { + flex: 1; + overflow: auto; + overscroll-behavior-y: none; + background: var(--transparent); } -/* 7-seg display */ -.sevenSeg{ - width: 90px; - height: 140px; - position: relative; - background: rgba(0,0,0,.18); - border:1px solid rgba(255,255,255,.10); - border-radius: 14px; - padding:10px; +.io .outputs { + display: flex; + flex-direction: column; + gap: 14px; + padding: 12px; } -.seg{ - position:absolute; - background: rgba(255,255,255,.10); + +.ledRow { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.ledGroup { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 6px; + padding: 8px; + border: 1px solid var(--led-group-border); border-radius: 8px; - filter: drop-shadow(0 0 0 rgba(0,0,0,0)); -} -.seg.on{ - background: rgba(88,166,255,.75); - filter: drop-shadow(0 0 8px rgba(88,166,255,.35)); + background: var(--led-group-bg); } -/* segment positions */ -.seg.a{ top:10px; left:18px; width:54px; height:12px; } -.seg.d{ bottom:10px; left:18px; width:54px; height:12px; } -.seg.g{ top:64px; left:18px; width:54px; height:12px; } - -.seg.f{ top:18px; left:10px; width:12px; height:54px; } -.seg.b{ top:18px; right:10px; width:12px; height:54px; } - -.seg.e{ bottom:18px; left:10px; width:12px; height:54px; } -.seg.c{ bottom:18px; right:10px; width:12px; height:54px; } - -.seg.dp{ - width:12px; height:12px; - bottom:10px; right:10px; - border-radius: 999px; -} - -.digitLabel{ - margin-top:8px; - font-family: var(--mono); - font-size: 11px; - color: var(--muted); - text-align:center; -} - -.inputs{ - grid-column: 1 / 2; - display:flex; - flex-direction:column; - min-height: 0; -} -.twoCols{ - padding:12px; - display:grid; - grid-template-columns: 1fr 1fr; - gap:12px; - min-height: 0; -} -.ioGrid{ - display:grid; - grid-template-columns: repeat(8, 1fr); - gap:8px; -} -.ioCell{ - display:flex; - flex-direction:column; - gap:6px; - align-items:center; - padding:8px 6px; - background: rgba(0,0,0,.16); - border:1px solid rgba(255,255,255,.08); - border-radius:12px; -} -.ioCell label{ - font-family: var(--mono); - font-size: 11px; - color: var(--muted); -} - -.toggle{ - width: 38px; - height: 22px; - border-radius: 999px; +.led { position: relative; - background: rgba(255,255,255,.10); - border:1px solid rgba(255,255,255,.14); - cursor:pointer; -} -.toggle::after{ - content:""; - width: 16px; - height: 16px; - position:absolute; - top:2px; - left:2px; + display: grid; + place-items: center; + width: 100%; + border: 1px solid var(--led-off-border); border-radius: 999px; - background: rgba(255,255,255,.45); - transition: left .12s ease, background .12s ease; -} -.toggle.on{ - background: rgba(34,197,94,.24); - border-color: rgba(34,197,94,.55); -} -.toggle.on::after{ - left: 20px; - background: rgba(34,197,94,.75); + background: var(--led-off-bg); + aspect-ratio: 1 / 1; } -.momentary{ - width: 40px; - height: 28px; +.led::after { + content: attr(data-bit); + color: var(--text-muted); + font-family: var(--mono); + font-size: 8px; + line-height: 1; +} + +.led.on { + border-color: var(--led-on-border); + background: var(--led-on-bg); + box-shadow: 0 0 6px color-mix(in srgb, var(--led-on-bg) 50%, transparent); +} + +.hexRow { + display: grid; + grid-template-columns: 1fr; + gap: 6px; + overflow-x: hidden; +} + +.hexGroup { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 4px; + width: 100%; + padding: 6px; + border: 1px solid var(--border-soft); border-radius: 10px; - border:1px solid rgba(255,255,255,.14); - background: rgba(255,255,255,.08); - cursor:pointer; -} -.momentary.down{ - background: rgba(245,158,11,.25); - border-color: rgba(245,158,11,.55); - box-shadow: 0 0 12px rgba(245,158,11,.18); + background: var(--surface-muted); } -.logs{ - grid-column: 2 / 3; - display:flex; - flex-direction:column; - min-height: 0; +.hexDigitWrap { + display: flex; + flex-direction: column; + align-items: center; + min-width: 0; } -.logView{ - margin:0; - padding:12px; - height:100%; - overflow:auto; - background: rgba(0,0,0,.22); + +.sevenSeg { + position: relative; + width: 100%; + max-width: 88px; + aspect-ratio: 88 / 136; + padding: 8%; + border: 1px solid var(--border-strong); + border-radius: 12px; + background: var(--surface-soft); +} + +.seg { + position: absolute; + border-radius: 6px; + background: var(--seg-off-bg); +} + +.seg.on { + background: var(--seg-on-bg); + box-shadow: 0 0 4px color-mix(in srgb, var(--seg-on-bg) 45%, transparent); +} + +.seg.a { top: 8%; left: 21%; width: 58%; height: 7%; } +.seg.b { top: 14%; right: 11%; width: 10%; height: 34%; } +.seg.c { bottom: 14%; right: 11%; width: 10%; height: 34%; } +.seg.d { bottom: 8%; left: 21%; width: 58%; height: 7%; } +.seg.e { bottom: 14%; left: 11%; width: 10%; height: 34%; } +.seg.f { top: 14%; left: 11%; width: 10%; height: 34%; } +.seg.g { top: 46.5%; left: 21%; width: 58%; height: 7%; } +.seg.dp { + right: 5%; + bottom: 5%; + width: 9%; + height: 9%; + border-radius: 999px; +} + +.digitLabel { + margin-top: 4px; + color: var(--text-muted); + font-family: var(--mono); + font-size: 10px; +} + +.inputs { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1.2 1 560px; +} + +.twoCols { + display: grid; + gap: 12px; + min-height: 0; + padding: 12px; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.ioGrid { + display: grid; + gap: 6px; + grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); +} + +.ioCell { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 64px; + min-height: 52px; + padding: 6px 4px; + border: 1px solid var(--border-soft); + border-radius: 12px; + background: var(--surface-soft); +} + +.ioCell label { + color: var(--text-muted); + font-family: var(--mono); + font-size: 11px; +} + +.toggle { + position: relative; + width: 34px; + height: 20px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--toggle-off-bg); + cursor: pointer; +} + +.toggle::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--toggle-off-knob); + transition: left 0.12s ease, background 0.12s ease; +} + +.toggle.on { + background: var(--toggle-on-bg); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--toggle-on-knob) 35%, transparent); +} + +.toggle.on::after { + left: 16px; + background: var(--toggle-on-knob); +} + +.momentary { + width: 34px; + height: 24px; + border: 1px solid var(--momentary-border); + border-radius: var(--radius-sm); + background: var(--momentary-bg); +} + +.momentary.down { + border-color: var(--momentary-down-border); + background: var(--momentary-down-bg); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-soft) 35%, transparent); +} + +.keypadGrid { + display: grid; + gap: 6px; + grid-template-columns: repeat(4, minmax(50px, 1fr)); +} + +.keypadBtn { + width: 100%; + min-height: 32px; + font-family: var(--mono); + font-size: 12px; +} + +.keypadBtn.op { + background: var(--keypad-op-bg); +} + +.keypadBtn.op:hover { + background: var(--accent-muted); +} + +.logs { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 560px; +} + +.logView { + height: 100%; + margin: 0; + padding: 12px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + background: var(--surface-soft); font-family: var(--mono); font-size: 12px; line-height: 1.35; - white-space: pre-wrap; - word-break: break-word; } -/* Responsive */ -@media (max-width: 1100px){ - .grid{ - grid-template-columns: 1fr; - grid-template-rows: auto; +@media (max-width: 1100px) { + .card { + flex-basis: 100%; } - .inputs, .logs{ - grid-column: auto; - } - .twoCols{ + + .twoCols { grid-template-columns: 1fr; } -} -/* Editor w/ line numbers */ -.editorWrap{ - display:flex; - width:100%; - height:100%; - min-height: 0; - background: rgba(0,0,0,.22); -} - -.lineGutter{ - margin:0; - padding:12px 10px 12px 12px; - width: 54px; /* gutter width */ - overflow:hidden; - user-select:none; - text-align:right; - color: rgba(159,176,208,.85); - font-family: var(--mono); - font-size: 13px; - line-height: 1.35; - border-right: 1px solid rgba(255,255,255,.06); - background: rgba(0,0,0,.10); -} - -#lineGutter .ln { display:block; } - -textarea#vhdlEditor{ - overscroll-behavior-y: none; - flex:1; - height:100%; - padding:12px; - background: transparent; /* uses editorWrap background */ - color:var(--text); - border:0; - outline:none; - resize:none; - font-family: var(--mono); - font-size: 13px; - line-height: 1.35; - overflow:auto; + .panelTopStatus { + margin-left: auto; + width: auto; + justify-content: flex-end; + } } diff --git a/examples/cpu.vhdl b/src/cpu.vhdl similarity index 74% rename from examples/cpu.vhdl rename to src/cpu.vhdl index d1fd124..32e695a 100644 --- a/examples/cpu.vhdl +++ b/src/cpu.vhdl @@ -17,119 +17,6 @@ port ( end circuit; -library ieee; -use ieee.std_logic_1164.all; -use ieee.numeric_std.all; - -entity alu is - Port ( - func: in unsigned(3 downto 0); - a: in unsigned(7 downto 0); - b: in unsigned(7 downto 0); - carry_in: in std_logic; - o: out unsigned(7 downto 0); - carry_out: out std_logic; - zero: out std_logic; - gt: out std_logic; - lt: out std_logic; - eq: out std_logic - ); -end alu; - -architecture Behavioral of alu is - signal tmp: unsigned(8 downto 0) := "000000000"; -begin - with func select - tmp <= ("0"&a) + ("0"&b) when x"0", - ("0"&a) + ("0"&b) + (x"00"&carry_in) when x"1", - ("0"&a) - ("0"&b) when x"2", - ("0"&a) - ("0"&b) - (x"00"&carry_in) when x"3", - ("0"&a) and ("0"&b) when x"4", - ("0"&a) or ("0"&b) when x"5", - ("0"&a) xor ("0"&b) when x"6", - "0"&x"00" when others; - - zero <= '1' when tmp = 0 else '0'; - eq <= '1' when a = b else '0'; - lt <= '1' when a < b else '0'; - gt <= '1' when a > b else '0'; - carry_out <= tmp(8); - o <= tmp(7 downto 0); - -end Behavioral ; -- Behavioral - -library ieee; -use ieee.std_logic_1164.all; -use ieee.numeric_std.all; - -entity ram_8x256 is - Port ( - clk : in std_logic; - we : in std_logic; -- write enable - addr : in unsigned(7 downto 0); -- 8-bit address - din : in unsigned(7 downto 0); -- data input - dout : out unsigned(7 downto 0) -- data output - ); -end ram_8x256; - -architecture Behavioral of ram_8x256 is - type ram_type is array (0 to 255) of unsigned(7 downto 0); - signal ram : ram_type := (others => x"AB"); -begin - process(clk) - begin - if rising_edge(clk) then - if we = '1' then - ram(to_integer(unsigned(addr))) <= din; - end if; - - dout <= ram(to_integer(unsigned(addr))); - end if; - end process; -end Behavioral; - - -library ieee; -use ieee.std_logic_1164.all; -use ieee.numeric_std.all; - -entity inst_ram_8x256 is - Port ( - clk : in std_logic; - addr : in unsigned(7 downto 0); -- 8-bit address - dout : out unsigned(7 downto 0) -- data output - ); -end inst_ram_8x256; - -architecture Behavioral of inst_ram_8x256 is - type ram_type is array (0 to 255) of unsigned(7 downto 0); - signal ram : ram_type := ( - 0 => x"A0", -- 0 => a - 1 => x"B1", -- 1 => b - 2 => x"10", -- a+b => out - 3 => x"FE", -- out - 4 => x"AE", -- out => a - 5 => x"01", -- swap a/b - - 6 => x"3F", -- cmp 144, b - 7 => x"90", - - 8 => x"C7", -- jump to 2 if 144 <= b - 9 => x"02", - - 10 => x"FF", -- halt - others => (others => '0') - ); -begin - process(clk) - begin - if rising_edge(clk) or falling_edge(clk) then - dout <= ram(to_integer(unsigned(addr))); - end if; - end process; -end Behavioral; - - library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; diff --git a/src/dram.vhdl b/src/dram.vhdl new file mode 100644 index 0000000..1968d95 --- /dev/null +++ b/src/dram.vhdl @@ -0,0 +1,29 @@ +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +entity ram_8x256 is + Port ( + clk : in std_logic; + we : in std_logic; -- write enable + addr : in unsigned(7 downto 0); -- 8-bit address + din : in unsigned(7 downto 0); -- data input + dout : out unsigned(7 downto 0) -- data output + ); +end ram_8x256; + +architecture Behavioral of ram_8x256 is + type ram_type is array (0 to 255) of unsigned(7 downto 0); + signal ram : ram_type := (others => x"AB"); +begin + process(clk) + begin + if rising_edge(clk) then + if we = '1' then + ram(to_integer(unsigned(addr))) <= din; + end if; + + dout <= ram(to_integer(unsigned(addr))); + end if; + end process; +end Behavioral; diff --git a/src/iram.vhdl b/src/iram.vhdl new file mode 100644 index 0000000..cb233ea --- /dev/null +++ b/src/iram.vhdl @@ -0,0 +1,39 @@ +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +entity inst_ram_8x256 is + Port ( + clk : in std_logic; + addr : in unsigned(7 downto 0); -- 8-bit address + dout : out unsigned(7 downto 0) -- data output + ); +end inst_ram_8x256; + +architecture Behavioral of inst_ram_8x256 is + type ram_type is array (0 to 255) of unsigned(7 downto 0); + signal ram : ram_type := ( + 0 => x"A0", -- 0 => a + 1 => x"B1", -- 1 => b + 2 => x"10", -- a+b => out + 3 => x"FE", -- out + 4 => x"AE", -- out => a + 5 => x"01", -- swap a/b + + 6 => x"3F", -- cmp 144, b + 7 => x"90", + + 8 => x"C7", -- jump to 2 if 144 <= b + 9 => x"02", + + 10 => x"FF", -- halt + others => (others => '0') + ); +begin + process(clk) + begin + if rising_edge(clk) or falling_edge(clk) then + dout <= ram(to_integer(unsigned(addr))); + end if; + end process; +end Behavioral;