diff --git a/CLAUDE.md b/CLAUDE.md index 17e908a..cd523d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,10 +246,13 @@ the wheel and publishes it to PyPI on a `v` tag (version in also reads it into `NAJA_SCHEMATIC_VERSION_STRING` for the C++ app's About dialog, so a release is one edit plus a tag). For local work set `NAJA_SCHEMATIC_BUNDLE` to a locally built bundle (see `_bundle.py`). Tests: -`pytest python/tests`. `python/notebooks/colab_test.ipynb` is the +`pytest python/tests`. `python/notebooks/getting_started.ipynb` is the Open-in-Colab notebook (badge in the top `README.md`): a walkthrough of the -widget against the released PyPI package. `tests/test_notebook.py` runs it -in a real kernel (nbclient) and splices in a cell that plays the viewer +widget against the released PyPI package. `python/notebooks/tinyrocket.ipynb` (a +gate-level RISC-V core with Liberty) is its sequel, kept apart because +najaeda holds one design at a time: in one notebook, *Run all* would leave +every view showing the last design. `tests/test_notebook.py` runs both +in a real kernel (nbclient) and, in the walkthrough, splices in a cell that plays the viewer against its `view`, so a `Schematic`/protocol change that breaks it fails CI; its install cell skips `pip install` when `naja_schematic` is already importable, so CI tests the fresh wheel, not PyPI's. The workflow's @@ -551,7 +554,18 @@ the same `id_path`/`path` pair as diagnosis items: observes the ids, so moving between anonymous siblings fires it). The other hosts only log it (`protocol.py`) or ignore it (`LocalSNLProvider`). -`design_changed` is a third server push, `{"response":"design_changed", +`export_svg` is a server push too, `{"response":"export_svg"}`, from +`Schematic.export_svg()` (`ViewerSession.export_svg()`): the viewer sets +`AppState::svgExportRequested` and, at the first frame its schematic shows +something, answers with a notification, +`{"request":"svg_exported","svg":""}` -- what File > Export +Schematic as SVG... saves. The session intercepts it (like +`instance_selected`), keeps it as `svg` and calls the request's one-shot +callbacks; the widget also writes it to the `path` given. A notebook reads +it in a later cell or through the callback (e.g. updating a +`display_id` output), since comm messages reach the kernel between cells. + +`design_changed` is another server push, `{"response":"design_changed", "generation":N}`: the host replaced or edited the design, so every tree node, instance id and schematic box the viewer holds may be stale -- or, worse, name something else, since a new design can reuse ids. The viewer diff --git a/README.md b/README.md index e4415a8..ba5fe72 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Native macOS Build](https://github.com/keplertech/naja-schematic/actions/workflows/native-macos.yml/badge.svg?branch=main)](https://github.com/keplertech/naja-schematic/actions/workflows/native-macos.yml) [![Native Linux Build](https://github.com/keplertech/naja-schematic/actions/workflows/native-linux.yml/badge.svg?branch=main)](https://github.com/keplertech/naja-schematic/actions/workflows/native-linux.yml) [![Emscripten Build](https://github.com/keplertech/naja-schematic/actions/workflows/emscripten.yml/badge.svg?branch=main)](https://github.com/keplertech/naja-schematic/actions/workflows/emscripten.yml) -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/keplertech/naja-schematic/blob/main/python/notebooks/colab_test.ipynb) +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/keplertech/naja-schematic/blob/main/python/notebooks/getting_started.ipynb) > **Note:** This is a proof of concept in beta. Expect rough edges, breaking diff --git a/REUSE.toml b/REUSE.toml index 982d676..29208f6 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -31,7 +31,8 @@ path = [ "python/naja_schematic/static/index.html", "python/naja_schematic/static/widget.js", "python/naja_schematic/widget.py", - "python/notebooks/colab_test.ipynb", + "python/notebooks/getting_started.ipynb", + "python/notebooks/tinyrocket.ipynb", "python/pyproject.toml", "python/tests/conftest.py", "python/tests/test_anonymous_instances.py", diff --git a/python/README.md b/python/README.md index 3e84ae6..01c0cb3 100644 --- a/python/README.md +++ b/python/README.md @@ -13,8 +13,8 @@ pip install naja-schematic ## In a notebook (Jupyter, Google Colab, VSCode) -[Try it in Colab](https://colab.research.google.com/github/keplertech/naja-schematic/blob/main/python/notebooks/colab_test.ipynb): -a walkthrough of every widget feature below, runnable as is. +[Try it in Colab](https://colab.research.google.com/github/keplertech/naja-schematic/blob/main/python/notebooks/getting_started.ipynb): +a walkthrough of every widget feature below, runnable as is. Then a real design: [a RISC-V core](https://colab.research.google.com/github/keplertech/naja-schematic/blob/main/python/notebooks/tinyrocket.ipynb). ```python from najaeda import netlist diff --git a/python/naja_schematic/__init__.py b/python/naja_schematic/__init__.py index 9563480..a9b4699 100644 --- a/python/naja_schematic/__init__.py +++ b/python/naja_schematic/__init__.py @@ -9,7 +9,7 @@ """ # Single version for the whole project: CMakeLists.txt reads this line into the # C++ app too (NAJA_SCHEMATIC_VERSION_STRING), so keep it a plain literal. -__version__ = "0.1.7" +__version__ = "0.1.8" from .protocol import diagnosis_response, handle_request from .server import ViewerServer diff --git a/python/naja_schematic/protocol.py b/python/naja_schematic/protocol.py index 7fd3a9a..d35a78f 100644 --- a/python/naja_schematic/protocol.py +++ b/python/naja_schematic/protocol.py @@ -904,3 +904,11 @@ def design_changed(): ignores messages for any other generation, and stamps its own requests with it.""" return {"response": "design_changed"} + + +def export_svg(): + """Build an export_svg push message: the viewer answers, once its + schematic shows something, with an svg_exported notification carrying + the schematic as SVG (the whole sheet, as File > Export Schematic as + SVG... saves it). session.ViewerSession intercepts that answer.""" + return {"response": "export_svg"} diff --git a/python/naja_schematic/session.py b/python/naja_schematic/session.py index f043c68..e2eea4b 100644 --- a/python/naja_schematic/session.py +++ b/python/naja_schematic/session.py @@ -168,6 +168,8 @@ def __init__(self, push=None, run=None, diagnosis=None, dispatch=None): if diagnosis is not None else None) self._focus_push = None self._select_callbacks = [] + self._svg_callbacks = [] # one-shot, for the next svg_exported + self.svg = None self.selected_id_path = None self.selected_path = None @@ -207,10 +209,17 @@ def handle(): with self._state_lock: generation = self._generation pushes = [p for p in (self._diagnosis_push, self._focus_push) if p] + if self._svg_callbacks: + # An export asked before the view was up got lost with + # it: ask again once the view is (re)loaded. + pushes.append(protocol.export_svg()) if stamp is not None and stamp != generation: return generation, None, pushes if kind == "instance_selected": return generation, self._resolve_selection(request), pushes + if kind == "svg_exported": + svg = request.get("svg") + return generation, svg if isinstance(svg, str) else "", pushes return generation, protocol.handle_request(request), pushes generation, result, pushes = self._run(handle) @@ -220,6 +229,9 @@ def handle(): if kind == "instance_selected": self._commit_selection(generation, *result) return [] # a notification: no reply + if kind == "svg_exported": + self._commit_svg(generation, result) + return [] replies = [] for reply in result: replies.append(self._stamp(reply, generation)) @@ -259,6 +271,22 @@ def _notify(self, generation, ids, path): except Exception: log.exception("Error in a selection callback") + def _commit_svg(self, generation, svg): + with self._state_lock: + if generation != self._generation: + return + self.svg = svg + callbacks, self._svg_callbacks = self._svg_callbacks, [] + for callback in callbacks: + self._dispatch(lambda cb=callback: self._deliver_svg(cb, svg)) + + @staticmethod + def _deliver_svg(callback, svg): + try: + callback(svg) + except Exception: + log.exception("Error in an SVG export callback") + def on_select(self, callback): """Call `callback(id_path, path)` on each selection the viewer reports for the current design, and with (None, None) when @@ -298,6 +326,18 @@ def show_instance(self, target): generation = self._generation self._send(self._stamp(message, generation)) + def export_svg(self, callback=None): + """Ask the viewer(s) for the schematic as SVG (the whole sheet, as + File > Export Schematic as SVG... saves it). The answer comes + asynchronously, once a viewer's schematic shows something: it is + kept in `svg`, and `callback(svg)`, if given, is called once, + through `dispatch`.""" + with self._state_lock: + if callback is not None: + self._svg_callbacks.append(callback) + generation = self._generation + self._send(self._stamp(protocol.export_svg(), generation)) + def design_changed(self, diagnosis=None, instance=None): """Start the next design generation: the viewer(s) reload the design from the root, and whatever they asked about or selected in the diff --git a/python/naja_schematic/widget.py b/python/naja_schematic/widget.py index c41732c..0aebdb1 100644 --- a/python/naja_schematic/widget.py +++ b/python/naja_schematic/widget.py @@ -42,6 +42,9 @@ class Schematic(anywidget.AnyWidget): # a name path, so moving between them wouldn't change it. selected_id_path = traitlets.List(default_value=None, allow_none=True) selected_path = traitlets.List(default_value=None, allow_none=True) + # The schematic as SVG, from the last export_svg(); None before. + # Kernel-side only, like the selection. + svg = traitlets.Unicode(default_value=None, allow_none=True) def __init__(self, **kwargs): # Read before AnyWidget.__init__, which turns it into a synced trait. @@ -97,6 +100,26 @@ def show_instance(self, target): """ self._session.show_instance(target) + def export_svg(self, path=None, callback=None): + """Ask the view for its schematic as SVG: the whole sheet, as File > + Export Schematic as SVG... saves it. The view answers once it is + displayed and its schematic shows something -- after this cell, since + the kernel reads the view's messages between cells. The SVG then + lands in `svg`, is written to `path` if given, and is passed to + `callback(svg)`, e.g. to update a display: + + out = display(Markdown("Waiting for the view..."), display_id=True) + view.export_svg("view.svg", lambda svg: out.update(SVG(svg))) + """ + def done(svg): + if path is not None: + with open(path, "w", encoding="utf-8") as f: + f.write(svg) + self.svg = svg + if callback is not None: + callback(svg) + self._session.export_svg(done) + def design_changed(self, diagnosis=None, instance=None): """Refresh this view after the design was replaced or edited in later cells, instead of calling show() again. Kept diagnoses and diff --git a/python/notebooks/colab_test.ipynb b/python/notebooks/colab_test.ipynb deleted file mode 100644 index 07b4d4d..0000000 --- a/python/notebooks/colab_test.ipynb +++ /dev/null @@ -1,244 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "9a83c378", - "metadata": {}, - "source": [ - "# naja-schematic in Google Colab — test notebook\n", - "\n", - "Checks the `naja_schematic` widget end to end in Colab. Run cells top to bottom; each **Check:** line says what to look for." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d2afb533", - "metadata": {}, - "outputs": [], - "source": [ - "# Installs (or upgrades to) the released package. The notebook test sets\n", - "# NAJA_SCHEMATIC_NOTEBOOK_TEST to keep the freshly built wheel it runs against.\n", - "import os\n", - "if \"NAJA_SCHEMATIC_NOTEBOOK_TEST\" not in os.environ:\n", - " %pip install -q -U naja-schematic\n", - "\n", - "import najaeda, naja_schematic, anywidget\n", - "print(\"naja-schematic\", naja_schematic.__version__, \"| anywidget\", anywidget.__version__)" - ] - }, - { - "cell_type": "markdown", - "id": "3fd7c1a6", - "metadata": {}, - "source": [ - "## 1. Small hierarchical design" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "36ae7db0", - "metadata": {}, - "outputs": [], - "source": [ - "%%writefile fulladder.v\n", - "module halfadder(input a, input b, output sum, output carry);\n", - " and carry_and(carry, a, b);\n", - " xor sum_xor(sum, a, b);\n", - "endmodule\n", - "\n", - "module fulladder(input a, input b, input cin, output sum, output cout);\n", - " wire sum1, carry1, carry2;\n", - " halfadder ha1(.a(a), .b(b), .sum(sum1), .carry(carry1));\n", - " halfadder ha2(.a(sum1), .b(cin), .sum(sum), .carry(carry2));\n", - " or cout_or(cout, carry1, carry2);\n", - "endmodule" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9bfd1fb5", - "metadata": {}, - "outputs": [], - "source": [ - "from najaeda import netlist\n", - "netlist.reset()\n", - "top = netlist.load_verilog(\"fulladder.v\")\n", - "top.get_name()" - ] - }, - { - "cell_type": "markdown", - "id": "45082e18", - "metadata": {}, - "source": [ - "## 2. Basic view\n", - "**Check:** viewer renders; tree shows `fulladder`; expanding `ha1`/`ha2` lazily loads instances, terms and nets; right-click a term → *Show Equipotential* / *Trace to Driver* draws a schematic; clicking an open pin grows it; *Show Properties* fills the Properties tab." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9cac3fc0", - "metadata": {}, - "outputs": [], - "source": [ - "view = naja_schematic.show(height=600)\n", - "view" - ] - }, - { - "cell_type": "markdown", - "id": "fa05dd8c", - "metadata": {}, - "source": [ - "## 3. Host → viewer: focus an instance\n", - "**Check:** tree opens down to `ha2/sum_xor`, it's selected, Properties shows it, schematic draws it alone with open pins." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e2f5219d", - "metadata": {}, - "outputs": [], - "source": [ - "view.show_instance([\"ha2\", \"sum_xor\"])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b40b4288", - "metadata": {}, - "outputs": [], - "source": [ - "# Same thing with a najaeda Instance\n", - "view.show_instance(top.get_child_instance(\"ha1\").get_child_instance(\"carry_and\"))" - ] - }, - { - "cell_type": "markdown", - "id": "2966c079", - "metadata": {}, - "source": [ - "## 4. Viewer → host: selection\n", - "Click an instance in the tree or a box in the schematic, then run the next cell. The callback prints on each click (output appears under this cell)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e2125d57", - "metadata": {}, - "outputs": [], - "source": [ - "handler = view.on_select(lambda inst: print(\"selected:\", inst))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4ab2dcc9", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"selected_path:\", view.selected_path)\n", - "inst = view.selected\n", - "print(inst, \"->\", inst.get_model_name() if inst else None)" - ] - }, - { - "cell_type": "markdown", - "id": "854f5c0f", - "metadata": {}, - "source": [ - "## 5. One-shot view on an instance\n", - "**Check:** a new view opens on `ha1`, selected, drawn alone with open pins." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7b22e0f3", - "metadata": {}, - "outputs": [], - "source": [ - "naja_schematic.show([\"ha1\"], height=500)" - ] - }, - { - "cell_type": "markdown", - "id": "5d91666e", - "metadata": {}, - "source": [ - "## 6. Edit, then a fresh view\n", - "**Check:** the new view shows `halfadder1` instead of `ha1` (the older view above does not refresh — known gap)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b86790fd", - "metadata": {}, - "outputs": [], - "source": [ - "top.get_child_instance(\"ha1\").set_name(\"halfadder1\")\n", - "naja_schematic.show(height=500)" - ] - }, - { - "cell_type": "markdown", - "id": "8a71293c", - "metadata": {}, - "source": [ - "## 7. Larger gate-level design with Liberty (tinyrocket)\n", - "**Check:** loads in reasonable time; tree scrolls; tracing a flop input to driver works and stays responsive." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ac9e690a", - "metadata": {}, - "outputs": [], - "source": [ - "!curl -sSfL https://github.com/najaeda/naja/archive/refs/heads/main.zip -o naja-main.zip && unzip -q -o naja-main.zip \"naja-main/tutorials/benchmarks/*\"\n", - "bench = \"naja-main/tutorials/benchmarks\"\n", - "netlist.reset()\n", - "netlist.load_liberty([f\"{bench}/liberty/tutorial_cells.lib\", f\"{bench}/liberty/fakeram45_64x32.lib\"])\n", - "top = netlist.load_verilog(f\"{bench}/verilog/tinyrocket/tinyrocket.v\")\n", - "naja_schematic.show(height=700)" - ] - }, - { - "cell_type": "markdown", - "id": "cbbaad03", - "metadata": {}, - "source": [ - "## Things to note while testing\n", - "- Mouse wheel over the viewer doesn't scroll the page (known).\n", - "- Two/three views on the page at once: do they all stay interactive?\n", - "- Keyboard input only goes to the focused viewer?\n", - "- Any errors in the browser console (F12) or the Colab cell output?" - ] - } - ], - "metadata": { - "colab": { - "provenance": [] - }, - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/python/notebooks/getting_started.ipynb b/python/notebooks/getting_started.ipynb new file mode 100644 index 0000000..8f18fbf --- /dev/null +++ b/python/notebooks/getting_started.ipynb @@ -0,0 +1,275 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b579203e", + "metadata": {}, + "source": [ + "# naja-schematic: interactive netlist schematics in a notebook\n", + "\n", + "[naja-schematic](https://github.com/keplertech/naja-schematic) is an open-source schematic viewer for gate-level and RTL netlists. The viewer is C++/[Dear ImGui](https://github.com/ocornut/imgui) compiled to WebAssembly. The netlist stays in this Python kernel, loaded and edited with [najaeda](https://github.com/najaeda/naja), and the viewer only asks for the part you're looking at.\n", + "\n", + "Run the cells one at a time, top to bottom (Shift+Enter). The views read the live netlist, so after *Run all* every view shows it as the last cell left it (with `u_red` renamed). It's a beta: expect rough edges, and [report them](https://github.com/keplertech/naja-schematic/issues)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d33f155f", + "metadata": {}, + "outputs": [], + "source": [ + "# Installs (or upgrades to) the released package. The notebook test sets\n", + "# NAJA_SCHEMATIC_NOTEBOOK_TEST to keep the freshly built wheel it runs against.\n", + "import os\n", + "if \"NAJA_SCHEMATIC_NOTEBOOK_TEST\" not in os.environ:\n", + " %pip install -q -U naja-schematic\n", + "\n", + "import najaeda, naja_schematic, anywidget\n", + "print(\"naja-schematic\", naja_schematic.__version__, \"| anywidget\", anywidget.__version__)" + ] + }, + { + "cell_type": "markdown", + "id": "ffcdb246", + "metadata": {}, + "source": [ + "## 1. Load a design\n", + "An RGB LED dimmer in SystemVerilog: one 8-bit counter shared by three PWM channels, each lighting its LED while the counter is below its duty cycle. najaeda elaborates it to gates and flops with [slang](https://github.com/MikePopoloski/slang), keeping the module hierarchy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffbf8ac4", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile rgb_pwm.sv\n", + "// RGB LED dimmer: one free-running counter, three PWM channels.\n", + "module counter #(parameter W = 8) (\n", + " input logic clk, rst,\n", + " output logic [W-1:0] q);\n", + " always_ff @(posedge clk)\n", + " if (rst) q <= '0;\n", + " else q <= q + 1'b1;\n", + "endmodule\n", + "\n", + "// The output is high while the counter is below the duty cycle.\n", + "module pwm_channel #(parameter W = 8) (\n", + " input logic clk,\n", + " input logic [W-1:0] cnt, duty,\n", + " output logic out);\n", + " always_ff @(posedge clk) out <= cnt < duty;\n", + "endmodule\n", + "\n", + "module rgb_pwm (\n", + " input logic clk, rst,\n", + " input logic [7:0] duty_r, duty_g, duty_b,\n", + " output logic led_r, led_g, led_b);\n", + " logic [7:0] cnt;\n", + " counter u_counter(.clk, .rst, .q(cnt));\n", + " pwm_channel u_red (.clk, .cnt, .duty(duty_r), .out(led_r));\n", + " pwm_channel u_green (.clk, .cnt, .duty(duty_g), .out(led_g));\n", + " pwm_channel u_blue (.clk, .cnt, .duty(duty_b), .out(led_b));\n", + "endmodule" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ee8cbbb", + "metadata": {}, + "outputs": [], + "source": [ + "from najaeda import netlist\n", + "netlist.reset()\n", + "top = netlist.load_system_verilog(\"rgb_pwm.sv\", netlist.SystemVerilogConfig(top=\"rgb_pwm\"))\n", + "[(inst.get_name(), inst.get_model_name()) for inst in top.get_child_instances()]" + ] + }, + { + "cell_type": "markdown", + "id": "48887041", + "metadata": {}, + "source": [ + "## 2. Explore it\n", + "Things to try in the viewer:\n", + "- **Tree (left):** expand `u_red`. Instances, terms and nets load on demand.\n", + "- **Trace a cone:** right-click the `led_r` term → *Trace to Driver*. The trace stops at the channel's output flop. Then right-click that flop's `D` pin in the schematic → *Trace to Driver*: the whole 8-bit comparator appears inside its `u_red` frame, reaching back to the counter and the `duty_r` inputs.\n", + "- **Click a hollow pin** in the schematic to pull in the net behind it. **Double-click a dashed box** to show all its pins.\n", + "- **Right-click** an instance or pin → *Show Properties*. Module frames come from *View > Show Hierarchy*.\n", + "- **Scroll** to zoom and **drag** to pan." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2045acdd", + "metadata": {}, + "outputs": [], + "source": [ + "view = naja_schematic.show(height=600)\n", + "view" + ] + }, + { + "cell_type": "markdown", + "id": "c0ec9a0f", + "metadata": {}, + "source": [ + "## 3. Drive the view from Python\n", + "Point the view at an instance, by name path or with a najaeda `Instance`. It opens the tree down to the instance, selects it and draws it with all its pins open, ready to extend." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e669b913", + "metadata": {}, + "outputs": [], + "source": [ + "view.show_instance([\"u_red\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2cdecdf8", + "metadata": {}, + "outputs": [], + "source": [ + "# The same with a najaeda Instance\n", + "view.show_instance(top.get_child_instance(\"u_counter\"))" + ] + }, + { + "cell_type": "markdown", + "id": "c1a67ede", + "metadata": {}, + "source": [ + "## 4. Read the selection back\n", + "Click an instance in the tree or a box in the schematic. The callback prints on each click, and the next cell gives the selection as a najaeda `Instance`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6816f4a3", + "metadata": {}, + "outputs": [], + "source": [ + "handler = view.on_select(lambda inst: print(\"selected:\", inst))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17acaff6", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"selected_path:\", view.selected_path)\n", + "inst = view.selected\n", + "print(inst, \"->\", inst.get_model_name() if inst else None)" + ] + }, + { + "cell_type": "markdown", + "id": "f3c8f456", + "metadata": {}, + "source": [ + "## 5. Export the schematic as SVG\n", + "`export_svg()` asks the view for what its schematic shows, as a standalone SVG: the whole sheet, with text kept as text. It's ready to drop into a doc or a PR. The view answers after this cell has run, so the SVG appears below and in `rgb_pwm.svg` once the view has drawn something. Trace the comparator cone above (section 2), then run the cell again to export it.\n", + "\n", + "The viewer's own *File > Export Schematic as SVG...* menu downloads the same file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0065c72", + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import SVG, Markdown, display\n", + "out = display(Markdown(\"*Waiting for the view to draw...*\"), display_id=True)\n", + "view.export_svg(\"rgb_pwm.svg\", lambda svg: out.update(SVG(svg)))" + ] + }, + { + "cell_type": "markdown", + "id": "6bc2862a", + "metadata": {}, + "source": [ + "## 6. Open a view on one instance\n", + "A new view, started on the blue channel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e16c49d6", + "metadata": {}, + "outputs": [], + "source": [ + "naja_schematic.show([\"u_blue\"], height=500)" + ] + }, + { + "cell_type": "markdown", + "id": "d630c719", + "metadata": {}, + "source": [ + "## 7. Edit the design, then view it again\n", + "Views read the live netlist, so a fresh view shows the edit: `u_led_red` instead of `u_red`. To update an existing view instead, call `view.design_changed()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd92618e", + "metadata": {}, + "outputs": [], + "source": [ + "top.get_child_instance(\"u_red\").set_name(\"u_led_red\")\n", + "naja_schematic.show(height=500)" + ] + }, + { + "cell_type": "markdown", + "id": "bcbfaf10", + "metadata": {}, + "source": [ + "## 8. A real design: tinyrocket\n", + "A gate-level RISC-V core is in its own notebook, since najaeda holds one design at a time: [open the tinyrocket notebook in Colab](https://colab.research.google.com/github/keplertech/naja-schematic/blob/main/python/notebooks/tinyrocket.ipynb)." + ] + }, + { + "cell_type": "markdown", + "id": "aa0c74d8", + "metadata": {}, + "source": [ + "## Known rough edges\n", + "- The mouse wheel zooms the viewer, so it doesn't scroll the page while the pointer is over a view.\n", + "- Older views don't refresh after the design is edited unless you call `view.design_changed()`.\n", + "- Any errors show in the browser console (F12) or the cell output. Issues are welcome: https://github.com/keplertech/naja-schematic/issues" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/python/notebooks/tinyrocket.ipynb b/python/notebooks/tinyrocket.ipynb new file mode 100644 index 0000000..8ea981d --- /dev/null +++ b/python/notebooks/tinyrocket.ipynb @@ -0,0 +1,115 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "eaee9fc8", + "metadata": {}, + "source": [ + "# naja-schematic: a RISC-V core\n", + "\n", + "[naja-schematic](https://github.com/keplertech/naja-schematic) is an open-source schematic viewer for gate-level and RTL netlists, running in this notebook. This loads **tinyrocket**, a synthesized [Rocket](https://github.com/chipsalliance/rocket-chip) RISC-V core, with its Liberty cell library, from the [naja tutorials](https://github.com/najaeda/naja/tree/main/tutorials). Gate symbols come from the cells' Liberty functions, never from their names.\n", + "\n", + "New to the viewer? Start with the [walkthrough notebook](https://colab.research.google.com/github/keplertech/naja-schematic/blob/main/python/notebooks/getting_started.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e0f4ca24", + "metadata": {}, + "outputs": [], + "source": [ + "# Installs (or upgrades to) the released package. The notebook test sets\n", + "# NAJA_SCHEMATIC_NOTEBOOK_TEST to keep the freshly built wheel it runs against.\n", + "import os\n", + "if \"NAJA_SCHEMATIC_NOTEBOOK_TEST\" not in os.environ:\n", + " %pip install -q -U naja-schematic\n", + "\n", + "import najaeda, naja_schematic, anywidget\n", + "print(\"naja-schematic\", naja_schematic.__version__, \"| anywidget\", anywidget.__version__)" + ] + }, + { + "cell_type": "markdown", + "id": "3b4cada3", + "metadata": {}, + "source": [ + "## Load the design" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ebfbb7c", + "metadata": {}, + "outputs": [], + "source": [ + "!curl -sSfL https://github.com/najaeda/naja/archive/refs/heads/main.zip -o naja-main.zip && unzip -q -o naja-main.zip \"naja-main/tutorials/benchmarks/*\"\n", + "bench = \"naja-main/tutorials/benchmarks\"\n", + "from najaeda import netlist\n", + "netlist.reset()\n", + "netlist.load_liberty([f\"{bench}/liberty/tutorial_cells.lib\", f\"{bench}/liberty/fakeram45_64x32.lib\"])\n", + "top = netlist.load_verilog(f\"{bench}/verilog/tinyrocket/tinyrocket.v\")\n", + "top.get_name()" + ] + }, + { + "cell_type": "markdown", + "id": "d30319b4", + "metadata": {}, + "source": [ + "## Explore it\n", + "- Expand the tree on the left: instances, terms and nets load on demand.\n", + "- Right-click a flop's `D` pin → *Trace to Driver* to draw the logic cone feeding it. It stops at other flops and the top-level inputs.\n", + "- Click a hollow pin to pull in the net behind it. Scroll to zoom and drag to pan." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab2c3c6d", + "metadata": {}, + "outputs": [], + "source": [ + "view = naja_schematic.show(height=700)\n", + "view" + ] + }, + { + "cell_type": "markdown", + "id": "9ae318cd", + "metadata": {}, + "source": [ + "## Export what you've drawn as SVG\n", + "Run this after drawing something above. The SVG appears below and in `tinyrocket.svg`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "733caedb", + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import SVG, Markdown, display\n", + "out = display(Markdown(\"*Waiting for the view to draw...*\"), display_id=True)\n", + "view.export_svg(\"tinyrocket.svg\", lambda svg: out.update(SVG(svg)))" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/python/tests/test_notebook.py b/python/tests/test_notebook.py index a8fbc51..bf0fb30 100644 --- a/python/tests/test_notebook.py +++ b/python/tests/test_notebook.py @@ -1,5 +1,6 @@ -"""Runs python/notebooks/colab_test.ipynb (the Open-in-Colab notebook) in a -real IPython kernel, so an API change that breaks it fails CI. +"""Runs the Open-in-Colab notebooks (python/notebooks/getting_started.ipynb, the +walkthrough, and tinyrocket.ipynb) in a real IPython kernel, so an API change +that breaks them fails CI. The kernel side is all that can run headless: there's no browser to render the widget, so a cell spliced in mid-notebook plays the viewer against the @@ -31,10 +32,10 @@ pytest.importorskip("ipykernel") pytest.importorskip("anywidget") -NOTEBOOK = Path(__file__).parents[1] / "notebooks" / "colab_test.ipynb" +NOTEBOOKS = Path(__file__).parents[1] / "notebooks" WIDGET_MIME = "application/vnd.jupyter.widget-view+json" -# Stands in for the viewer while the full adder is loaded: every request +# Stands in for the viewer while the RGB dimmer is loaded: every request # must be answered, and the viewer -> host selection must reach najaeda. VIEWER_CHECK = r''' import json @@ -48,18 +49,24 @@ def _ask(request): replies = _ask({"request": "load_root"}) assert [r["response"] for r in replies] == ["root_response", "focus_instance"], replies -assert replies[0]["root"]["name"] == "fulladder", replies[0] -assert replies[1]["path"] == ["ha1", "carry_and"], replies[1] +assert replies[0]["root"]["name"] == "rgb_pwm", replies[0] +assert replies[1]["path"] == ["u_counter"], replies[1] -(resolved,) = _ask({"request": "resolve_instance", "path": ["ha2", "sum_xor"]}) +(resolved,) = _ask({"request": "resolve_instance", "path": ["u_red"]}) assert resolved["response"] == "instance_resolved" and resolved["found"], resolved -(props,) = _ask({"request": "get_properties", "kind": "instance", "path": ["ha1"]}) -assert {"name": "Model", "value": "halfadder"} in props["properties"], props +(props,) = _ask({"request": "get_properties", "kind": "instance", "path": ["u_red"]}) +assert {"name": "Model", "value": "pwm_channel"} in props["properties"], props -assert _ask({"request": "instance_selected", "path": ["ha2"]}) == [] -assert view.selected_path == ["ha2"] -assert view.selected.get_name() == "ha2" +assert _ask({"request": "instance_selected", "path": ["u_green"]}) == [] +assert view.selected_path == ["u_green"] +assert view.selected.get_name() == "u_green" + +_svgs = [] +view.export_svg(callback=_svgs.append) +assert [m["response"] for m in _sent] == ["export_svg"], _sent +assert _ask({"request": "svg_exported", "svg": ""}) == [] +assert _svgs == [""] and view.svg == "" ''' @@ -99,9 +106,11 @@ def splice_viewer_check(nb): nb.cells.insert(at + 1, nbformat.v4.new_code_cell(VIEWER_CHECK)) -def test_colab_notebook_runs(notebook_env): - nb = nbformat.read(NOTEBOOK, as_version=4) - splice_viewer_check(nb) +@pytest.mark.parametrize("name, min_views", [("getting_started", 3), ("tinyrocket", 1)]) +def test_colab_notebook_runs(notebook_env, name, min_views): + nb = nbformat.read(NOTEBOOKS / f"{name}.ipynb", as_version=4) + if name == "getting_started": + splice_viewer_check(nb) if COLAB_VERSION: install = next(i for i, cell in enumerate(nb.cells) if cell.cell_type == "code") @@ -123,4 +132,4 @@ def test_colab_notebook_runs(notebook_env): views = [c for c in code_cells if any(WIDGET_MIME in o.get("data", {}) for o in c.outputs)] shows = [c for c in code_cells if "show(" in c.source] - assert len(views) == len(shows) >= 4 + assert len(views) == len(shows) >= min_views diff --git a/python/tests/test_widget.py b/python/tests/test_widget.py index d7c3513..24b8b2e 100644 --- a/python/tests/test_widget.py +++ b/python/tests/test_widget.py @@ -149,3 +149,41 @@ def test_design_changed_resolves_the_new_focus_first(top, view): v.design_changed(instance=["u_sub", "u_and"]) v._on_viewer_message(v, {"json": json.dumps({"request": "load_root"})}, []) assert sent_kinds(sent) == ["design_changed", "root_response", "focus_instance"] + + +def test_export_svg_asks_the_view_and_keeps_its_answer(top, view, tmp_path): + v, sent = view + got = [] + out = tmp_path / "view.svg" + v.export_svg(out, got.append) + assert [json.loads(m["json"]) for m in sent] == [{"response": "export_svg", "generation": 0}] + del sent[:] + v._on_viewer_message(v, {"json": json.dumps( + {"request": "svg_exported", "svg": "", "generation": 0})}, []) + assert sent == [] # a notification: no reply + assert got == [""] and v.svg == "" and out.read_text() == "" + # One-shot: another rendering of the same view answering too is ignored. + v._on_viewer_message(v, {"json": json.dumps({"request": "svg_exported", "svg": "2"})}, []) + assert got == [""] and v.svg == "" + + +def test_export_svg_for_a_replaced_design_is_dropped(top, view): + v, _ = view + got = [] + v.export_svg(callback=got.append) + v.design_changed() + v._on_viewer_message(v, {"json": json.dumps( + {"request": "svg_exported", "svg": "", "generation": 0})}, []) + assert got == [] and v.svg is None + + +def test_export_svg_waiting_is_asked_again_after_each_root(top, view): + v, sent = view + v.export_svg() + del sent[:] + v._on_viewer_message(v, {"json": json.dumps({"request": "load_root"})}, []) + assert sent_kinds(sent) == ["root_response", "export_svg"] + v._on_viewer_message(v, {"json": json.dumps({"request": "svg_exported", "svg": ""})}, []) + del sent[:] + v._on_viewer_message(v, {"json": json.dumps({"request": "load_root"})}, []) + assert sent_kinds(sent) == ["root_response"] diff --git a/src/AppLogic.cpp b/src/AppLogic.cpp index b421453..9d2b0c9 100644 --- a/src/AppLogic.cpp +++ b/src/AppLogic.cpp @@ -399,6 +399,8 @@ void setupProvider(AppState& state) { Console::Log("Diagnosis received: " + std::to_string(items.size()) + " item(s)"); DiagnosisStore::setDiagnostics(std::move(items)); state.focusDiagnosisTab = true; + } else if (resp == "export_svg") { + state.svgExportRequested = true; } else if (resp == "focus_instance") { // Host push (notebook show_instance()): resolve the path, then // instance_resolved below reveals and draws it. The host names the @@ -731,6 +733,13 @@ bool appFrame(AppState& state) { { if (EquipotentialView::takePendingClear()) state.guiData->clearEquipotentials(); EquipotentialView::renderSchematic(state.guiData->equipotentials_); + if (state.svgExportRequested && EquipotentialView::hasSchematic()) { + state.svgExportRequested = false; + json reply; + reply["request"] = "svg_exported"; + reply["svg"] = EquipotentialView::exportSvg(); + state.provider->send(reply.dump()); + } } ImGui::EndChild(); } diff --git a/src/AppLogic.h b/src/AppLogic.h index 46ec8d9..aaa7f01 100644 --- a/src/AppLogic.h +++ b/src/AppLogic.h @@ -25,6 +25,11 @@ struct AppState { // "Equipotential" table lists: 1 after a single load_equipotential, every // net of the cone after a trace_driver_response. size_t tableEquipotentialCount {1}; + // Set by a host's export_svg push (notebook Schematic.export_svg()): + // appFrame() answers with an svg_exported message once the schematic + // shows something, so a request sent while the view is still loading + // waits for its first drawing. + bool svgExportRequested {false}; }; // Wire all provider callbacks (on_open, on_message, on_close, on_error)