diff --git a/Dockerfile b/Dockerfile index a71c86e..a4076c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,8 @@ WORKDIR /app COPY . . # Build the application +# Note: fix-links.sh rewrites asset paths for GitHub Pages (/simplicity-webide/ subdirectory). +# For local Docker development, comment out "sh fix-links.sh" below. RUN trunk build --release && \ sh fix-links.sh diff --git a/flake.nix b/flake.nix index 8301784..d3af3d1 100644 --- a/flake.nix +++ b/flake.nix @@ -18,6 +18,7 @@ "x86_64-linux" "aarch64-linux" "x86_64-darwin" + "aarch64-darwin" ] (system: let overlays = [ diff --git a/index.html b/index.html index f3225a8..249b64c 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,10 @@ + + + + diff --git a/src/assets/js/dag_viewer.js b/src/assets/js/dag_viewer.js new file mode 100644 index 0000000..d17b0c1 --- /dev/null +++ b/src/assets/js/dag_viewer.js @@ -0,0 +1,217 @@ +// DAG Viewer using D3.js for Simplicity program visualization + +let dagSvg, dagZoom, dagZoomInitialTransform; +let dagRenderedFor = null; // Track which JSON we've rendered to avoid re-renders + +// Color scheme for different node types +const kindColors = { + jet: '#ff9517', // Orange - jets are important + leaf: '#4ade80', // Green - leaf nodes (witness, word, fail) + plumbing: '#6b7280', // Gray - structural nodes (iden, unit, comp, take, drop) + sum: '#8b5cf6', // Purple - sum types (injl, injr, case) + product: '#3b82f6', // Blue - product types (pair) + assertion: '#ef4444', // Red - assertions + advanced: '#f59e0b', // Amber - advanced (disconnect) +}; + +// Make renderDag available globally for Rust to call +window.renderDag = function(containerId, dagJson) { + const container = document.getElementById(containerId); + if (!container) return; + + if (dagRenderedFor === dagJson && container.querySelector('svg')) { + return; + } + dagRenderedFor = dagJson; + + let dag; + try { + dag = JSON.parse(dagJson); + } catch (e) { + console.error('Failed to parse DAG JSON:', e); + return; + } + + if (!dag.nodes || dag.nodes.length === 0) { + container.innerHTML = '
No nodes to display
'; + return; + } + + // Check node count limit + if (dag.nodes.length > 500) { + container.innerHTML = `
Too many nodes to display (${dag.nodes.length}). Consider simplifying the program.
`; + return; + } + + container.innerHTML = ''; + + // Create SVG + const width = container.clientWidth || 800; + const height = container.clientHeight || 500; + + const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + container.appendChild(svgEl); + + dagSvg = d3.select(svgEl) + .attr('width', width) + .attr('height', height); + + // Build hierarchy from DAG + const nodeMap = new Map(); + dag.nodes.forEach(n => nodeMap.set(n.id, { ...n, children: [] })); + + // Build parent-child relationships + dag.edges.forEach(e => { + const parent = nodeMap.get(e.from); + const child = nodeMap.get(e.to); + if (parent && child) { + parent.children.push(child); + } + }); + + // Find root node + const root = nodeMap.get(dag.root_id) || nodeMap.values().next().value; + if (!root) { + container.innerHTML = '
No root node found
'; + return; + } + + // Create D3 hierarchy + const hierarchy = d3.hierarchy(root, d => d.children); + + // Layout settings + const nodeSize = [140, 36]; + const nodeGap = [50, 20]; + + const treeLayout = d3.tree() + .nodeSize([nodeSize[1] + nodeGap[1], nodeSize[0] + nodeGap[0]]); + + const treeData = treeLayout(hierarchy); + + // Set up zoom + const zoomG = dagSvg.append('g'); + + dagZoom = d3.zoom() + .scaleExtent([0.1, 3]) + .on('zoom', (e) => { + zoomG.attr('transform', e.transform); + }); + + dagSvg.call(dagZoom); + + // Calculate tree bounds for fit-to-view + const nodes = treeData.descendants(); + const xExtent = d3.extent(nodes, d => d.x); + const yExtent = d3.extent(nodes, d => d.y); + + const treeBounds = { + minX: xExtent[0] - nodeSize[1] / 2, + maxX: xExtent[1] + nodeSize[1] / 2, + minY: yExtent[0] - nodeSize[0] / 2, + maxY: yExtent[1] + nodeSize[0] / 2 + }; + + const treeWidth = treeBounds.maxY - treeBounds.minY; + const treeHeight = treeBounds.maxX - treeBounds.minX; + + // Calculate scale to fit with padding + const padding = 40; + const scaleX = (width - padding * 2) / treeWidth; + const scaleY = (height - padding * 2) / treeHeight; + const scale = Math.min(scaleX, scaleY, 1); // Don't zoom in past 1x + + // Calculate center offset to fit the tree in viewport + const centerX = width / 2 - (treeBounds.minY + treeWidth / 2) * scale; + const centerY = height / 2 - (treeBounds.minX + treeHeight / 2) * scale; + + dagZoomInitialTransform = d3.zoomIdentity.translate(centerX, centerY).scale(scale); + dagSvg.call(dagZoom.transform, dagZoomInitialTransform); + + const graphG = zoomG.append('g'); + + // Draw edges + const links = treeData.links(); + graphG.selectAll('path.dag-edge') + .data(links) + .enter() + .append('path') + .attr('class', 'dag-edge') + .attr('d', d => { + const midY = (d.source.y + d.target.y) / 2; + return `M${d.source.y},${d.source.x} C${midY},${d.source.x} ${midY},${d.target.x} ${d.target.y},${d.target.x}`; + }); + + // Draw nodes + const nodeGroups = graphG.selectAll('g.dag-node') + .data(treeData.descendants()) + .enter() + .append('g') + .attr('class', 'dag-node') + .attr('transform', d => `translate(${d.y}, ${d.x})`) + .style('cursor', 'pointer') + .on('click', (event, d) => { + // Call the Rust callback via window + if (window.dagNodeClickHandler) { + window.dagNodeClickHandler(d.data.id); + } + // Highlight selected node + graphG.selectAll('.dag-node-rect').classed('selected', false); + d3.select(event.currentTarget).select('.dag-node-rect').classed('selected', true); + }); + + // Node rectangles + nodeGroups.append('rect') + .attr('class', 'dag-node-rect') + .attr('x', -nodeSize[0] / 2) + .attr('y', -nodeSize[1] / 2) + .attr('width', nodeSize[0]) + .attr('height', nodeSize[1]) + .attr('rx', 6) + .attr('ry', 6) + .style('fill', d => kindColors[d.data.kind_class] || '#6b7280') + .style('stroke', d => d3.color(kindColors[d.data.kind_class] || '#6b7280').darker(0.5)) + .style('stroke-width', 2); + + // Node labels + nodeGroups.append('text') + .attr('class', 'dag-node-label') + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(d => { + const label = d.data.kind; + return label.length > 12 ? label.slice(0, 10) + '..' : label; + }); + + // Set up zoom controls + setupZoomControls(); +} + +function setupZoomControls() { + const zoomInBtn = document.getElementById('dag-zoom-in'); + const zoomOutBtn = document.getElementById('dag-zoom-out'); + const zoomResetBtn = document.getElementById('dag-zoom-reset'); + + if (zoomInBtn) { + zoomInBtn.onclick = () => dagSvg?.transition().call(dagZoom.scaleBy, 1.3); + } + if (zoomOutBtn) { + zoomOutBtn.onclick = () => dagSvg?.transition().call(dagZoom.scaleBy, 0.7); + } + if (zoomResetBtn) { + zoomResetBtn.onclick = () => dagSvg?.transition().call(dagZoom.transform, dagZoomInitialTransform); + } +} + +// Manual zoom function (exposed for external control) +window.manualDagZoom = function(mode) { + if (!dagSvg || !dagZoom) return; + + if (mode === 'zoom_in') { + dagSvg.transition().call(dagZoom.scaleBy, 1.3); + } else if (mode === 'zoom_out') { + dagSvg.transition().call(dagZoom.scaleBy, 0.7); + } else if (mode === 'zoom_reset') { + dagSvg.transition().call(dagZoom.transform, dagZoomInitialTransform); + } +}; + diff --git a/src/assets/style/components/analysis.scss b/src/assets/style/components/analysis.scss index 5ae188d..901dc4b 100644 --- a/src/assets/style/components/analysis.scss +++ b/src/assets/style/components/analysis.scss @@ -1,45 +1,126 @@ @use "../helpers"; -.analysis { - margin-top: 40px; - padding: 30px; - background-color: helpers.$background-light; - border-radius: 7.5px; - - .analysis-header { - .analysis-title { - margin: 6px 0 14px 0; - font-size: 28px; - font-style: normal; - font-weight: 700; - line-height: 24px; - } +// Analyze Modal Styles +.analyze-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.75); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; + backdrop-filter: blur(4px); +} - @media only screen and (max-width: 800px) { - flex-direction: column; +.analyze-modal { + background-color: helpers.$background-dark; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + + scrollbar-color: helpers.$background-light #424242; + scrollbar-width: thin; + + &.analyze-modal-large { + max-width: 1200px; + } + + .analyze-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background-color: helpers.$background-light; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: helpers.$text-white; } - .program-status{ - border: 1px dotted #7eff18; - color: #7eff18; - padding: 12px 20px; - font-size: 13px; - margin-bottom: 8px; - display: flex; - align-items: center; - - &.is_error{ - border: 1px dotted #ff0000; - color: #ff0000; + .analyze-modal-close { + background: none; + border: none; + color: helpers.$text-grey; + font-size: 18px; + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.2s ease; + + &:hover { + color: helpers.$text-white; + background-color: rgba(255, 255, 255, 0.1); } - - i { - font-size: 20px; - margin-right: 10px; + } + } + + .analyze-modal-content { + padding: 24px; + + // Override nested analysis styles when inside modal + .analysis { + margin-top: 0; + padding: 0; + background-color: transparent; + border-radius: 0; + + .analysis-header { + .analysis-title { + display: none; // Hide duplicate title + } } } } + // Empty state when no run result + .analyze-empty-state { + text-align: center; + padding: 40px 20px; + color: helpers.$text-grey; + + i { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; + } + + p { + font-size: 16px; + margin: 0; + } + } +} + +@media only screen and (max-width: 600px) { + .analyze-modal { + max-height: 80vh; + margin: 10px; + + .analyze-modal-header { + padding: 16px; + } + + .analyze-modal-content { + padding: 16px; + } + } +} + +.analysis { + // Match string-box alignment (margin-top: 10px, padding: 10px) + margin-top: 10px; + .analysis-body { display: flex; flex-wrap: wrap; @@ -54,81 +135,295 @@ flex: 0 0 50%; border: 1px solid rgba(255, 255, 255, 0.10); - padding: 20px; + padding: 10px 10px; background-color: helpers.$background-dark; .analysis-item-label { font-family: 't26-carbon', 'sans-serif'; color: helpers.$text-white; - font-size: 20px; + font-size: 14px; font-style: normal; - font-weight: 700; + font-weight: 600; line-height: normal; } .analysis-item-data { color: helpers.$text-grey; - font-size: 16px; + font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; } } } +} + +// Analyze Panel Styles +.analyze-panel { + padding: 0; +} + +// Analyze View (in ProgramWindow tab-content) +.analyze-view { + padding: 10px; + height: 100%; + overflow-y: auto; + + scrollbar-color: helpers.$background-light #424242; + scrollbar-width: thin; +} + +// DAG Section Styles +.dag-section { + margin-top: 20px; + + .dag-section-title { + font-family: 't26-carbon', 'sans-serif'; + font-size: 16px; + font-weight: 600; + color: helpers.$text-white; + margin: 0 0 12px 0; + } +} - .program-status-error-message{ - border: 1px dotted #ff0000; - color: #ff0000; - word-break: break-word; - margin-top: 10px; - padding: 10px; - overflow-x: scroll; +.dag-layout { + display: flex; + gap: 16px; + height: 450px; + + @media only screen and (max-width: 900px) { + flex-direction: column; + height: auto; + } +} + +.dag-canvas-container { + flex: 1; + position: relative; + min-width: 0; + + @media only screen and (max-width: 900px) { + height: 350px; + } +} + +.dag-canvas { + width: 100%; + height: 100%; + background-color: helpers.$background-dark; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + overflow: hidden; - scrollbar-color: helpers.$background-dark #b00000; - scrollbar-width: thin; + svg { + width: 100%; + height: 100%; } - .graph-error{ - border: 1px dotted #ff0000; - color: #ff0000; - padding: 10px; + .dag-edge { + fill: none; + stroke: #4090ff; + stroke-width: 2; + opacity: 0.6; } - .graph-toggle-holder{ + .dag-node-rect { + transition: all 0.15s ease; + + &:hover { + filter: brightness(1.2); + } + + &.selected { + stroke: #fff !important; + stroke-width: 3; + filter: brightness(1.3); + } + } + + .dag-node-label { + fill: #fff; + font-size: 11px; + font-weight: 500; + pointer-events: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); + } + + .dag-empty, .dag-error { display: flex; align-items: center; + justify-content: center; + height: 100%; + color: helpers.$text-grey; + font-size: 14px; + padding: 20px; + text-align: center; + } + + .dag-error { + color: #ef4444; + } +} + +.dag-controls { + position: absolute; + top: 12px; + right: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.dag-control-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: helpers.$background-light; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: helpers.$text-grey; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #FF9517; + color: #fff; + border-color: #FF9517; + } + + i { + font-size: 12px; + } +} + +// DAG Details Panel +.dag-details { + width: 320px; + background-color: helpers.$background-dark; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + overflow: hidden; + + @media only screen and (max-width: 900px) { width: 100%; - margin-left: 14px; + height: 200px; + } +} - #graph-toggle-icon{ - margin-left: 14px; - - rect{ - stroke: #b5bdc2; - transition: .3s; - } - circle{ - fill: #b5bdc2; - transition: .3s; - } +.dag-details-content { + padding: 16px; + height: 100%; + overflow-y: auto; + + scrollbar-color: helpers.$background-light #424242; + scrollbar-width: thin; +} - &:hover{ - rect{ - stroke: white; - } - circle{ - fill: white; - } - } +.dag-detail-item { + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } +} - &.toggle-on{ - rect{ - stroke: #9595FC; - } - circle{ - fill: #9595FC; - transform:translateX(22px); - } - } - } +.dag-detail-label { + display: block; + font-size: 11px; + font-weight: 600; + color: helpers.$text-grey; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.dag-detail-value { + display: block; + font-size: 14px; + color: helpers.$text-white; + word-break: break-all; + + &.dag-kind { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + + &[data-kind-class="jet"] { background: rgba(255, 149, 23, 0.2); color: #ff9517; } + &[data-kind-class="leaf"] { background: rgba(74, 222, 128, 0.2); color: #4ade80; } + &[data-kind-class="plumbing"] { background: rgba(107, 114, 128, 0.2); color: #9ca3af; } + &[data-kind-class="sum"] { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; } + &[data-kind-class="product"] { background: rgba(59, 130, 246, 0.2); color: #3b82f6; } + &[data-kind-class="assertion"] { background: rgba(239, 68, 68, 0.2); color: #ef4444; } + &[data-kind-class="advanced"] { background: rgba(245, 158, 11, 0.2); color: #f59e0b; } + } + + &.dag-type { + font-family: 'Roboto Mono', monospace; + font-size: 12px; + color: #8b5cf6; + } + + &.dag-cmr, &.dag-extra { + font-family: 'Roboto Mono', monospace; + font-size: 11px; + background: rgba(255, 255, 255, 0.05); + padding: 6px 8px; + border-radius: 4px; + color: helpers.$text-grey; + } +} + +.dag-detail-cmr { + .dag-detail-value { + display: block; + } +} + +.dag-details-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + text-align: center; + color: helpers.$text-grey; + + i { + font-size: 32px; + margin-bottom: 12px; + opacity: 0.4; + } + + p { + font-size: 14px; + margin: 0; + } +} + +.dag-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: helpers.$text-grey; + background-color: helpers.$background-dark; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + + i { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.4; + } + + p { + font-size: 16px; + margin: 0; } } diff --git a/src/assets/style/components/navbar.scss b/src/assets/style/components/navbar.scss index 1732063..1cc434d 100644 --- a/src/assets/style/components/navbar.scss +++ b/src/assets/style/components/navbar.scss @@ -59,6 +59,11 @@ &:active{ background: #2E3135; } + &.button-active { + color: #FF9517; + background: #2E3135; + border-color: #FF9517; + } } @keyframes run-button-success { diff --git a/src/components/analysis.rs b/src/components/analysis.rs index 6bace9b..049b229 100644 --- a/src/components/analysis.rs +++ b/src/components/analysis.rs @@ -1,4 +1,4 @@ -use leptos::{component, view, IntoView, ReadSignal, Signal, SignalGet}; +use leptos::{component, view, IntoView, Signal, SignalGet}; use std::str::FromStr; use std::sync::Arc; @@ -6,24 +6,17 @@ use crate::util; use crate::util::Expression; #[component] -pub fn Analysis( - program: Signal>>, - run_result: ReadSignal>>, -) -> impl IntoView { - let maybe_input = move || match (program.get(), run_result.get()) { - (Some(program), Some(run_result)) => Some((program, run_result)), - _ => None, - }; - +pub fn Analysis(program: Signal>>) -> impl IntoView { view! { { - move || maybe_input().map(|(program, run_result)| view! { -
- -
- }) + move || match program.get() { + Some(expr) => view! { +
+ +
+ }.into_view(), + None => view! {}.into_view(), + } } } } @@ -31,7 +24,7 @@ pub fn Analysis( const MILLISECONDS_PER_WU: f64 = 0.5 / 1000.0; #[component] -fn AnalysisInner(expression: Arc, run_result: Result) -> impl IntoView { +fn AnalysisInner(expression: Arc) -> impl IntoView { let bounds = expression.bounds(); // FIXME: Add conversion method to simplicity::Cost let milli_weight = u32::from_str(&bounds.cost.to_string()).unwrap(); @@ -44,74 +37,32 @@ fn AnalysisInner(expression: Arc, run_result: Result view! {
-
-

Program Analysis

- -
-
Size:
-
{size}B
+
"Size:"
+
{size}"B"
-
Virtual size:
-
{virtual_size}vB
+
"Virtual size:"
+
{virtual_size}"vB"
-
Maximum memory:
-
{max_bytes}B
+
"Maximum memory:"
+
{max_bytes}"B"
-
Weight:
-
{weight}WU
+
"Weight:"
+
{weight}"WU"
-
Maximum runtime:
-
{max_milliseconds}ms
+
"Maximum runtime:"
+
{max_milliseconds}"ms"
-
Program compression:
-
{compression}x
+
"Program compression:"
+
{compression}"x"
- -
} } - -#[component] -fn RunSuccess(run_success: bool) -> impl IntoView { - match run_success { - true => view! { -
- - Program success -
- }, - false => view! { -
- - Program failure -
- }, - } -} - -#[component] -fn RunResultMessage(run_result: Result) -> impl IntoView { - match run_result { - Ok(_) => view! { -
- }, - Err(error) => { - view! { -
-
-                        {error}
-                    
-
- } - } - } -} diff --git a/src/components/app.rs b/src/components/app.rs index f1a0165..abd04f7 100644 --- a/src/components/app.rs +++ b/src/components/app.rs @@ -12,6 +12,16 @@ use crate::util::{HashedData, SigningKeys}; #[derive(Copy, Clone, Debug, Default)] pub struct ActiveRunTab(pub RwSignal<&'static str>); +/// Controls which view is shown in `ProgramWindow`: "Run" (code editor) or "Analyze" (DAG view) +#[derive(Copy, Clone, Debug)] +pub struct ActiveProgramView(pub RwSignal<&'static str>); + +impl Default for ActiveProgramView { + fn default() -> Self { + Self(RwSignal::new("Run")) + } +} + #[component] pub fn App() -> impl IntoView { let program = Program::load_from_storage().unwrap_or_default(); @@ -26,6 +36,7 @@ pub fn App() -> impl IntoView { provide_context(HashCount::load_from_storage().unwrap_or_default()); provide_context(Runtime::new(program, tx_env.lazy_env)); provide_context(ActiveRunTab::default()); + provide_context(ActiveProgramView::default()); if program.is_empty() { select_example(examples::get("✍️️ P2PK").expect("P2PK example should exist")); diff --git a/src/components/mod.rs b/src/components/mod.rs index 6342b61..74f23ef 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,6 +1,6 @@ #![allow(clippy::needless_pass_by_value)] // leptos has broken lifetime parsing -mod analysis; +pub mod analysis; mod app; mod copy_to_clipboard; mod dropdown; diff --git a/src/components/program_window/analyze_button.rs b/src/components/program_window/analyze_button.rs new file mode 100644 index 0000000..815cb67 --- /dev/null +++ b/src/components/program_window/analyze_button.rs @@ -0,0 +1,29 @@ +use leptos::{component, use_context, view, IntoView, SignalGet, SignalSet}; + +use crate::components::app::ActiveProgramView; + +#[component] +pub fn AnalyzeButton() -> impl IntoView { + let active_view = use_context::().expect("ActiveProgramView should exist"); + + let toggle_analyze = move |_| { + let current = active_view.0.get(); + if current == "Analyze" { + active_view.0.set("Run"); + } else { + active_view.0.set("Analyze"); + } + }; + + let is_active = move || active_view.0.get() == "Analyze"; + + view! { + + } +} diff --git a/src/components/program_window/analyze_view.rs b/src/components/program_window/analyze_view.rs new file mode 100644 index 0000000..2484cb3 --- /dev/null +++ b/src/components/program_window/analyze_view.rs @@ -0,0 +1,169 @@ +use leptos::{ + component, create_effect, create_memo, create_rw_signal, use_context, view, wasm_bindgen, + IntoView, RwSignal, Signal, SignalGet, SignalSet, +}; +use std::sync::Arc; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast, JsValue}; + +use crate::components::analysis::Analysis; +use crate::components::program_window::Runtime; +use crate::util::dag::{build_dag_export, NodeMeta}; +use crate::util::Expression; + +#[component] +pub fn AnalyzeView() -> impl IntoView { + let runtime = use_context::().expect("Runtime should exist in context"); + + // Get program expression signal + let program_expr: Signal>> = + Signal::derive(move || runtime.program_expr().get()); + + // Selected node id for details panel + let selected: RwSignal> = create_rw_signal(None); + + // Build DAG export when program exists + let dag_export = create_memo(move |_| program_expr.get().map(|expr| build_dag_export(&expr))); + + // Build node lookup map + let node_map = create_memo(move |_| { + dag_export.get().map(|dag| { + dag.nodes + .iter() + .map(|n| (n.id.clone(), n.clone())) + .collect::>() + }) + }); + + // Get selected node details + let selected_node = move || { + let sel_id = selected.get()?; + let map = node_map.get()?; + map.get(&sel_id).cloned() + }; + + view! { +
+ // Existing Analysis metrics + + + // DAG Visualization section +
+

"Committed DAG"

+ + {move || match dag_export.get() { + Some(dag) => view! { +
+ + +
+ }.into_view(), + None => view! { +
+ +

"Run your program to visualize the DAG"

+
+ }.into_view(), + }} +
+
+ } +} + +#[component] +fn DagCanvas(dag_json: String, selected: RwSignal>) -> impl IntoView { + let container_id = "simplicity-dag-container"; + + // Set up click handler and render + create_effect(move |_| { + // Set up the click handler on window + let window = web_sys::window().expect("no window"); + + // Create click callback + let selected_clone = selected; + let cb = Closure::wrap(Box::new(move |node_id: String| { + selected_clone.set(Some(node_id)); + }) as Box); + + // Store callback on window for JS to call + let _ = js_sys::Reflect::set( + &window, + &JsValue::from_str("dagNodeClickHandler"), + cb.as_ref(), + ); + cb.forget(); + + // Call the global renderDag function + let render_fn = js_sys::Reflect::get(&window, &JsValue::from_str("renderDag")).ok(); + if let Some(func) = render_fn { + if let Ok(func) = func.dyn_into::() { + let _ = func.call2( + &JsValue::NULL, + &JsValue::from_str(container_id), + &JsValue::from_str(&dag_json), + ); + } + } + }); + + view! { +
+
+
+ + + +
+
+ } +} + +#[component] +fn DagDetails(node: Option) -> impl IntoView { + view! { +
+ {match node { + Some(n) => view! { +
+
+ "Kind:" + + {n.kind.clone()} + +
+
+ "Type:" + {n.type_arrow.clone()} +
+
+ "Description:" + {n.desc.clone()} +
+
+ "CMR:" + {n.cmr.clone()} +
+ {n.extra.clone().map(|extra| view! { +
+ "Extra:" + {extra} +
+ })} +
+ }.into_view(), + None => view! { +
+ +

"Click a node to see details"

+
+ }.into_view(), + }} +
+ } +} diff --git a/src/components/program_window/mod.rs b/src/components/program_window/mod.rs index c1e7061..edc42ba 100644 --- a/src/components/program_window/mod.rs +++ b/src/components/program_window/mod.rs @@ -1,4 +1,6 @@ mod address_button; +mod analyze_button; +mod analyze_view; mod examples_dropdown; mod help_button; mod program_tab; @@ -8,15 +10,18 @@ mod tools_dropdown; mod transaction_button; use leptos::create_signal; -use leptos::{component, view, IntoView, SignalGet, SignalSet}; +use leptos::{component, use_context, view, IntoView, SignalGet, SignalSet}; use self::address_button::AddressButton; +use self::analyze_button::AnalyzeButton; +use self::analyze_view::AnalyzeView; use self::examples_dropdown::ExamplesDropdown; use self::help_button::HelpButton; use self::program_tab::ProgramTab; use self::run_button::RunButton; use self::share_button::ShareButton; use self::transaction_button::TransactionButton; +use crate::components::app::ActiveProgramView; use crate::components::toolbar::Toolbar; pub use self::examples_dropdown::select_example; @@ -25,6 +30,7 @@ pub use self::program_tab::{Program, Runtime}; #[component] pub fn ProgramWindow() -> impl IntoView { let (mobile_open, set_mobile_open) = create_signal(false); + let active_view = use_context::().expect("ActiveProgramView should exist"); view! { @@ -34,6 +40,7 @@ pub fn ProgramWindow() -> impl IntoView {
+
@@ -50,6 +57,11 @@ pub fn ProgramWindow() -> impl IntoView { }}
- + + // Toggle between code editor and analyze view + {move || match active_view.0.get() { + "Analyze" => view! { }.into_view(), + _ => view! { }.into_view(), + }} } } diff --git a/src/components/program_window/program_tab.rs b/src/components/program_window/program_tab.rs index d73bdbe..dfb9983 100644 --- a/src/components/program_window/program_tab.rs +++ b/src/components/program_window/program_tab.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use itertools::Itertools; use leptos::{ component, create_node_ref, create_rw_signal, ev, event_target_value, html, spawn_local, - use_context, view, IntoView, RwSignal, Signal, SignalGetUntracked, SignalSet, SignalUpdate, - SignalWith, SignalWithUntracked, + use_context, view, IntoView, ReadSignal, RwSignal, Signal, SignalGetUntracked, SignalSet, + SignalUpdate, SignalWith, SignalWithUntracked, }; use simplicityhl::parse::ParseFromStr; use simplicityhl::simplicity::jet::elements::ElementsEnv; @@ -13,6 +13,7 @@ use simplicityhl::{CompiledProgram, SatisfiedProgram, WitnessValues}; use crate::components::copy_to_clipboard::CopyToClipboard; use crate::function::Runner; +use crate::util::Expression; #[derive(Copy, Clone, Debug)] pub struct Program { @@ -108,6 +109,7 @@ pub struct Runtime { pub run_succeeded: RwSignal>, pub debug_output: RwSignal, pub error_output: RwSignal, + program_expr: RwSignal>>, } impl Runtime { @@ -118,9 +120,14 @@ impl Runtime { run_succeeded: RwSignal::default(), debug_output: RwSignal::default(), error_output: RwSignal::default(), + program_expr: RwSignal::default(), } } + pub fn program_expr(&self) -> ReadSignal>> { + self.program_expr.read_only() + } + fn set_success(self, success: bool) { spawn_local(async move { self.run_succeeded.set(Some(success)); @@ -142,10 +149,15 @@ impl Runtime { Ok(x) => x, Err(error) => { self.error_output.set(error); + self.program_expr.set(None); self.set_success(false); return; } }; + // Store the program expression for analysis + self.program_expr + .set(Some(satisfied_program.redeem().clone())); + let mut runner = Runner::for_program(&satisfied_program); let success = self.env.with(|env| match runner.run(env) { Ok(..) => { diff --git a/src/components/program_window/run_button.rs b/src/components/program_window/run_button.rs index 3fd2d47..9d7ae49 100644 --- a/src/components/program_window/run_button.rs +++ b/src/components/program_window/run_button.rs @@ -1,5 +1,6 @@ -use leptos::{component, ev, use_context, view, IntoView, SignalGet}; +use leptos::{component, ev, use_context, view, IntoView, SignalGet, SignalSet}; +use crate::components::app::ActiveProgramView; use crate::components::program_window::{Program, Runtime}; use crate::components::state::update_local_storage; @@ -7,8 +8,12 @@ use crate::components::state::update_local_storage; pub fn RunButton() -> impl IntoView { let program = use_context::().expect("program should exist in context"); let runtime = use_context::().expect("runtime should exist in context"); + let active_view = use_context::().expect("ActiveProgramView should exist"); let run_program = move |_event: ev::MouseEvent| { + // Switch back to code editor view + active_view.0.set("Run"); + program.add_default_modules(); update_local_storage(); runtime.run(); diff --git a/src/components/program_window/share_button.rs b/src/components/program_window/share_button.rs index 3b6da5e..59c8263 100644 --- a/src/components/program_window/share_button.rs +++ b/src/components/program_window/share_button.rs @@ -7,7 +7,7 @@ pub fn ShareButton() -> impl IntoView { let url = move || "Sharing is temporarily disabled".to_string(); view! { - " Share" + "Share" } } diff --git a/src/util/dag.rs b/src/util/dag.rs new file mode 100644 index 0000000..6dbb6c2 --- /dev/null +++ b/src/util/dag.rs @@ -0,0 +1,285 @@ +//! DAG export utilities for visualizing Simplicity program structure. +//! +//! Matches the logic from hal-simplicity CLI's dag.rs for consistency. + +use hex_conservative::DisplayHex; +use serde::{Deserialize, Serialize}; +use simplicity::node::Inner; +use simplicityhl::simplicity; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::util::Expression; + +/// Metadata for a single node in the DAG. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct NodeMeta { + pub id: String, + pub kind: String, + pub kind_class: String, + pub type_arrow: String, + pub desc: String, + pub cmr: String, + pub extra: Option, + pub children: Vec, +} + +/// An edge in the DAG (from parent to child for D3 tree layout). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Edge { + pub from: String, + pub to: String, +} + +/// Complete DAG export for visualization. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DagExport { + pub root_id: String, + pub nodes: Vec, + pub edges: Vec, +} + +/// Build a DAG export from an `Expression` (`RedeemNode`). +pub fn build_dag_export(expr: &Arc) -> DagExport { + let mut builder = DagBuilder::new(); + let root_id = builder.visit(expr); + + DagExport { + root_id, + nodes: builder.nodes, + edges: builder.edges, + } +} + +/// Builder that traverses the DAG recursively, matching CLI logic. +struct DagBuilder { + /// Map from node pointer to assigned ID (for DAG sharing detection) + node_ids: HashMap, + /// List of nodes in order of their IDs + nodes: Vec, + /// List of edges (parent → child for D3) + edges: Vec, + /// Next available node ID + next_id: usize, +} + +impl DagBuilder { + fn new() -> Self { + Self { + node_ids: HashMap::new(), + nodes: Vec::new(), + edges: Vec::new(), + next_id: 0, + } + } + + /// Visit a node and return its ID string. + /// This matches the CLI's recursive approach: assign ID first, then process children. + fn visit(&mut self, node: &Arc) -> String { + let ptr = Arc::as_ptr(node) as usize; + + // Check if we've already visited this node (DAG sharing) + if let Some(&id) = self.node_ids.get(&ptr) { + return format!("n{}", id); + } + + // Assign ID in pre-order (before visiting children) + let id = self.next_id; + self.next_id += 1; + self.node_ids.insert(ptr, id); + let id_str = format!("n{}", id); + + // Get children and classify node + let (kind, kind_class, desc, extra, children) = Self::classify_node(node); + + // Process children and collect their IDs + let child_ids: Vec = children.iter().map(|c| self.visit(c)).collect(); + + // Add edges from this node (parent) to its children + // D3 tree layout expects parent → child edges + for child_id in &child_ids { + self.edges.push(Edge { + from: id_str.clone(), + to: child_id.clone(), + }); + } + + // Get type arrow + let arrow = node.arrow(); + let type_arrow = format!("{} → {}", arrow.source, arrow.target); + + // Get CMR as hex + let cmr = node.cmr().as_ref().as_hex().to_string(); + + self.nodes.push(NodeMeta { + id: id_str.clone(), + kind, + kind_class, + type_arrow, + desc, + cmr, + extra, + children: child_ids, + }); + + id_str + } + + /// Classify a node and return (kind, `kind_class`, desc, extra, children). + fn classify_node( + node: &Arc, + ) -> (String, String, String, Option, Vec>) { + match node.inner() { + Inner::Iden => ( + "iden".into(), + "combinator".into(), + "Identity: passes input through unchanged.".into(), + None, + vec![], + ), + Inner::Unit => ( + "unit".into(), + "combinator".into(), + "Produces unit value ().".into(), + None, + vec![], + ), + Inner::InjL(child) => ( + "injl".into(), + "combinator".into(), + "Inject value into left side of a sum type.".into(), + None, + vec![Arc::clone(child)], + ), + Inner::InjR(child) => ( + "injr".into(), + "combinator".into(), + "Inject value into right side of a sum type.".into(), + None, + vec![Arc::clone(child)], + ), + Inner::Take(child) => ( + "take".into(), + "combinator".into(), + "Project left element of a pair.".into(), + None, + vec![Arc::clone(child)], + ), + Inner::Drop(child) => ( + "drop".into(), + "combinator".into(), + "Project right element of a pair.".into(), + None, + vec![Arc::clone(child)], + ), + Inner::Comp(left, right) => ( + "comp".into(), + "combinator".into(), + "Composition: run left child then right child.".into(), + None, + vec![Arc::clone(left), Arc::clone(right)], + ), + Inner::Pair(left, right) => ( + "pair".into(), + "combinator".into(), + "Pairing: compute two functions on same input.".into(), + None, + vec![Arc::clone(left), Arc::clone(right)], + ), + Inner::Case(left, right) => ( + "case".into(), + "combinator".into(), + "Case split on Either type.".into(), + None, + vec![Arc::clone(left), Arc::clone(right)], + ), + Inner::AssertL(child, hidden_cmr) => { + let cmr_hex = hidden_cmr.as_ref().as_hex().to_string(); + let short_cmr = if cmr_hex.len() > 8 { + &cmr_hex[..8] + } else { + &cmr_hex + }; + ( + format!("assertl:{}", short_cmr), + "combinator".into(), + "Assert left branch.".into(), + Some(cmr_hex), + vec![Arc::clone(child)], + ) + } + Inner::AssertR(hidden_cmr, child) => { + let cmr_hex = hidden_cmr.as_ref().as_hex().to_string(); + let short_cmr = if cmr_hex.len() > 8 { + &cmr_hex[..8] + } else { + &cmr_hex + }; + ( + format!("assertr:{}", short_cmr), + "combinator".into(), + "Assert right branch.".into(), + Some(cmr_hex), + vec![Arc::clone(child)], + ) + } + Inner::Disconnect(left, _right) => ( + "disconnect".into(), + "combinator".into(), + "Disconnect combinator for delegation.".into(), + None, + vec![Arc::clone(left)], + ), + Inner::Witness(val) => { + let val_str = format!("{:?}", val); + ( + "witness".into(), + "leaf".into(), + "Witness data placeholder.".into(), + Some(val_str), + vec![], + ) + } + Inner::Fail(entropy) => { + let entropy_hex = entropy.as_ref().as_hex().to_string(); + let short = if entropy_hex.len() > 8 { + &entropy_hex[..8] + } else { + &entropy_hex + }; + ( + format!("fail:{}", short), + "leaf".into(), + "Universal fail: always fails.".into(), + Some(entropy_hex), + vec![], + ) + } + Inner::Jet(jet) => { + let jet_name = format!("{:?}", jet).to_lowercase(); + ( + format!("jet_{}", jet_name), + "jet".into(), + format!("Jet primitive: {}", jet_name), + None, + vec![], + ) + } + Inner::Word(word) => { + let word_str = word.as_value().to_string(); + let label = if word_str.len() > 16 { + format!("const:{}…", &word_str[..16]) + } else { + format!("const:{}", word_str) + }; + ( + label, + "leaf".into(), + format!("Constant word value: {}", word_str), + Some(word_str), + vec![], + ) + } + } + } +} diff --git a/src/util.rs b/src/util/mod.rs similarity index 99% rename from src/util.rs rename to src/util/mod.rs index fb76409..9236d96 100644 --- a/src/util.rs +++ b/src/util/mod.rs @@ -1,3 +1,5 @@ +pub mod dag; + use elements::hashes::{sha256, Hash}; use elements::secp256k1_zkp as secp256k1; use secp256k1::rand::{self, Rng, SeedableRng};