diff --git a/CLAUDE.md b/CLAUDE.md index 558a83d..ebd7180 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,9 @@ WASM viewer, so najaeda users get a viewer from `pip install` alone: viewer's requests come back over the widget comm channel as `{"json": ""}` and are answered in the kernel, so the view shows the netlist as edited by earlier cells. `Schematic.annotate(items)` - pushes diagnoses. + pushes diagnoses; `show_instance()`/`selected`/`on_select()` exchange + instances with najaeda (see "Getting to one hierarchical instance" + under Wire protocol). The viewer bundle `static/naja-schematic.js` is **not** checked in: it's the WASM target configured with `-DNAJA_SCHEMATIC_WASM_MODULE=ON` (single file, @@ -347,6 +349,41 @@ all, so it gets diagnosis data via **File > Load Diagnosis JSON...** (reads a `{"items": [...]}` file or a bare array through the same `DiagnosisItem` parser) instead. +Getting to one hierarchical instance works in both directions, keyed by +the same instance-name path as diagnosis items: + +- **Host → viewer.** `focus_instance` is a server push, + `{"response":"focus_instance","path":["u1","u2"]}`, from + `Schematic.show_instance()` / `show(instance=...)`, re-pushed after each + `root_response` like diagnoses. The viewer answers it with a + `resolve_instance` request (`{"request":"resolve_instance","path":[...]}`), + which both providers implement (`protocol.py` + `_handle_resolve_instance`, `LocalSNLProvider::buildResolveInstanceResponse`). + The reply looks like this: + ```json + { "response": "instance_resolved", "path": ["u1","u2"], "found": true, + "instance": { // absent for the top design ([]) + "path": [["u1", 3, "Mod"], ["u2", 7, "AND2"]], // [name, child_id, model] per level + "design_ref": {...}, "has_instances": false, "source_loc": null, + "terms": [ {"name": "A", "child_id": 0, "direction": 0}, ... ] } } // expanded_instance_terms shape + ``` + The viewer then: + - clears the schematic and draws the instance alone with every pin open + (`EquipotentialView::showInstance()`, parsed by + `startInstanceFromResolved()`); + - selects it (`SelectionStore`); + - reveals it in the tree. `NetlistTree::reveal()` walks down the path, + requesting each level's Instances/Primitives group as needed; it's + asynchronous and `advanceReveal()` runs each frame. +- **Viewer → host.** There is one selected instance: click an instance row + in the tree or a box/frame body in the schematic. Each change sends a + notification with no reply, `{"request":"instance_selected","path":[...]}`, + plus a `get_properties` for it. The notebook widget intercepts + `instance_selected` into `Schematic.selected_path`; `Schematic.selected` + returns it as a najaeda `netlist.Instance`, and `on_select()` gives a + callback. The other hosts only log it (`protocol.py`) or ignore it + (`LocalSNLProvider`). + `get_properties`/`properties_response` is a general name/value inspector for whatever object the UI asks about — an instance (including the top design itself), a term/pin, or a net — answered by both `LocalSNLProvider` @@ -414,6 +451,12 @@ appear as boxes/pins there). `root_response`/`root_loaded`. - **`PropertiesView`** — renders the current `PropertiesStore` contents as a two-column name/value table into the "Properties" bottom-panel tab. +- **`SelectionStore`** — global static store (same pattern) for the one + selected instance, by pathKey (`""` = top). It's set from the tree, the + schematic or a host `focus_instance`, and drawn highlighted in both + views. Its listener (installed in `AppLogic.cpp`) reports each change to + the host. `NetlistTree` reveals selections made outside it by comparing + `revision()`. Cleared on every fresh `root_response`/`root_loaded`. - **`SchematicLayout`** — the schematic's pure placement geometry, split out of `EquipotentialView` so it's unit-testable without ImGui frames or a provider (`tests/SchematicLayoutTest.cpp`): `IncrementalLayout` (per-net diff --git a/CMakeLists.txt b/CMakeLists.txt index 03181bd..c0e7d5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,6 +83,7 @@ set(CORE_SOURCES src/SourceView.cpp src/PropertiesStore.cpp src/PropertiesView.cpp + src/SelectionStore.cpp src/AppLogic.cpp ${IMGUI_SOURCES} ) @@ -236,6 +237,7 @@ if(NOT EMSCRIPTEN_BUILD) src/DiagnosisStore.cpp src/SourceStore.cpp src/PropertiesStore.cpp + src/SelectionStore.cpp src/SchematicLayout.cpp src/SchematicInteraction.cpp src/EquipotentialView.cpp diff --git a/REUSE.toml b/REUSE.toml index 42835c6..6f7d54e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -73,6 +73,8 @@ path = [ "src/SchematicLayout.h", "src/SchematicView.cpp", "src/SchematicView.h", + "src/SelectionStore.cpp", + "src/SelectionStore.h", "src/SourceStore.cpp", "src/SourceStore.h", "src/SourceView.cpp", @@ -92,6 +94,7 @@ path = [ "tests/GUIDataTest.cpp", "tests/NetlistTreeTest.cpp", "tests/SchematicInteractionTest.cpp", + "tests/SelectionTest.cpp", "tests/SchematicLayoutTest.cpp", "tests/SourceStoreTest.cpp", "tests/TypesJsonTest.cpp", diff --git a/python/README.md b/python/README.md index 5385365..ec602ab 100644 --- a/python/README.md +++ b/python/README.md @@ -33,6 +33,26 @@ view.annotate([{"kind": "instance", "path": ["u_sub"], "severity": "error", view ``` +The view and your najaeda code can point each other at instances. +`show_instance()` takes a najaeda `Instance`, a `"u1/u2"` path or a list of +names. The viewer opens its tree down to that instance, selects it, and +draws it alone in the schematic. Each pin there is open, so you can click +one to add its net and grow the schematic from the instance. In the other +direction, the instance selected in the viewer (click it in the tree or the +schematic) is available as a najaeda `Instance`: + +```python +view.show_instance(netlist.get_instance_by_path(["u_sub", "u_and"])) +view.show_instance("u_sub/u_and") # same thing + +inst = view.selected # what the user clicked, or None +inst.get_model_name() + +view.on_select(lambda inst: print("selected", inst)) +``` + +`naja_schematic.show(instance="u_sub/u_and")` starts a new view on it. + ## From a shell ```bash diff --git a/python/naja_schematic/protocol.py b/python/naja_schematic/protocol.py index c87c31c..05e186d 100644 --- a/python/naja_schematic/protocol.py +++ b/python/naja_schematic/protocol.py @@ -454,6 +454,31 @@ def _handle_trace_driver(u, request): }] +def bit_terms_json(design): + # Every pin of a design, bus terms expanded bit by bit ("data[3]", with + # "bit" set) so each gets its own schematic pin. + terms = [] + for term in design.getTerms(): + if isinstance(term, naja.SNLBusTerm): + lo, hi = sorted((term.getLSB(), term.getMSB())) + for b in range(lo, hi + 1): + bit_term = term.getBusTermBit(b) + if bit_term: + terms.append({ + "name": f"{term.getName()}[{b}]", + "child_id": bit_term.getID(), + "direction": direction_to_int(bit_term.getDirection()), + "bit": b, + }) + else: + terms.append({ + "name": term.getName(), + "child_id": term.getID(), + "direction": direction_to_int(term.getDirection()), + }) + return terms + + def _handle_expand_instance_terms(u, request): path_key = request.get("path_key", "") design_ref = get_design_ref(request.get("design_ref")) @@ -461,35 +486,57 @@ def _handle_expand_instance_terms(u, request): design = u.getSNLDesign(design_ref) if design_ref else None if not design: log.warning("expand_instance_terms: design not found for %s", design_ref) - terms = [] - - if design: - for term in design.getTerms(): - if isinstance(term, naja.SNLBusTerm): - lo, hi = sorted((term.getLSB(), term.getMSB())) - for b in range(lo, hi + 1): - bit_term = term.getBusTermBit(b) - if bit_term: - terms.append({ - "name": f"{term.getName()}[{b}]", - "child_id": bit_term.getID(), - "direction": direction_to_int(bit_term.getDirection()), - "bit": b, - }) - else: - terms.append({ - "name": term.getName(), - "child_id": term.getID(), - "direction": direction_to_int(term.getDirection()), - }) - return [{ "response": "expanded_instance_terms", "path_key": path_key, - "terms": terms + "terms": bit_terms_json(design) if design else [] }] +def _handle_resolve_instance(u, request): + # Everything the viewer needs to show one instance, given only its + # instance-name path (the same convention as get_properties and + # diagnosis items): the child_id/model of each path level, to reveal it + # in the tree and address its pins, plus its full pin list, to draw it + # alone in the schematic as a starting point. + path = [str(name) for name in request.get("path", [])] + top = u.getTopDesign() + design, instance = resolve_instance_path(top, path) if top else (None, None) + reply = {"response": "instance_resolved", "path": path, + "found": top is not None and (instance is not None or not path)} + if instance is None: + if top is not None and path: + log.warning("resolve_instance: could not resolve instance path %s", path) + return [reply] + levels = [] + current = top + for name in path: + inst = current.getInstance(name) + levels.append([inst.getName(), inst.getID(), inst.getModel().getName()]) + current = inst.getModel() + reply["instance"] = { + "path": levels, + "design_ref": { + "db_id": design.getDB().getID(), + "library_id": design.getLibrary().getID(), + "design_id": design.getID(), + }, + "has_instances": (design.hasNonPrimitiveInstances() or + has_visible_primitive_instances(design)), + "source_loc": get_source_loc(instance), + "terms": bit_terms_json(design), + } + return [reply] + + +def _handle_instance_selected(u, request): + # A notification, not a request: the viewer reports the instance the + # user selected. The notebook widget intercepts it (Schematic.selected); + # elsewhere it's only logged. + log.info("Viewer selected instance: %s", "/".join(request.get("path") or []) or "") + return [] + + def _handle_load_instance_internals(u, request): path_key = request.get("path_key", "") design_ref = get_design_ref(request.get("design_ref")) @@ -670,6 +717,8 @@ def _handle_get_properties(u, request): "load_instance_internals": _handle_load_instance_internals, "load_source": _handle_load_source, "get_properties": _handle_get_properties, + "resolve_instance": _handle_resolve_instance, + "instance_selected": _handle_instance_selected, } @@ -709,3 +758,10 @@ def diagnosis_response(items): if isinstance(items, dict): items = items.get("items", []) return {"response": "diagnosis_response", "items": list(items)} + + +def focus_instance(path): + """Build a focus_instance push message: the viewer resolves `path` (a + list of instance names, top excluded; [] = the top design), reveals and + selects it in the tree, and draws it alone in the schematic.""" + return {"response": "focus_instance", "path": [str(name) for name in path]} diff --git a/python/naja_schematic/widget.py b/python/naja_schematic/widget.py index 1b4af5c..525730f 100644 --- a/python/naja_schematic/widget.py +++ b/python/naja_schematic/widget.py @@ -11,6 +11,7 @@ import anywidget import traitlets +from najaeda import naja from . import protocol from ._bundle import STATIC_DIR, bundle_path @@ -32,10 +33,58 @@ def _widget_esm(): return bundle.read_text() + "\n" + (STATIC_DIR / "widget.js").read_text() +def _names_to_ids(names): + # Instance-name path (top excluded) -> najaeda child-id path, walking + # down from the top design; None if a name doesn't resolve. + universe = naja.NLUniverse.get() + design = universe.getTopDesign() if universe else None + ids = [] + for name in names: + inst = design.getInstance(name) if design else None + if inst is None: + return None + ids.append(inst.getID()) + design = inst.getModel() + return ids + + +def _ids_to_names(ids): + universe = naja.NLUniverse.get() + design = universe.getTopDesign() if universe else None + names = [] + for inst_id in ids: + inst = design.getInstanceByID(inst_id) if design else None + if inst is None: + raise ValueError(f"instance id path {list(ids)} does not resolve in the loaded design") + names.append(inst.getName()) + design = inst.getModel() + return names + + +def instance_path(target): + """Normalize what show_instance() accepts to a list of instance names + (top excluded): a najaeda netlist.Instance, a "u1/u2" string, or a + list/tuple of names. The top design is [] (or "").""" + if hasattr(target, "pathIDs"): # najaeda.netlist.Instance + return _ids_to_names(target.pathIDs) + if isinstance(target, str): + return [name for name in target.split("/") if name] + if isinstance(target, (list, tuple)): + return [str(name) for name in target] + raise TypeError(f"expected a najaeda Instance, a 'u1/u2' path or a list of names, " + f"got {type(target).__name__}") + + class Schematic(anywidget.AnyWidget): """An interactive naja-schematic view of the design in NLUniverse.""" height = traitlets.Int(600).tag(sync=True) + # Instance-name path (top excluded) of the instance selected in the + # viewer -- clicked in its tree or schematic, or set by show_instance(). + # None until something is selected. Kernel-side only (not synced): the + # viewer reports changes as instance_selected messages. Observe it with + # on_select(), or traitlets' observe(..., names="selected_path"). + selected_path = traitlets.List(default_value=None, allow_none=True) def __init__(self, **kwargs): # Read before AnyWidget.__init__, which turns it into a synced trait. @@ -44,6 +93,7 @@ def __init__(self, **kwargs): self._esm = _widget_esm() super().__init__(**kwargs) self._diagnosis_push = None + self._focus_push = None self.on_msg(self._on_viewer_message) def _send_json(self, message): @@ -54,16 +104,23 @@ def _on_viewer_message(self, _widget, content, _buffers): if not isinstance(message, str): return try: - replies = protocol.handle_message(message) + request = json.loads(message) except json.JSONDecodeError as e: log.error("Ignoring malformed viewer request %r: %s", message, e) return - for reply in replies: - self._send_json(reply) - # The viewer clears its diagnoses on every root_response, so - # re-annotate after the root is (re)loaded. - if self._diagnosis_push and '"root_response"' in reply: - self._send_json(self._diagnosis_push) + if request.get("request") == "instance_selected": + path = request.get("path") + self.selected_path = list(path) if isinstance(path, list) else None + return + for reply in protocol.handle_request(request): + self._send_json(json.dumps(reply)) + # The viewer clears its diagnoses and selection on every + # root_response, so re-apply them after the root is (re)loaded. + if reply.get("response") == "root_response": + if self._diagnosis_push: + self._send_json(self._diagnosis_push) + if self._focus_push: + self._send_json(self._focus_push) def annotate(self, items): """Overlay diagnosis items on the view (and keep them across reloads). @@ -77,16 +134,56 @@ def annotate(self, items): self._diagnosis_push = json.dumps(protocol.diagnosis_response(items)) self._send_json(self._diagnosis_push) + def show_instance(self, target): + """Bring one hierarchical instance into view. -def show(height=600, diagnosis=None): + `target`: a najaeda netlist.Instance, a "u1/u2" path string, or a + list of instance names (top excluded; [] or "" = the top design). + The viewer opens the tree down to it, selects it, shows its + properties, and draws it alone in the schematic with all its pins, + ready to be extended pin by pin. Kept across view reloads. + """ + path = instance_path(target) + if _names_to_ids(path) is None: + raise ValueError(f"no instance {'/'.join(path)!r} in the loaded design") + self._focus_push = json.dumps(protocol.focus_instance(path)) + self._send_json(self._focus_push) + + @property + def selected(self): + """The instance selected in the viewer, as a najaeda netlist.Instance + (the top design for a selected root), or None if nothing is selected + or it no longer exists in the netlist.""" + if self.selected_path is None: + return None + ids = _names_to_ids(self.selected_path) + if ids is None: + return None + from najaeda import netlist + return netlist.Instance(ids) + + def on_select(self, callback): + """Call `callback(instance)` each time the viewer's selection changes + (`instance` as returned by `selected`). Returns the traitlets handler, + for `unobserve(handler, names="selected_path")`.""" + def handler(_change): + callback(self.selected) + self.observe(handler, names="selected_path") + return handler + + +def show(height=600, diagnosis=None, instance=None): """Display a view of the design currently loaded with najaeda. Evaluate it as the last expression of a cell (or pass it to IPython.display.display). Requests are answered from the live netlist, so run show() again after editing the design to get a fresh view. + `instance` starts the view on one instance (see Schematic.show_instance). """ # (anywidget itself turns on Colab's custom widget manager.) view = Schematic(height=height) if diagnosis is not None: view.annotate(diagnosis) + if instance is not None: + view.show_instance(instance) return view diff --git a/python/tests/test_protocol.py b/python/tests/test_protocol.py index dfc94a7..1f421ec 100644 --- a/python/tests/test_protocol.py +++ b/python/tests/test_protocol.py @@ -101,3 +101,36 @@ def test_diagnosis_response_accepts_document_or_list(): item = {"kind": "instance", "path": ["u_sub"], "severity": "error", "message": "m"} assert protocol.diagnosis_response([item]) == {"response": "diagnosis_response", "items": [item]} assert protocol.diagnosis_response({"items": [item]})["items"] == [item] + + +def test_resolve_instance(top): + (reply,) = handle_request({"request": "resolve_instance", "path": ["u_sub", "u_and"]}) + assert reply["response"] == "instance_resolved" and reply["found"] + inst = reply["instance"] + assert [level[0] for level in inst["path"]] == ["u_sub", "u_and"] + assert [level[2] for level in inst["path"]] == ["sub", "LUT2"] + assert not inst["has_instances"] + assert {t["name"] for t in inst["terms"]} == {"I0", "I1", "O"} + + (reply,) = handle_request({"request": "resolve_instance", "path": ["u_sub"]}) + assert reply["instance"]["has_instances"] # u_and is a visible primitive + # Ids are the ones every other request addresses the instance with. + (props,) = handle_request({"request": "get_properties", "kind": "instance", "path": ["u_sub"]}) + assert {"name": "Model", "value": "sub"} in props["properties"] + + +def test_resolve_instance_top_and_unknown(top): + (reply,) = handle_request({"request": "resolve_instance", "path": []}) + assert reply["found"] and "instance" not in reply + + (reply,) = handle_request({"request": "resolve_instance", "path": ["u_sub", "nope"]}) + assert reply == {"response": "instance_resolved", "path": ["u_sub", "nope"], "found": False} + + +def test_instance_selected_is_a_notification(top): + assert handle_request({"request": "instance_selected", "path": ["u_sub"]}) == [] + + +def test_focus_instance_message(): + assert protocol.focus_instance(["u_sub", "u_and"]) == { + "response": "focus_instance", "path": ["u_sub", "u_and"]} diff --git a/python/tests/test_widget.py b/python/tests/test_widget.py index 8d8a146..1979fe9 100644 --- a/python/tests/test_widget.py +++ b/python/tests/test_widget.py @@ -47,3 +47,64 @@ def test_missing_bundle_is_a_clear_error(tmp_path, monkeypatch): with pytest.raises(RuntimeError, match="viewer bundle"): widget.Schematic() widget._widget_esm.cache_clear() + + +def sent_kinds(sent): + return [json.loads(m["json"])["response"] for m in sent] + + +@pytest.mark.parametrize("target", ["u_sub/u_and", ["u_sub", "u_and"], ("u_sub", "u_and")]) +def test_show_instance_pushes_focus(top, view, target): + v, sent = view + v.show_instance(target) + (msg,) = sent + assert json.loads(msg["json"]) == {"response": "focus_instance", "path": ["u_sub", "u_and"]} + + +def test_show_instance_accepts_a_najaeda_instance(top, view): + from najaeda import netlist + v, sent = view + v.show_instance(netlist.get_instance_by_path(["u_sub", "u_and"])) + assert json.loads(sent[0]["json"])["path"] == ["u_sub", "u_and"] + + +def test_show_instance_rejects_unknown_paths(top, view): + v, sent = view + with pytest.raises(ValueError, match="u_sub/nope"): + v.show_instance("u_sub/nope") + with pytest.raises(TypeError): + v.show_instance(42) + assert sent == [] + + +def test_focus_is_reapplied_after_each_root(top, view): + v, sent = view + v.show_instance("u_sub") + v._on_viewer_message(v, {"json": json.dumps({"request": "load_root"})}, []) + assert sent_kinds(sent) == ["focus_instance", "root_response", "focus_instance"] + + +def test_viewer_selection_is_a_najaeda_instance(top, view): + from najaeda import netlist + v, sent = view + assert v.selected is None + seen = [] + v.on_select(seen.append) + + select = {"request": "instance_selected", "path": ["u_sub", "u_and"]} + v._on_viewer_message(v, {"json": json.dumps(select)}, []) + assert sent == [] # a notification: nothing is sent back + assert v.selected_path == ["u_sub", "u_and"] + assert v.selected == netlist.get_instance_by_path(["u_sub", "u_and"]) + assert v.selected.get_model_name() == "LUT2" + assert seen == [v.selected] + + v._on_viewer_message(v, {"json": json.dumps({"request": "instance_selected", "path": []})}, []) + assert v.selected.is_top() + assert len(seen) == 2 + + +def test_selection_that_no_longer_resolves_is_none(top, view): + v, _ = view + v.selected_path = ["gone"] + assert v.selected is None diff --git a/src/AppLogic.cpp b/src/AppLogic.cpp index 99de862..26439f5 100644 --- a/src/AppLogic.cpp +++ b/src/AppLogic.cpp @@ -23,6 +23,7 @@ using json = nlohmann::json; #include "SourceStore.h" #include "SourceView.h" #include "PropertiesStore.h" +#include "SelectionStore.h" #include "PropertiesView.h" #include "DroidSansFont.h" #include "Version.h" @@ -63,6 +64,21 @@ void setupProvider(AppState& state) { EquipotentialView::setProvider(state.provider); attachTreeCallbacks(state); + // Every selection change (tree click, schematic click, host focus) shows + // the instance's properties and is reported to the host, which scripts + // on it (the notebook widget's Schematic.selected). + SelectionStore::setListener([&state](const std::string& pathKey) { + json note; + note["request"] = "instance_selected"; + note["path"] = splitPathKey(pathKey); + state.provider->send(note.dump()); + json props; + props["request"] = "get_properties"; + props["kind"] = "instance"; + props["path"] = splitPathKey(pathKey); + state.provider->send(props.dump()); + }); + state.provider->on_open([&state]() { state.connected = true; Console::Log("Connected to netlist provider"); @@ -98,6 +114,7 @@ void setupProvider(AppState& state) { Console::Log("Root node data received"); DiagnosisStore::clear(); // stale diagnoses reference the old design PropertiesStore::clear(); // stale properties reference the old design + SelectionStore::clear(); // so does a stale selection const auto& root = j["root"]; if (root.contains("has_terms") || root.contains("has_primitives") || root.contains("has_instances")) { InstanceResponseJson data = root.get(); @@ -293,6 +310,30 @@ 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 == "focus_instance") { + // Host push (notebook show_instance()): resolve the path, then + // instance_resolved below reveals and draws it. + json req; + req["request"] = "resolve_instance"; + req["path"] = j.contains("path") && j["path"].is_array() ? j["path"] : json::array(); + state.provider->send(req.dump()); + } else if (resp == "instance_resolved") { + std::vector path; + if (j.contains("path") && j["path"].is_array()) + for (const auto& seg : j["path"]) path.push_back(seg.get()); + std::string pathKey; + for (const auto& seg : path) pathKey += (pathKey.empty() ? "" : "/") + seg; + if (!j.value("found", false)) { + Console::Error("No instance '" + pathKey + "' in the design"); + return; + } + // Start a fresh schematic from the instance alone, all pins open. + if (auto start = EquipotentialView::startInstanceFromResolved(j)) { + state.guiData->clearEquipotentials(); + EquipotentialView::resetLayout(); + EquipotentialView::showInstance(*start); + } + SelectionStore::select(pathKey, SelectionStore::Origin::Host); } else if (resp == "error") { std::cerr << "Backend error: " << j["message"] << std::endl; } diff --git a/src/EquipotentialView.cpp b/src/EquipotentialView.cpp index 135cb02..74494d9 100644 --- a/src/EquipotentialView.cpp +++ b/src/EquipotentialView.cpp @@ -15,6 +15,7 @@ #include "DiagnosisStore.h" #include "SchematicLayout.h" #include "SchematicInteraction.h" +#include "SelectionStore.h" // Net/instance placement and hierarchy frames live in SchematicLayout // (pure geometry, unit-tested); this file turns them into drawn shapes. @@ -106,6 +107,8 @@ static std::string g_pendingZoomGroup; // Persistent layout state: where each instance has been placed so far. static IncrementalLayout g_layout; +// Instances shown on their own (showInstance), by pathKey. +static std::map g_startInstances; // --------------------------------------------------------------------------- // Helpers @@ -375,6 +378,7 @@ void EquipotentialView::clearNets() { g_pendingClear = true; } void EquipotentialView::resetLayout() { g_layout.clear(); + g_startInstances.clear(); g_expandedInstances.clear(); g_pendingExpansions.clear(); g_expandedBuses.clear(); @@ -407,6 +411,48 @@ void EquipotentialView::setShowHierarchy(bool on) { g_pendingFit = true; } +void EquipotentialView::showInstance(const StartInstance& start) { + std::string key; + for (const auto& seg : start.path) key += (key.empty() ? "" : "/") + seg; + if (key.empty()) return; // the top design has no box of its own + g_startInstances[key] = start; + g_expandedInstances[key] = start.ports; +} + +std::optional +EquipotentialView::startInstanceFromResolved(const json& reply) { + if (!reply.value("found", false) || !reply.contains("instance") || !reply["instance"].is_object()) + return std::nullopt; + const auto& inst = reply["instance"]; + StartInstance start; + for (const auto& level : inst.value("path", json::array())) { + if (!level.is_array() || level.size() < 2) continue; + start.path.push_back(level[0].get()); + start.pathIds.push_back(level[1].get()); + start.pathModels.push_back(level.size() > 2 ? level[2].get() : ""); + } + if (start.path.empty()) return std::nullopt; + if (inst.contains("design_ref")) start.designRef = inst["design_ref"].get(); + start.hasInstances = inst.value("has_instances", false); + if (inst.contains("source_loc") && inst["source_loc"].is_object()) { + const auto& loc = inst["source_loc"]; + start.sourceLoc = SourceLoc{loc.value("file", std::string("")), loc.value("line", 0), + loc.value("end_line", 0), loc.value("column", 0), + loc.value("end_column", 0)}; + } + for (const auto& t : inst.value("terms", json::array())) { + ExpandedPort ep; + ep.name = t.value("name", std::string("")); + ep.childId = t.value("child_id", 0u); + if (t.contains("bit") && !t["bit"].is_null()) ep.bit = t["bit"].get(); + int dirInt = t.value("direction", 0); + ep.direction = dirInt == 1 ? Direction::Output + : dirInt == 2 ? Direction::Inout : Direction::Input; + start.ports.push_back(std::move(ep)); + } + return start; +} + void EquipotentialView::applyInstanceExpansion( const std::string& pathKey, const std::vector& ports) { @@ -582,7 +628,19 @@ void EquipotentialView::renderSchematic(const std::vector& equip } break; } - if (!glyphHit) clickPin(wp); + if (!glyphHit && !clickPin(wp)) { + // On a box body: select that instance (or module frame). Reverse + // scan so the innermost box under the cursor wins. + for (auto rit = g_schematic.instances.rbegin(); rit != g_schematic.instances.rend(); ++rit) { + if (rit->w <= 0.f || rit->h <= 0.f) continue; + if (wp.x < rit->x || wp.x > rit->x + rit->w) continue; + if (wp.y < rit->y || wp.y > rit->y + rit->h) continue; + auto occIt = g_occInfoByShapeId.find(rit->id); + if (occIt != g_occInfoByShapeId.end()) + SelectionStore::select(occIt->second.pathKey, SelectionStore::Origin::Schematic); + break; + } + } } // Double-click on a box body (not a pin -- those act on single click @@ -630,6 +688,8 @@ void EquipotentialView::renderSchematic(const std::vector& equip // ----------------------------------------------------------------------- for (Equipotential* eq : equipotentials) if (eq) g_layout.place(eq); + for (const auto& [key, start] : g_startInstances) + g_layout.placeAlone(key); // ----------------------------------------------------------------------- // Rebuild geometry: merged instances + per-equip wires @@ -661,6 +721,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip std::optional sourceLoc; std::vector path; std::vector pathModels; + std::vector pathIds; // child_ids down to this instance std::vector ports; }; std::map minsts; @@ -701,6 +762,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip mi.sourceLoc = item.sourceLoc; mi.path = item.path; mi.pathModels = item.pathModels; + mi.pathIds = item.pathIds; auto pit = g_layout.positions().find(item.key()); mi.pos = pit != g_layout.positions().end() ? pit->second : ImVec2{kLeftMargin, 0.f}; @@ -730,6 +792,25 @@ void EquipotentialView::renderSchematic(const std::vector& equip } } + // Instances shown on their own (showInstance): a box even with no net + // through it yet. Their pins come from g_expandedInstances, all open + // until a net reaches them. + for (const auto& [key, start] : g_startInstances) { + ++totalItems; + auto& mi = minsts[key]; + if (mi.initialized) continue; + mi.initialized = true; + mi.designRef = start.designRef; + mi.hasInstances = start.hasInstances; + mi.bitTermCount = start.ports.size(); + mi.sourceLoc = start.sourceLoc; + mi.path = start.path; + mi.pathModels = start.pathModels; + mi.pathIds = start.pathIds; + auto pit = g_layout.positions().find(key); + mi.pos = pit != g_layout.positions().end() ? pit->second : ImVec2{kLeftMargin, 0.f}; + } + // Pass 2: build InstanceShapes from merged data std::map keyToInstId; // Per-bit port id (as assigned in pass 1, or freshly allocated below for @@ -750,6 +831,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip inst.name = key; inst.modelName = modelNameFromLeaf(leafSegment(key)); inst.diagOutline = DiagnosisStore::instanceColor(key); + inst.selected = SelectionStore::isSelected(key); g_occInfoByShapeId[inst.id] = { key, mi.designRef, mi.sourceLoc }; keyToInstId[key] = inst.id; @@ -798,8 +880,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip if (ps.name == ep.name && ps.direction == ep.direction) { pid = ps.portId; nextPortId--; open = false; break; } } - auto pathIds = mi.ports.empty() ? std::vector{} : mi.ports[0].pathIds; - g_portEquiByPortId[pid] = { pathIds, ep.childId, ep.bit }; + g_portEquiByPortId[pid] = { mi.pathIds, ep.childId, ep.bit }; resolved.push_back({ &ep, pid, open }); } @@ -945,6 +1026,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip std::vector frameShapes; for (auto& f : frames) { f.shape.diagOutline = DiagnosisStore::instanceColor(f.pathKey); + f.shape.selected = SelectionStore::isSelected(f.pathKey); g_occInfoByShapeId[f.shape.id] = { f.pathKey, DesignRef{}, std::nullopt }; frameShapes.push_back(std::move(f.shape)); } @@ -1239,7 +1321,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip } g_schematic.render(dl, cpos, inner); - if (equipotentials.empty()) { + if (equipotentials.empty() && g_startInstances.empty()) { // Nothing drawn yet: the schematic is only ever filled from a pin's // context menu, which a first-time user has no way to guess. const char* hint = "Right-click a pin in the Netlist Hierarchy (e.g. top > Terms > a port)\n" diff --git a/src/EquipotentialView.h b/src/EquipotentialView.h index 82e6a53..52ad294 100644 --- a/src/EquipotentialView.h +++ b/src/EquipotentialView.h @@ -45,6 +45,18 @@ class EquipotentialView { std::vector nets; }; + // One instance to draw on its own, with its full interface and no net + // yet -- a starting point to extend pin by pin (see showInstance()). + struct StartInstance { + std::vector path; // instance names, top excluded + std::vector pathIds; // matching child_ids + std::vector pathModels; // matching model names + DesignRef designRef{}; // the instance's model + bool hasInstances = false; + std::optional sourceLoc; + std::vector ports; // every pin, bus bits expanded + }; + static void renderSchematic(const std::vector& equipotentials); static void renderTable(const std::vector& equipotentials); static void zoomIn(); @@ -69,6 +81,14 @@ class EquipotentialView { static void applyInstanceExpansion(const std::string& pathKey, const std::vector& ports); + // Adds `start` to the view as a lone box showing all its pins, each + // "open" until its net is loaded. Call resetLayout() first to start a + // fresh view from it (as a host's focus_instance does). + static void showInstance(const StartInstance& start); + // The StartInstance described by an instance_resolved reply, or nullopt + // when it has none (not found, or the top design). + static std::optional startInstanceFromResolved(const json& reply); + // Called by AppLogic when an instance_internals_response arrives. static void applyInstanceInternals(const std::string& pathKey, const InstanceInternals& data); diff --git a/src/LocalSNLProvider.cpp b/src/LocalSNLProvider.cpp index bf0ded5..60de4c4 100644 --- a/src/LocalSNLProvider.cpp +++ b/src/LocalSNLProvider.cpp @@ -137,6 +137,35 @@ static json bitTermJson(const SNLBitTerm* bt) { // RTL source location for an elaborated object, if naja has one recorded // (SNLRTLInfos -- populated today only by the SystemVerilog/slang frontend). // Returns JSON null when unavailable, which the client treats as "no link". +// Every pin of a design, bus terms expanded bit by bit ("data[3]", with +// "bit" set) so each gets its own schematic pin. +static json allBitTermsJson(const SNLDesign* design) { + json terms = json::array(); + for (auto* term : design->getTerms()) { + if (auto* bus = dynamic_cast(term)) { + int lo = std::min(static_cast(bus->getLSB()), + static_cast(bus->getMSB())); + int hi = std::max(static_cast(bus->getLSB()), + static_cast(bus->getMSB())); + for (int b = lo; b <= hi; ++b) { + if (auto* bit = bus->getBit(b)) { + json t = bitTermJson(bit); + // Append the bit index to the name for display ("data[3]"), + // but keep the "bit" field so the client can build requests. + if (t.contains("bit")) { + t["name"] = t["name"].get() + + "[" + std::to_string(t["bit"].get()) + "]"; + } + terms.push_back(std::move(t)); + } + } + } else if (auto* st = dynamic_cast(term)) { + terms.push_back(bitTermJson(st)); + } + } + return terms; +} + static json sourceLocJson(const SNLDesignObject* obj) { if (!obj || !obj->hasRTLInfos()) return nullptr; auto* rtl = obj->getRTLInfos(); @@ -422,6 +451,11 @@ void LocalSNLProvider::handleRequest(const std::string& jsonRequest) { msgCb_(buildSourceResponse(req)); } else if (request == "get_properties") { msgCb_(buildPropertiesResponse(req)); + } else if (request == "resolve_instance") { + msgCb_(buildResolveInstanceResponse(req)); + } else if (request == "instance_selected") { + // A notification (the user's selection), for hosts that script the + // viewer -- nothing to answer in the standalone app. } else { Console::Error("LocalSNLProvider: unknown request: " + request); } @@ -794,41 +828,59 @@ std::string LocalSNLProvider::buildExpandInstanceTermsResponse(const json& req) designId = req["design_ref"].value("design_id", 0u); } - json terms = json::array(); auto* design = findDesign(dbId, libId, designId); - if (design) { - for (auto* term : design->getTerms()) { - if (auto* bus = dynamic_cast(term)) { - // Expand bus into individual bits so each gets its own port indicator. - int lo = std::min(static_cast(bus->getLSB()), - static_cast(bus->getMSB())); - int hi = std::max(static_cast(bus->getLSB()), - static_cast(bus->getMSB())); - for (int b = lo; b <= hi; ++b) { - if (auto* bit = bus->getBit(b)) { - json t = bitTermJson(bit); - // Append the bit index to the name for display ("data[3]"), - // but keep the "bit" field so the client can build requests. - if (t.contains("bit")) { - t["name"] = t["name"].get() + - "[" + std::to_string(t["bit"].get()) + "]"; - } - terms.push_back(std::move(t)); - } - } - } else if (auto* st = dynamic_cast(term)) { - terms.push_back(bitTermJson(st)); - } - } - } - return json{ {"response", "expanded_instance_terms"}, {"path_key", pathKey}, - {"terms", terms} + {"terms", design ? allBitTermsJson(design) : json::array()} }.dump(); } +// Everything the viewer needs to show one instance given only its +// instance-name path (same convention as get_properties): each path level's +// [name, child_id, model_name], and the instance's model and full pin list, +// to reveal it in the tree and draw it alone in the schematic. Mirrors +// protocol.py's _handle_resolve_instance. +std::string LocalSNLProvider::buildResolveInstanceResponse(const json& req) const { + std::vector path; + if (req.contains("path") && req["path"].is_array()) + for (auto& seg : req["path"]) path.push_back(seg.get()); + + json reply = {{"response", "instance_resolved"}, {"path", path}, {"found", false}}; + auto* topDesign = db_ ? db_->getTopDesign() : nullptr; + if (!topDesign) return reply.dump(); + + SNLDesign* design = topDesign; + SNLInstance* instance = nullptr; + json levels = json::array(); + for (const auto& name : path) { + instance = design ? design->getInstance(NLName(name)) : nullptr; + if (!instance) { + Console::Error("resolve_instance: could not resolve instance path"); + return reply.dump(); + } + design = instance->getModel(); + levels.push_back(json::array({instance->getString(), + static_cast(instance->getID()), + designName(design)})); + } + reply["found"] = true; + if (!instance) return reply.dump(); // the top design itself + + reply["instance"] = { + {"path", std::move(levels)}, + {"design_ref", { + {"db_id", static_cast(design->getDB()->getID())}, + {"library_id", static_cast(design->getLibrary()->getID())}, + {"design_id", static_cast(design->getID())} + }}, + {"has_instances", hasAnySubInstances(design)}, + {"source_loc", sourceLocJson(instance)}, + {"terms", allBitTermsJson(design)} + }; + return reply.dump(); +} + // Resolve an instance's own model (the request's design_ref, exactly as the // client already has it from InstTermOccurrence::designRef / the merged // instance's occurrence data) and report what's inside it for a nested diff --git a/src/LocalSNLProvider.h b/src/LocalSNLProvider.h index dee90ef..c6a650c 100644 --- a/src/LocalSNLProvider.h +++ b/src/LocalSNLProvider.h @@ -73,6 +73,7 @@ class LocalSNLProvider : public INetlistProvider { std::string buildInstanceInternalsResponse(const nlohmann::json& req) const; std::string buildSourceResponse(const nlohmann::json& req) const; std::string buildPropertiesResponse(const nlohmann::json& req) const; + std::string buildResolveInstanceResponse(const nlohmann::json& req) const; }; #endif // __EMSCRIPTEN__ diff --git a/src/NetlistTree.cpp b/src/NetlistTree.cpp index 2c93226..665f4f6 100644 --- a/src/NetlistTree.cpp +++ b/src/NetlistTree.cpp @@ -5,6 +5,7 @@ #include "Console.h" #include "DiagnosisStore.h" #include "INetlistProvider.h" +#include "SelectionStore.h" void NetlistTree::createRootNode( const std::string& name, @@ -18,6 +19,15 @@ void NetlistTree::createRootNode( } void NetlistTree::render() { + // A selection made elsewhere (schematic click, host focus_instance) is + // revealed here; one made by clicking this tree is already visible. + if (SelectionStore::revision() != seenSelectionRevision_) { + seenSelectionRevision_ = SelectionStore::revision(); + if (SelectionStore::hasSelection() && + SelectionStore::origin() != SelectionStore::Origin::Tree) + reveal(SelectionStore::selected()); + } + advanceReveal(); if (root_) { root_->render(); } else { @@ -25,6 +35,78 @@ void NetlistTree::render() { } } +void NetlistTree::reveal(const std::string& pathKey) { + revealPath_ = splitPathKey(pathKey); +} + +void NetlistTree::advanceReveal() { + if (!revealPath_ || !root_) return; + NetlistTreeNode* node = root_; + std::vector toOpen; + for (const auto& name : *revealPath_) { + node->expand(); // an instance node's groups are created locally + std::vector groups; + for (auto* child : *node->children_) { + auto* g = dynamic_cast(child); + if (g && (g->getType() == NetlistTreeGroupNode::Type::Instances || + g->getType() == NetlistTreeGroupNode::Type::Primitives)) + groups.push_back(g); + } + NetlistTreeNode* next = nullptr; + NetlistTreeGroupNode* via = nullptr; + for (auto* g : groups) { + if (!g->children_) continue; + for (auto* c : *g->children_) { + auto* inst = dynamic_cast(c); + if (inst && inst->getName() == name) { next = inst; via = g; break; } + } + if (next) break; + } + if (!next) { + // Not among the loaded levels: load the ones still missing and wait. + bool waiting = false; + for (auto* g : groups) { + if (g->children_) continue; + if (!g->hasRequestedChildren_) { + g->hasRequestedChildren_ = true; + g->sendLoadRequest(); + } + waiting = true; + } + if (!waiting) { + Console::Error("Cannot reveal instance: no '" + name + "' under " + + (node->getPathKey().empty() ? node->getLabel() : node->getPathKey())); + revealPath_.reset(); + } + return; + } + toOpen.push_back(node); + toOpen.push_back(via); + node = next; + } + for (auto* n : toOpen) forceOpen_.insert(n->guiID_); + scrollTo_ = node->guiID_; + revealPath_.reset(); +} + +NetlistTreeNode* NetlistTree::findInstance(const std::string& pathKey) const { + NetlistTreeNode* node = root_; + for (const auto& name : splitPathKey(pathKey)) { + if (!node || !node->children_) return nullptr; + NetlistTreeNode* next = nullptr; + for (auto* group : *node->children_) { + if (!group->children_) continue; + for (auto* c : *group->children_) { + auto* inst = dynamic_cast(c); + if (inst && inst->getName() == name) { next = inst; break; } + } + if (next) break; + } + node = next; + } + return node; +} + void NetlistTree::insertNodeInMap(NetlistTreeNode* node) { node->guiID_ = nextGUIID_; nodes_[nextGUIID_++] = node; @@ -53,10 +135,17 @@ std::string NetlistTreeNode::getPathKey() const { void NetlistTreeNode::render() { expand(); - ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow; + NetlistTree* tree = getTree(); + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; if (isLeaf()) { flags = flags | ImGuiTreeNodeFlags_Leaf; } + if (isInstanceNode() && SelectionStore::isSelected(getPathKey())) { + flags = flags | ImGuiTreeNodeFlags_Selected; + } + if (tree->forceOpen_.erase(guiID_)) { + ImGui::SetNextItemOpen(true); + } if (getColor() != 0) { ImGui::PushStyleColor(ImGuiCol_Text, getColor()); } @@ -64,6 +153,14 @@ void NetlistTreeNode::render() { if (getColor() != 0) { ImGui::PopStyleColor(); } + if (tree->scrollTo_ == guiID_) { + ImGui::SetScrollHereY(0.5f); + tree->scrollTo_.reset(); + } + // Clicking an instance's label (not its open/close arrow) selects it. + if (isInstanceNode() && ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::IsItemToggledOpen()) { + SelectionStore::select(getPathKey(), SelectionStore::Origin::Tree); + } if (ImGui::IsItemHovered()) { auto diagnostics = getDiagnostics(); if (!diagnostics.empty()) { diff --git a/src/NetlistTree.h b/src/NetlistTree.h index 1ecf8a8..c5b597c 100644 --- a/src/NetlistTree.h +++ b/src/NetlistTree.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include "imgui.h" @@ -49,12 +51,33 @@ class NetlistTree { void render(); void insertNodeInMap(NetlistTreeNode* node); + + // Opens the tree down to the instance at `pathKey` (slash-joined + // instance names, "" = root) and scrolls to it. Levels load on demand, so + // this is asynchronous: each level's Instances/Primitives groups are + // requested as needed and advanceReveal() continues once their replies + // have created the child nodes. A new reveal replaces a pending one. + void reveal(const std::string& pathKey); + // One step of a pending reveal; called by render() every frame (public + // so tests can drive it without an ImGui frame). + void advanceReveal(); + bool isRevealPending() const { return revealPath_.has_value(); } + // The instance node at `pathKey`, if its level is already loaded. + NetlistTreeNode* findInstance(const std::string& pathKey) const; private: + friend class NetlistTreeNode; INetlistProvider* ws_ {nullptr}; std::function onEquipotentialRequest_; NetlistTreeNode* root_ {nullptr}; unsigned nextGUIID_ {0}; NodesMap nodes_; + // Pending reveal: the instance names still to walk down from the root. + std::optional> revealPath_; + // Nodes render() must open (once each) / scroll to, after a reveal. + std::set forceOpen_; + std::optional scrollTo_; + // Last SelectionStore revision render() reacted to. + unsigned seenSelectionRevision_ {0}; }; class NetlistTreeInstanceNode; @@ -211,6 +234,7 @@ class NetlistTreeInstanceNode : public NetlistTreeNode { virtual std::vector getDiagnostics() const override; virtual std::optional getSourceLoc() const override { return sourceLoc_; } virtual bool isInstanceNode() const override { return true; } + const std::string& getName() const { return name_; } private: bool isRoot_ {false}; std::string name_ {}; @@ -234,6 +258,7 @@ class NetlistTreeGroupNode : public NetlistTreeNode { }; NetlistTreeGroupNode(NetlistTreeNode* parent, Type type); + Type getType() const { return type_; } virtual std::string getLabel() const override; virtual void sendLoadRequest() const override; virtual bool isLeaf() const override { diff --git a/src/SchematicLayout.cpp b/src/SchematicLayout.cpp index 5e296db..a48bbdf 100644 --- a/src/SchematicLayout.cpp +++ b/src/SchematicLayout.cpp @@ -124,6 +124,12 @@ void IncrementalLayout::place(const Equipotential* eq) { } } +void IncrementalLayout::placeAlone(const std::string& key) { + if (placed_.count(key)) return; + placed_.emplace(key, ImVec2{kLeftMargin, nextY_}); + nextY_ += kInstH + kRowSpacing + kNetVGap; +} + void IncrementalLayout::resolveColumnOverlaps(std::vector& instances) { std::map> byColumn; for (auto& inst : instances) diff --git a/src/SchematicLayout.h b/src/SchematicLayout.h index 586535f..d0aace1 100644 --- a/src/SchematicLayout.h +++ b/src/SchematicLayout.h @@ -77,6 +77,10 @@ class IncrementalLayout { public: // No-op if `eq` was already placed. void place(const Equipotential* eq); + // Places one instance on its own (a starting point with no net shown + // yet) in the left column below everything placed so far. No-op if + // `key` is already placed. + void placeAlone(const std::string& key); // Push boxes down within each column (same x) so none overlaps the one // above it -- place() reserves a fixed kInstH slot per instance, but a box diff --git a/src/SchematicView.cpp b/src/SchematicView.cpp index 74426dd..f636499 100644 --- a/src/SchematicView.cpp +++ b/src/SchematicView.cpp @@ -77,6 +77,13 @@ constexpr ImU32 kBoundaryPortLineColor = IM_COL32(28, 110, 60, 255); // red/amber/blue-ish severities by being brighter and used only transiently. constexpr ImU32 kPinHoverColor = IM_COL32(20, 110, 235, 255); constexpr ImU32 kPinPendingColor = IM_COL32(150, 90, 220, 255); +// The selected instance (SelectionStore) gets an outline just outside its +// box, in the same interaction blue as a hovered pin. +constexpr ImU32 kSelectionColor = IM_COL32(20, 110, 235, 255); +void addSelectionOutline(ImDrawList* dl, ImVec2 rmin, ImVec2 rmax) { + dl->AddRect(ImVec2(rmin.x - 4.0f, rmin.y - 4.0f), ImVec2(rmax.x + 4.0f, rmax.y + 4.0f), + kSelectionColor, 2.0f, 0, 2.5f); +} // Open-pin stub circle (see Port::open), world-space "1x zoom" radius. constexpr float kOpenPinBaseR = 3.5f; constexpr float kMinOpenPinR = 2.0f; @@ -293,6 +300,7 @@ static void drawGenericInstance(ImDrawList* dl, const InstanceShape& inst, // the partialInterface dashed border above. if (inst.diagOutline != 0) dl->AddRect(rmin, rmax, inst.diagOutline, 0.0f, 0, 3.5f); + if (inst.selected) addSelectionOutline(dl, rmin, rmax); float instFontSize = labelFontSize(kInstanceLabelBaseSize, sv.transform.scale); const std::string& shown = inst.label.empty() ? inst.name : inst.label; @@ -358,6 +366,11 @@ static void drawAssignInstance(ImDrawList* dl, const InstanceShape& inst, dl->AddTriangle(tl, bl, mr, kInstanceLineColor, 1.25f); if (inst.diagOutline != 0) dl->AddTriangle(tl, bl, mr, inst.diagOutline, 3.0f); + if (inst.selected) { + ImVec2 rmin, rmax; + sv.worldRectToScreen(inst.x, inst.y, inst.w, inst.h, canvasPos, canvasSize, rmin, rmax); + addSelectionOutline(dl, rmin, rmax); + } // Label ("assign") near top-left of the bounding box, small and subtle ImVec2 lblPos = ImVec2(tl.x + 4.0f, tl.y + 4.0f); @@ -450,6 +463,7 @@ static void drawHierGroupInstance(ImDrawList* dl, const InstanceShape& inst, dl->AddRect(rmin, rmax, IM_COL32(70, 80, 115, 220), 0.0f, 0, 1.25f); if (inst.diagOutline != 0) dl->AddRect(rmin, rmax, inst.diagOutline, 0.0f, 0, 3.5f); + if (inst.selected) addSelectionOutline(dl, rmin, rmax); float fontSize = labelFontSize(kInstanceLabelBaseSize, sv.transform.scale); if (!inst.name.empty() && fontSize > 0.0f) { diff --git a/src/SelectionStore.cpp b/src/SelectionStore.cpp new file mode 100644 index 0000000..cf5b775 --- /dev/null +++ b/src/SelectionStore.cpp @@ -0,0 +1,37 @@ +#include "SelectionStore.h" + +namespace { +std::string g_selected; +bool g_hasSelection = false; +SelectionStore::Origin g_origin = SelectionStore::Origin::Tree; +unsigned g_revision = 0; +std::function g_listener; +} // namespace + +void SelectionStore::select(const std::string& pathKey, Origin origin) { + if (g_hasSelection && g_selected == pathKey) return; + g_selected = pathKey; + g_hasSelection = true; + g_origin = origin; + ++g_revision; + if (g_listener) g_listener(pathKey); +} + +void SelectionStore::clear() { + if (!g_hasSelection) return; + g_selected.clear(); + g_hasSelection = false; + ++g_revision; +} + +bool SelectionStore::hasSelection() { return g_hasSelection; } +const std::string& SelectionStore::selected() { return g_selected; } +bool SelectionStore::isSelected(const std::string& pathKey) { + return g_hasSelection && g_selected == pathKey; +} +SelectionStore::Origin SelectionStore::origin() { return g_origin; } +unsigned SelectionStore::revision() { return g_revision; } + +void SelectionStore::setListener(std::function listener) { + g_listener = std::move(listener); +} diff --git a/src/SelectionStore.h b/src/SelectionStore.h new file mode 100644 index 0000000..e5e6660 --- /dev/null +++ b/src/SelectionStore.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +// The one selected instance, by instance-name pathKey ("" = the top design; +// same convention as DiagnosisItem::pathKey()) -- same static/global state +// pattern as PropertiesStore/DiagnosisStore. Set by a click on an instance in +// the tree or the schematic, or by a host's focus_instance push; drawn +// highlighted in both, and reported to the host (instance_selected) through +// the change listener AppLogic installs. +class SelectionStore { + public: + enum class Origin { Tree, Schematic, Host }; + + // Selects `pathKey` and notifies the listener. Selecting what is already + // selected does nothing (no notification). + static void select(const std::string& pathKey, Origin origin); + // Drops the selection without notifying (a new design was loaded). + static void clear(); + + static bool hasSelection(); + static const std::string& selected(); + static bool isSelected(const std::string& pathKey); + static Origin origin(); + // Bumped on every change, so a view can react once to a new selection + // (e.g. the tree revealing one made in the schematic). + static unsigned revision(); + + static void setListener(std::function listener); +}; diff --git a/src/Types.h b/src/Types.h index daec2a2..7721672 100644 --- a/src/Types.h +++ b/src/Types.h @@ -296,6 +296,9 @@ struct InstanceShape { // Nesting depth of a hierarchy group frame (1 = directly under the top // design), used to shade nested frames progressively. int hierDepth = 0; + // The instance (or module frame) selected in the viewer -- see + // SelectionStore. Drawn with a selection outline. + bool selected = false; }; struct NetWire { diff --git a/tests/EquipotentialViewTest.cpp b/tests/EquipotentialViewTest.cpp index cb4ded8..d71bfbd 100644 --- a/tests/EquipotentialViewTest.cpp +++ b/tests/EquipotentialViewTest.cpp @@ -4,6 +4,7 @@ // through a FakeNetlistProvider and on the geometry it builds. #include "EquipotentialView.h" #include "SchematicView.h" +#include "SelectionStore.h" #include #include @@ -37,11 +38,13 @@ class SchematicClicks : public ::testing::Test { io.Fonts->GetTexDataAsRGBA32(&px, &w, &h); EquipotentialView::setProvider(&provider); EquipotentialView::resetLayout(); + SelectionStore::clear(); } void TearDown() override { EquipotentialView::setProvider(nullptr); EquipotentialView::resetLayout(); + SelectionStore::clear(); for (auto* eq : eqs) delete eq; ImGui::DestroyContext(); } @@ -294,3 +297,107 @@ TEST_F(SchematicClicks, DoubleClickOnHierarchyGlyphTogglesItOnce) { EXPECT_TRUE(shape("u2")->hierExpanded); EXPECT_EQ(sent("load_instance_internals").size(), 1u); } + +// --------------------------------------------------------------------------- +// Starting the view from one instance (a host's focus_instance) +// --------------------------------------------------------------------------- + +namespace { + +// An instance_resolved reply as protocol.py / LocalSNLProvider build it, for +// top/core/u7 (an AND2 with pins A, B, Y). +nlohmann::json resolvedU7() { + return nlohmann::json::parse(R"({ + "response": "instance_resolved", "path": ["core", "u7"], "found": true, + "instance": { + "path": [["core", 5, "Core"], ["u7", 7, "AND2"]], + "design_ref": {"db_id": 1, "library_id": 2, "design_id": 3}, + "has_instances": false, "source_loc": null, + "terms": [{"name": "A", "child_id": 0, "direction": 0}, + {"name": "B", "child_id": 1, "direction": 0}, + {"name": "Y", "child_id": 2, "direction": 1}] + }})"); +} + +} // namespace + +TEST(StartInstance, ParsedFromAnInstanceResolvedReply) { + auto start = EquipotentialView::startInstanceFromResolved(resolvedU7()); + ASSERT_TRUE(start.has_value()); + EXPECT_EQ(start->path, (std::vector{"core", "u7"})); + EXPECT_EQ(start->pathIds, (std::vector{5, 7})); + EXPECT_EQ(start->pathModels, (std::vector{"Core", "AND2"})); + EXPECT_EQ(start->designRef.design_id, 3u); + ASSERT_EQ(start->ports.size(), 3u); + EXPECT_EQ(start->ports[2].name, "Y"); + EXPECT_EQ(start->ports[2].direction, Direction::Output); + + auto notFound = nlohmann::json::parse(R"({"response":"instance_resolved","path":["x"],"found":false})"); + EXPECT_FALSE(EquipotentialView::startInstanceFromResolved(notFound).has_value()); + auto top = nlohmann::json::parse(R"({"response":"instance_resolved","path":[],"found":true})"); + EXPECT_FALSE(EquipotentialView::startInstanceFromResolved(top).has_value()); +} + +TEST_F(SchematicClicks, StartInstanceIsDrawnAloneWithAllPinsOpen) { + EquipotentialView::showInstance(*EquipotentialView::startInstanceFromResolved(resolvedU7())); + frame(3); + + const InstanceShape* u7 = shape("core/u7"); + ASSERT_NE(u7, nullptr); + EXPECT_FALSE(u7->partialInterface); // its whole interface is shown + for (auto name : {"A", "B", "Y"}) { + ASSERT_NE(pin("core/u7", name), nullptr) << name; + EXPECT_TRUE(pin("core/u7", name)->open) << name; + } + // Drawn inside its module's frame, like any traced leaf. + bool framed = false; + for (const auto& s : sv().instances) framed = framed || (s.isHierGroup && s.name == "core (Core)"); + EXPECT_TRUE(framed); +} + +TEST_F(SchematicClicks, ClickingAStartInstancePinLoadsItsNetWithTheInstancePath) { + EquipotentialView::showInstance(*EquipotentialView::startInstanceFromResolved(resolvedU7())); + frame(3); + moveTo(pinScreen("core/u7", "Y")); + click(); + + auto reqs = sent("load_equipotential"); + ASSERT_EQ(reqs.size(), 1u); + EXPECT_EQ(reqs[0]["path"], nlohmann::json::array({5, 7})); + EXPECT_EQ(reqs[0]["term_id"], 2); +} + +TEST_F(SchematicClicks, ResetLayoutDropsTheStartInstance) { + EquipotentialView::showInstance(*EquipotentialView::startInstanceFromResolved(resolvedU7())); + frame(2); + ASSERT_NE(shape("core/u7"), nullptr); + EquipotentialView::resetLayout(); + frame(2); + EXPECT_EQ(shape("core/u7"), nullptr); +} + +TEST_F(SchematicClicks, ClickingABoxSelectsItAndDrawsItSelected) { + showFirstNet(); + moveTo(boxCenterScreen("u2")); + click(); + EXPECT_TRUE(SelectionStore::isSelected("u2")); + EXPECT_EQ(SelectionStore::origin(), SelectionStore::Origin::Schematic); + frame(); + EXPECT_TRUE(shape("u2")->selected); + EXPECT_FALSE(shape("u1")->selected); +} + +TEST_F(SchematicClicks, ClickingAPinDoesNotChangeTheSelection) { + showFirstNet(); + expandU2(); + moveTo(pinScreen("u2", "B")); + click(); + EXPECT_FALSE(SelectionStore::hasSelection()); +} + +TEST_F(SchematicClicks, ASelectionMadeElsewhereIsDrawnInTheSchematic) { + showFirstNet(); + SelectionStore::select("u1", SelectionStore::Origin::Host); + frame(); + EXPECT_TRUE(shape("u1")->selected); +} diff --git a/tests/SelectionTest.cpp b/tests/SelectionTest.cpp new file mode 100644 index 0000000..afff92a --- /dev/null +++ b/tests/SelectionTest.cpp @@ -0,0 +1,217 @@ +#include "NetlistTree.h" +#include "SelectionStore.h" + +#include +#include +#include + +#include "FakeNetlistProvider.h" + +// --------------------------------------------------------------------------- +// SelectionStore +// --------------------------------------------------------------------------- + +class SelectionStoreTest : public ::testing::Test { + protected: + void SetUp() override { + SelectionStore::clear(); + SelectionStore::setListener([this](const std::string& k) { notified.push_back(k); }); + } + void TearDown() override { + SelectionStore::setListener(nullptr); + SelectionStore::clear(); + } + std::vector notified; +}; + +TEST_F(SelectionStoreTest, SelectNotifiesOncePerChange) { + EXPECT_FALSE(SelectionStore::hasSelection()); + const unsigned rev = SelectionStore::revision(); + + SelectionStore::select("u1/u2", SelectionStore::Origin::Schematic); + EXPECT_TRUE(SelectionStore::isSelected("u1/u2")); + EXPECT_FALSE(SelectionStore::isSelected("u1")); + EXPECT_EQ(SelectionStore::origin(), SelectionStore::Origin::Schematic); + EXPECT_EQ(SelectionStore::revision(), rev + 1); + + SelectionStore::select("u1/u2", SelectionStore::Origin::Tree); // same: no-op + EXPECT_EQ(SelectionStore::revision(), rev + 1); + EXPECT_EQ(notified, std::vector{"u1/u2"}); +} + +TEST_F(SelectionStoreTest, TopDesignIsSelectable) { + SelectionStore::select("", SelectionStore::Origin::Host); + EXPECT_TRUE(SelectionStore::hasSelection()); + EXPECT_TRUE(SelectionStore::isSelected("")); + EXPECT_EQ(notified, std::vector{""}); +} + +TEST_F(SelectionStoreTest, ClearDoesNotNotify) { + SelectionStore::select("u1", SelectionStore::Origin::Tree); + SelectionStore::clear(); + EXPECT_FALSE(SelectionStore::hasSelection()); + EXPECT_FALSE(SelectionStore::isSelected("u1")); + EXPECT_EQ(notified.size(), 1u); +} + +// --------------------------------------------------------------------------- +// NetlistTree::reveal -- driven by the load requests it actually sends, the +// replies simulated the way AppLogic handles instances/primitives responses. +// --------------------------------------------------------------------------- + +namespace { + +DesignRef design(int id) { return DesignRef{1, 1, id}; } + +class TreeReveal : public ::testing::Test { + protected: + void SetUp() override { + tree.createRootNode("top", design(0), /*hasTerms=*/true, /*hasPrimitives=*/true, + /*hasInstances=*/true, /*hasNets=*/false); + } + + // Requests of `type` sent since the last call, as parsed JSON. + std::vector take(const std::string& type) { + std::vector out, rest; + for (const auto& s : provider.sent) { + auto j = nlohmann::json::parse(s); + (j.value("request", "") == type ? out : rest).push_back(j); + } + provider.sent.clear(); + for (const auto& j : rest) provider.sent.push_back(j.dump()); + return out; + } + + struct Child { std::string name; int id; bool hierarchical; }; + // Answers a load request for group node `guiId` with `children`. + void answer(unsigned guiId, const std::vector& children) { + NetlistTreeNode* group = tree.getNode(guiId); + ASSERT_NE(group, nullptr); + group->createChildren(); + for (const auto& c : children) + group->createInstanceNode(c.name, "M_" + c.name, c.id, design(c.id), + /*hasTerms=*/true, /*hasPrimitives=*/c.hierarchical, + /*hasInstances=*/c.hierarchical, /*hasNets=*/false); + } + + FakeNetlistProvider provider; + NetlistTree tree{&provider}; +}; + +} // namespace + +TEST_F(TreeReveal, WalksDownLoadingOnlyWhatIsNeeded) { + tree.reveal("u1/u2"); + tree.advanceReveal(); + EXPECT_TRUE(tree.isRevealPending()); + + // Level 1 isn't loaded: both instance groups of the root are requested + // (u1 could be hierarchical or a primitive), but no terms/nets. + auto inst = take("load_instances"); + auto prim = take("load_primitives"); + ASSERT_EQ(inst.size(), 1u); + ASSERT_EQ(prim.size(), 1u); + EXPECT_TRUE(provider.sent.empty()); + + // Waiting for replies: advancing again doesn't re-send. + tree.advanceReveal(); + EXPECT_TRUE(provider.sent.empty()); + + answer(inst[0]["gui_id"], {{"u0", 10, true}, {"u1", 11, true}}); + answer(prim[0]["gui_id"], {{"r", 12, false}}); + tree.advanceReveal(); + EXPECT_TRUE(tree.isRevealPending()); + inst = take("load_instances"); + prim = take("load_primitives"); + ASSERT_EQ(inst.size(), 1u); + ASSERT_EQ(prim.size(), 1u); + // Requests for u1's own groups, addressed by u1's model. + EXPECT_EQ(inst[0]["design_ref"]["design_id"], 11); + + answer(inst[0]["gui_id"], {}); + answer(prim[0]["gui_id"], {{"u2", 20, false}}); + tree.advanceReveal(); + EXPECT_FALSE(tree.isRevealPending()); + NetlistTreeNode* u2 = tree.findInstance("u1/u2"); + ASSERT_NE(u2, nullptr); + EXPECT_EQ(u2->getPathKey(), "u1/u2"); +} + +TEST_F(TreeReveal, AlreadyLoadedLevelsNeedNoRequest) { + tree.reveal("u1"); + tree.advanceReveal(); + auto inst = take("load_instances"); + auto prim = take("load_primitives"); + answer(inst[0]["gui_id"], {{"u1", 11, true}}); + answer(prim[0]["gui_id"], {}); + tree.advanceReveal(); + ASSERT_FALSE(tree.isRevealPending()); + + tree.reveal("u1"); // again: everything is there + tree.advanceReveal(); + EXPECT_FALSE(tree.isRevealPending()); + EXPECT_TRUE(provider.sent.empty()); +} + +TEST_F(TreeReveal, FindsAnInstanceInTheFirstLoadedGroupWithoutWaitingForOthers) { + tree.reveal("u1"); + tree.advanceReveal(); + auto inst = take("load_instances"); + take("load_primitives"); // never answered + answer(inst[0]["gui_id"], {{"u1", 11, true}}); + tree.advanceReveal(); + EXPECT_FALSE(tree.isRevealPending()); + EXPECT_NE(tree.findInstance("u1"), nullptr); +} + +TEST_F(TreeReveal, UnknownNameStopsTheReveal) { + tree.reveal("nope"); + tree.advanceReveal(); + answer(take("load_instances")[0]["gui_id"], {{"u1", 11, true}}); + answer(take("load_primitives")[0]["gui_id"], {}); + tree.advanceReveal(); + EXPECT_FALSE(tree.isRevealPending()); + EXPECT_EQ(tree.findInstance("nope"), nullptr); +} + +TEST_F(TreeReveal, RootIsRevealedImmediately) { + tree.reveal(""); + tree.advanceReveal(); + EXPECT_FALSE(tree.isRevealPending()); + EXPECT_EQ(tree.findInstance(""), tree.getRoot()); +} + +// A selection made outside the tree (schematic click, host focus) makes the +// tree reveal it on its next render; one made in the tree doesn't. +TEST_F(TreeReveal, RevealsASelectionMadeElsewhereOnRender) { + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(800.f, 600.f); + io.DeltaTime = 1.f / 60.f; + io.IniFilename = nullptr; + unsigned char* px; int w, h; + io.Fonts->GetTexDataAsRGBA32(&px, &w, &h); + auto frame = [&] { + ImGui::NewFrame(); + ImGui::Begin("Tree"); + tree.render(); + ImGui::End(); + ImGui::Render(); + }; + + SelectionStore::clear(); + frame(); + provider.sent.clear(); + + SelectionStore::select("u1", SelectionStore::Origin::Tree); + frame(); + EXPECT_FALSE(tree.isRevealPending()); + + SelectionStore::select("u9", SelectionStore::Origin::Schematic); + frame(); + EXPECT_TRUE(tree.isRevealPending()); + EXPECT_FALSE(take("load_instances").empty()); + + SelectionStore::clear(); + ImGui::DestroyContext(); +}