diff --git a/CLAUDE.md b/CLAUDE.md index fee7b23..558a83d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,6 +287,20 @@ to Driver"; both clear the view first, like "Show Equipotential") and from the schematic (right-click a pin -> "Trace to Driver"; this one *adds* to the view instead of clearing it). +Extending the schematic by hand works pin by pin. A box drawn with a dashed +border shows only the pins on nets already in view; double-clicking its body +sends `expand_instance_terms` and reveals the rest. Pins whose net isn't in +the view yet are "open" (`Port::open`, drawn with a hollow-circle stub), and a +**single click** on one sends `load_equipotential` for its net (the stub fills +while it loads; `SchematicInteraction::PendingRequests` stops repeat sends). +A single click on a merged bus pin shows its bits instead. Hovering a pin +highlights it and shows a tooltip naming its click action. When the requested +net arrives the view pans so the clicked box stays put on screen, even if the +hierarchy frames re-lay everything out. Pins are hit-tested nearest-first by +`SchematicInteraction::pickPin`. `GUIData::addEquipotential()` rejects a net +whose endpoints are all already shown (`equipotentialCovers()`), so neither a +re-click nor a trace re-listing a shown net draws its wires twice. + Occurrence `path` entries (in `equipotential_response` and each `trace_driver_response` net) are `[name, child_id, model_name]`; the third element is optional on parse (`InstTermOccurrence::pathModels`, `""` when @@ -406,6 +420,17 @@ appear as boxes/pins there). instance placement and column de-overlap) and `layoutHierarchyGroups()` (module frames). Changes to how the schematic is laid out belong here, with a test; `EquipotentialView` only turns the result into drawn shapes. +- **`SchematicInteraction`** — the pure side of the schematic's pin + interactions: `pickPin()` (nearest-pin hit test, radius capped in world + units so a pin doesn't swallow box-body clicks at low zoom) and + `PendingRequests` (in-flight pin requests, which expire after 5 s). + `tests/SchematicInteractionTest.cpp` tests them. The click flow itself is + covered end to end by `tests/EquipotentialViewTest.cpp`. That test drives + the real `EquipotentialView::renderSchematic()` in a headless ImGui context + with no render backend: it moves the mouse and clicks through the ImGui IO + API, then asserts on what `FakeNetlistProvider` received and on the + geometry, read through `EquipotentialView::schematicForTesting()`. Use the + same pattern for new canvas interactions. ### Diagnosis overlay diff --git a/CMakeLists.txt b/CMakeLists.txt index d0b1868..03181bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,6 +75,7 @@ set(CORE_SOURCES src/NetlistTree.cpp src/EquipotentialView.cpp src/SchematicLayout.cpp + src/SchematicInteraction.cpp src/SchematicView.cpp src/DiagnosisStore.cpp src/DiagnosisView.cpp @@ -209,8 +210,9 @@ endif() # Native only: exercises the logic layer (JSON parsing, DiagnosisStore, # SourceStore, NetlistTree structure, SchematicLayout geometry) without SDL/OpenGL/naja. Links only # ImGui's core (imgui.cpp/imgui_draw.cpp/imgui_tables.cpp/imgui_widgets.cpp), -# not the SDL2/OpenGL3 backends, since none of the tested code needs a real -# render backend -- render()/draw calls are never invoked from tests. +# not the SDL2/OpenGL3 backends: tests that do run UI code (e.g. +# EquipotentialViewTest) drive a headless ImGui context with simulated input +# and never need anything actually drawn to a screen. if(NOT EMSCRIPTEN_BUILD) option(NAJA_SCHEMATIC_BUILD_TESTS "Build naja-schematic-tests" ON) if(NAJA_SCHEMATIC_BUILD_TESTS) @@ -235,6 +237,9 @@ if(NOT EMSCRIPTEN_BUILD) src/SourceStore.cpp src/PropertiesStore.cpp src/SchematicLayout.cpp + src/SchematicInteraction.cpp + src/EquipotentialView.cpp + src/SchematicView.cpp ${IMGUI_DIR}/imgui.cpp ${IMGUI_DIR}/imgui_draw.cpp ${IMGUI_DIR}/imgui_tables.cpp diff --git a/REUSE.toml b/REUSE.toml index cbbd01e..42835c6 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -67,6 +67,8 @@ path = [ "src/PropertiesView.cpp", "src/PropertiesView.h", "src/RenderTypes.h", + "src/SchematicInteraction.cpp", + "src/SchematicInteraction.h", "src/SchematicLayout.cpp", "src/SchematicLayout.h", "src/SchematicView.cpp", @@ -85,8 +87,11 @@ path = [ "src/main_native.cpp", "src/main_wasm.cpp", "tests/DiagnosisStoreTest.cpp", + "tests/EquipotentialViewTest.cpp", "tests/FakeNetlistProvider.h", + "tests/GUIDataTest.cpp", "tests/NetlistTreeTest.cpp", + "tests/SchematicInteractionTest.cpp", "tests/SchematicLayoutTest.cpp", "tests/SourceStoreTest.cpp", "tests/TypesJsonTest.cpp", diff --git a/src/AppLogic.cpp b/src/AppLogic.cpp index 0589cec..99de862 100644 --- a/src/AppLogic.cpp +++ b/src/AppLogic.cpp @@ -189,22 +189,28 @@ void setupProvider(AppState& state) { parent->createNetNode(net.name, net.msb, net.lsb); } } else if (resp == "equipotential_response") { - Console::Log("Equipotential data received"); - state.guiData->addEquipotential(new Equipotential(j.get())); - state.tableEquipotentialCount = 1; + if (state.guiData->addEquipotential(new Equipotential(j.get()))) { + Console::Log("Equipotential data received"); + state.tableEquipotentialCount = 1; + } else { + Console::Log("Equipotential already shown"); + } } else if (resp == "trace_driver_response") { // Nets arrive breadth-first from the traced net toward the drivers, so // adding them in order lets the layout chain each one off an instance // that's already placed. - size_t n = 0; + // Nets already on screen (e.g. the one the trace started from, when + // it's launched from a schematic pin) are skipped rather than drawn twice. + size_t n = 0, added = 0; if (j.contains("equipotentials") && j["equipotentials"].is_array()) { for (const auto& e : j["equipotentials"]) { - state.guiData->addEquipotential(new Equipotential(e.get())); ++n; + if (state.guiData->addEquipotential(new Equipotential(e.get()))) ++added; } } - state.tableEquipotentialCount = std::max(n, 1); - Console::Log("Driver trace received: " + std::to_string(n) + " net(s)" + + state.tableEquipotentialCount = std::max(added, 1); + Console::Log("Driver trace received: " + std::to_string(n) + " net(s), " + + std::to_string(added) + " new" + (j.value("truncated", false) ? " (truncated)" : "")); } else if (resp == "expanded_instance_terms") { std::string pathKey = j.value("path_key", std::string("")); diff --git a/src/EquipotentialView.cpp b/src/EquipotentialView.cpp index 4a5d770..135cb02 100644 --- a/src/EquipotentialView.cpp +++ b/src/EquipotentialView.cpp @@ -14,6 +14,7 @@ #include "INetlistProvider.h" #include "DiagnosisStore.h" #include "SchematicLayout.h" +#include "SchematicInteraction.h" // Net/instance placement and hierarchy frames live in SchematicLayout // (pure geometry, unit-tested); this file turns them into drawn shapes. @@ -24,15 +25,6 @@ using namespace SchematicLayout; // --------------------------------------------------------------------------- static constexpr float kPortSpacing = 18.0f; -// Pin click/hit-test radius, in screen pixels (converted to world units by -// dividing by the current zoom scale where it's used). A pin now renders as -// a short tick line (see SchematicView.cpp's drawPorts) reaching a few -// pixels out from the port's anchor point rather than a filled dot sitting -// right on it, so the hit area needs to comfortably cover the whole tick -// (and the start of its label) -- not just the anchor -- or double-click/ -// right-click on a pin becomes very hard to land. -static constexpr float kPortHitRadiusPx = 18.0f; - // Hierarchy embedding (nested boxes) geometry. static constexpr float kHierChildW = 110.0f; static constexpr float kHierChildH = 44.0f; @@ -47,10 +39,24 @@ static SchematicView g_schematic; static int g_pendingZoomSteps = 0; static bool g_pendingFit = false; static bool g_pendingClear = false; -// Set when a pin double-click requests a net: the view it extends keeps the +// Set when a pin click requests a net: the view it extends keeps the // user's current pan/zoom instead of re-fitting to the whole drawing once // the net arrives. Consumed by the next item-count change. static bool g_skipNextAutoFit = false; +// The instance whose pin was clicked, and where it was: once the net arrives +// the view is panned so that box stays put on screen, even though adding a +// net can re-lay out everything (hierarchy frames grow and shift). +static std::string g_keepInPlaceKey; +static ImVec2 g_keepInPlaceWorld{}; +// Pin nets requested by a click and not arrived yet (keyed by +// SchematicInteraction::pinRequestKey), so a second click doesn't re-send. +static SchematicInteraction::PendingRequests g_pendingPinNets; +// Instance box whose "double-click to show all pins" hint is due, and since +// when the cursor has rested on it. +static int g_hintShapeId = -1; +static double g_hintSince = 0.0; +// Screen position of the canvas's top-left corner, last frame. +static ImVec2 g_canvasOrigin{}; static INetlistProvider* g_provider = nullptr; // Which instance box (if any) the canvas right-click popup currently // targets; -1 = the canvas-level menu (Clear all nets / Fit view). @@ -67,12 +73,12 @@ struct PortEquiRequest { }; static std::map g_occInfoByShapeId; static std::map g_portEquiByPortId; -// Merged bus-pin port id -> the bus-group key it expands on double-click. +// Merged bus-pin port id -> the bus-group key it expands on click. // Checked before g_portEquiByPortId. Rebuilt every frame. static std::map g_busGroupByPortId; // Bit port id of a bus shown expanded -> its bus-group key, for the pin -// context menu's "Collapse Bus". Double-clicking these bits loads their net -// like any scalar pin. Rebuilt every frame. +// context menu's "Collapse Bus". Clicking an open bit loads its net like any +// scalar pin. Rebuilt every frame. static std::map g_expandedBusGroupByPortId; static std::map> g_expandedInstances; @@ -161,6 +167,62 @@ static ImVec2 mouseWorldPos(const SchematicView& sv, const ImVec2& cpos) { (m.y - cpos.y) / s + sv.transform.offset.y - sv.transform.screenOrigin.y / s); } +// --------------------------------------------------------------------------- +// Pin interactions. A left click on a pin does the one thing that pin +// offers -- the same thing its hover tooltip announces: +// - merged bus pin -> show its individual bits (no request needed) +// - open pin -> add its net to the view (load_equipotential) +// A pin whose net is already shown has no click action (its right-click menu +// still offers Trace to Driver). Pins are hit-tested nearest-first with +// SchematicInteraction::pickPin against what was drawn last frame. +// --------------------------------------------------------------------------- +enum class PinAction { None, ExpandBus, LoadNet, Loading }; + +static PinAction pinAction(const Port& p) { + if (g_busGroupByPortId.count(p.id)) return PinAction::ExpandBus; + if (p.open) return p.pending ? PinAction::Loading : PinAction::LoadNet; + return PinAction::None; +} + +static std::string pinRequestKey(const PortEquiRequest& r) { + return SchematicInteraction::pinRequestKey(r.pathIds, r.termId, r.bit); +} + +// Sends load_equipotential for `port` (sitting on `inst`) unless it's already +// in flight, and arranges for `inst` to stay where it is on screen when the +// net arrives. +static void requestPinNet(const InstanceShape& inst, const Port& port) { + auto it = g_portEquiByPortId.find(port.id); + if (!g_provider || it == g_portEquiByPortId.end()) return; + if (!g_pendingPinNets.begin(pinRequestKey(it->second), ImGui::GetTime())) return; + json j; + j["request"] = "load_equipotential"; + j["path"] = it->second.pathIds; + j["term_id"] = it->second.termId; + if (it->second.bit.has_value()) j["bit"] = it->second.bit.value(); + g_provider->send(j.dump()); + g_skipNextAutoFit = true; + g_keepInPlaceKey = inst.name; + g_keepInPlaceWorld = ImVec2(inst.x, inst.y); +} + +// Runs the click action of the pin under `wp`, if any. Returns true when a +// pin was hit (even one with no action), so the click isn't also treated as +// a click on the box body underneath. +static bool clickPin(const ImVec2& wp) { + auto hit = SchematicInteraction::pickPin(g_schematic.instances, wp, g_schematic.transform.scale); + if (!hit) return false; + InstanceShape* inst = g_schematic.findInstanceById(hit->instanceId); + Port* port = inst ? g_schematic.findPortById(*inst, hit->portId) : nullptr; + if (!port) return true; + switch (pinAction(*port)) { + case PinAction::ExpandBus: g_expandedBuses.insert(g_busGroupByPortId[port->id]); break; + case PinAction::LoadNet: requestPinNet(*inst, *port); break; + default: break; + } + return true; +} + // --------------------------------------------------------------------------- // Hierarchy embedding: lay out and wire one expanded instance's internals // (its direct sub-instances plus the nets connecting them) nested inside its @@ -320,6 +382,8 @@ void EquipotentialView::resetLayout() { g_hierPending.clear(); g_instanceInternals.clear(); g_skipNextAutoFit = false; + g_keepInPlaceKey.clear(); + g_pendingPinNets.clear(); g_pendingFit = true; } @@ -334,6 +398,9 @@ void EquipotentialView::setProvider(INetlistProvider* p) { g_provider = p; } bool EquipotentialView::showHierarchy() { return g_showHierarchy; } +const SchematicView& EquipotentialView::schematicForTesting() { return g_schematic; } +ImVec2 EquipotentialView::canvasOriginForTesting() { return g_canvasOrigin; } + void EquipotentialView::setShowHierarchy(bool on) { if (g_showHierarchy == on) return; g_showHierarchy = on; @@ -392,6 +459,7 @@ void EquipotentialView::renderSchematic(const std::vector& equip ImGuiButtonFlags_MouseButtonMiddle); ImDrawList* dl = ImGui::GetWindowDrawList(); ImVec2 cpos = ImGui::GetItemRectMin(); + g_canvasOrigin = cpos; g_schematic.handleInteraction(cpos, inner); @@ -410,19 +478,8 @@ void EquipotentialView::renderSchematic(const std::vector& equip ImVec2 wp = mouseWorldPos(g_schematic, cpos); g_ctxInstanceId = -1; g_ctxPortId = -1; - { - // Same hit radius as the double-click pin test further down. - const float sc = std::max(0.01f, g_schematic.transform.scale); - const float hr2 = (kPortHitRadiusPx / sc) * (kPortHitRadiusPx / sc); - for (const auto& inst : g_schematic.instances) { - for (const auto& port : inst.ports) { - ImVec2 pw = g_schematic.portWorldPos(inst, port); - float dx = wp.x - pw.x, dy = wp.y - pw.y; - if (dx*dx + dy*dy <= hr2 && g_portEquiByPortId.count(port.id)) - g_ctxPortId = port.id; - } - } - } + if (auto hit = SchematicInteraction::pickPin(g_schematic.instances, wp, g_schematic.transform.scale)) + if (g_portEquiByPortId.count(hit->portId)) g_ctxPortId = hit->portId; 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; @@ -490,18 +547,23 @@ void EquipotentialView::renderSchematic(const std::vector& equip ImGui::EndPopup(); } - // Single click on an instance's hierarchy glyph: expand/collapse its - // sub-instances nested inside its box. Checked before the double-click - // handling below since it targets a small, distinct hotspot (top-center - // of the box) that never overlaps a port dot or the body double-click - // area used for expand_instance_terms. - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + // Single click: an instance's hierarchy glyph expands/collapses its + // sub-instances nested inside its box; otherwise a pin runs its click + // action (see clickPin). The glyph is checked first since it's a small, + // distinct hotspot (top-center of the box) that never overlaps a pin. + // Only the first click of a double-click counts, so a double-click + // doesn't act twice (e.g. expand a bus, then load the bit that lands + // under the cursor). + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetMouseClickedCount(ImGuiMouseButton_Left) == 1) { ImVec2 wp = mouseWorldPos(g_schematic, cpos); + bool glyphHit = false; for (const auto& inst : g_schematic.instances) { if (!canShowHierToggle(inst)) continue; float gx0, gy0, gx1, gy1; hierToggleGlyphRect(inst, gx0, gy0, gx1, gy1); if (wp.x < gx0 || wp.x > gx1 || wp.y < gy0 || wp.y > gy1) continue; + glyphHit = true; if (!g_hierExpanded.erase(inst.name)) { g_hierExpanded.insert(inst.name); if (!g_instanceInternals.count(inst.name) && !g_hierPending.count(inst.name) && g_provider) { @@ -520,47 +582,18 @@ void EquipotentialView::renderSchematic(const std::vector& equip } break; } + if (!glyphHit) clickPin(wp); } - // Double-click: port dot → load equip; instance box → expand interface + // Double-click on a box body (not a pin -- those act on single click + // above): a partial-interface box loads its full interface, revealing + // the pins whose nets aren't shown yet. if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && g_provider) { - - float s = g_schematic.transform.scale; - ImVec2 m = ImGui::GetMousePos(); - float wx = (m.x - cpos.x) / s + g_schematic.transform.offset.x - - g_schematic.transform.screenOrigin.x / s; - float wy = (m.y - cpos.y) / s + g_schematic.transform.offset.y - - g_schematic.transform.screenOrigin.y / s; - const float hr2 = (kPortHitRadiusPx / std::max(0.01f, s)) * (kPortHitRadiusPx / std::max(0.01f, s)); - bool hit = false; - - for (const auto& inst : g_schematic.instances) { - for (const auto& port : inst.ports) { - ImVec2 pw = g_schematic.portWorldPos(inst, port); - float dx = wx - pw.x, dy = wy - pw.y; - if (dx*dx + dy*dy > hr2) continue; - auto busIt = g_busGroupByPortId.find(port.id); - if (busIt != g_busGroupByPortId.end()) { - // Merged bus pin: expand it instead of requesting a net. - // All bits are already loaded, so no request is needed. - // Collapsing is done from a bit pin's context menu. - g_expandedBuses.insert(busIt->second); - hit = true; break; - } - auto it = g_portEquiByPortId.find(port.id); - if (it == g_portEquiByPortId.end()) break; - json j; - j["request"] = "load_equipotential"; - j["path"] = it->second.pathIds; - j["term_id"] = it->second.termId; - if (it->second.bit.has_value()) j["bit"] = it->second.bit.value(); - g_provider->send(j.dump()); - g_skipNextAutoFit = true; - hit = true; break; - } - if (hit) break; - } + ImVec2 wp = mouseWorldPos(g_schematic, cpos); + const float wx = wp.x, wy = wp.y; + const bool hit = SchematicInteraction::pickPin(g_schematic.instances, wp, + g_schematic.transform.scale).has_value(); if (!hit) { // A hierarchy-expanded box's nested children sit *inside* its // own bounding rect, so a forward scan would always match the @@ -689,6 +722,8 @@ void EquipotentialView::renderSchematic(const std::vector& equip ps.pathIds = item.pathIds; mi.ports.push_back(std::move(ps)); g_portEquiByPortId[pid] = { item.pathIds, item.termChildId, item.termBit }; + // This pin's net is in the view now: its request is done. + g_pendingPinNets.resolve(pinRequestKey(g_portEquiByPortId[pid])); } equiEnds[ei].push_back({ item.key(), pid }); } @@ -751,18 +786,21 @@ void EquipotentialView::renderSchematic(const std::vector& equip // equipotential, else allocating a display-only id), then group // by (direction, bus base name) exactly like the partial- // interface branch below so a bus collapses to one pin here too. - struct ResolvedPort { const EquipotentialView::ExpandedPort* ep; int pid; }; + // `open`: no loaded equipotential backs this pin, i.e. its net + // isn't in the view yet -- clicking it will add it. + struct ResolvedPort { const EquipotentialView::ExpandedPort* ep; int pid; bool open; }; std::vector resolved; resolved.reserve(expIt->second.size()); for (const auto& ep : expIt->second) { int pid = nextPortId++; + bool open = true; for (const auto& ps : mi.ports) { if (ps.name == ep.name && ps.direction == ep.direction) - { pid = ps.portId; nextPortId--; break; } + { 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 }; - resolved.push_back({ &ep, pid }); + resolved.push_back({ &ep, pid, open }); } struct Row { std::vector members; bool merged = false; @@ -808,11 +846,15 @@ void EquipotentialView::renderSchematic(const std::vector& equip for (auto* m : row.members) logicalToRenderedPortId[m->pid] = p.id; g_busGroupByPortId[p.id] = row.groupKey; p.color = DiagnosisStore::netColor(key, row.busBase); + for (auto* m : row.members) p.open = p.open || m->open; } else { p.id = row.members[0]->pid; p.name = row.members[0]->ep->name; p.color = DiagnosisStore::netColor(key, stripBusIndex(row.members[0]->ep->name)); if (row.isExpandedBusBit) g_expandedBusGroupByPortId[p.id] = row.groupKey; + p.open = row.members[0]->open; + p.pending = p.open && g_pendingPinNets.isPending( + pinRequestKey(g_portEquiByPortId[p.id]), ImGui::GetTime()); } p.lx = isIn ? -0.5f : 0.5f; p.ly = isIn ? portLy(li++, nL) : portLy(ri++, nR); @@ -1073,6 +1115,11 @@ void EquipotentialView::renderSchematic(const std::vector& equip // those into one thicker "bus" wire instead of drawing them stacked. { std::map, size_t> firstOf; + auto portIsBus = [](int instId, int portId) { + auto* inst = g_schematic.findInstanceById(instId); + auto* port = inst ? g_schematic.findPortById(*inst, portId) : nullptr; + return port && port->isBus; + }; std::vector merged; for (const auto& n : g_schematic.nets) { auto k = std::make_tuple(n.srcInstance, n.srcPortId, n.dstInstance, n.dstPortId); @@ -1080,17 +1127,32 @@ void EquipotentialView::renderSchematic(const std::vector& equip if (it == firstOf.end()) { firstOf[k] = merged.size(); merged.push_back(n); - } else { + } else if (portIsBus(n.srcInstance, n.srcPortId) || portIsBus(n.dstInstance, n.dstPortId)) { merged[it->second].isBus = true; } + // else: the same net drawn twice (e.g. overlapping trace nets) -- + // just one wire, not a bus. } g_schematic.nets = std::move(merged); } static int lastTotal = -1; if (totalItems != lastTotal) { - if (g_skipNextAutoFit) g_skipNextAutoFit = false; - else g_schematic.requestFit(true); + if (g_skipNextAutoFit) { + g_skipNextAutoFit = false; + // Pan so the box whose pin was clicked stays under the cursor, + // wherever the re-layout moved it (screen = (world - offset) * + // scale, so shifting offset by the world delta cancels it out). + for (const auto& inst : g_schematic.instances) { + if (inst.isHierGroup || inst.name != g_keepInPlaceKey) continue; + g_schematic.transform.offset.x += inst.x - g_keepInPlaceWorld.x; + g_schematic.transform.offset.y += inst.y - g_keepInPlaceWorld.y; + break; + } + g_keepInPlaceKey.clear(); + } else { + g_schematic.requestFit(true); + } lastTotal = totalItems; } if (!g_pendingZoomGroup.empty()) { @@ -1105,9 +1167,45 @@ void EquipotentialView::renderSchematic(const std::vector& equip } g_schematic.updateFitIfNeeded(cpos, inner, 60.f); - // Hover tooltip: show diagnosis messages for the instance under the cursor. - if (ImGui::IsMouseHoveringRect(cpos, ImVec2(cpos.x + inner.x, cpos.y + inner.y))) { + // Hover feedback, against this frame's geometry (the one about to be + // drawn): the pin under the cursor is highlighted, gets a hand cursor if + // clicking it does something, and a tooltip saying what; otherwise the + // box under the cursor shows its diagnostics and, after a short rest on + // a partial-interface box, how to reveal its other pins. + g_schematic.hoveredPortId = -1; + const bool canvasHovered = + ImGui::IsMouseHoveringRect(cpos, ImVec2(cpos.x + inner.x, cpos.y + inner.y)) && + !ImGui::IsPopupOpen("##ctx") && !ImGui::IsMouseDragging(ImGuiMouseButton_Right) && + !ImGui::IsMouseDragging(ImGuiMouseButton_Middle); + bool pinHovered = false; + if (canvasHovered) { + ImVec2 wp = mouseWorldPos(g_schematic, cpos); + auto hit = SchematicInteraction::pickPin(g_schematic.instances, wp, g_schematic.transform.scale); + InstanceShape* inst = hit ? g_schematic.findInstanceById(hit->instanceId) : nullptr; + Port* port = inst ? g_schematic.findPortById(*inst, hit->portId) : nullptr; + if (port) { + pinHovered = true; + g_schematic.hoveredPortId = port->id; + const PinAction action = pinAction(*port); + if (action == PinAction::ExpandBus || action == PinAction::LoadNet) + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::BeginTooltip(); + if (inst->modelName == "port") ImGui::Text("%s (top-level port)", port->name.c_str()); + else ImGui::Text("%s / %s", inst->name.c_str(), port->name.c_str()); + switch (action) { + case PinAction::ExpandBus: ImGui::TextDisabled("Click: show the individual bits"); break; + case PinAction::LoadNet: ImGui::TextDisabled("Click: add this net to the view"); break; + case PinAction::Loading: ImGui::TextDisabled("Loading net..."); break; + case PinAction::None: ImGui::TextDisabled("Net shown"); break; + } + if (g_portEquiByPortId.count(port->id)) + ImGui::TextDisabled("Right-click: Trace to Driver"); + ImGui::EndTooltip(); + } + } + if (canvasHovered && !pinHovered) { ImVec2 wp = mouseWorldPos(g_schematic, cpos); + bool overBox = false; // Reverse scan: a nested child's rect sits inside its parent's, so // the innermost (most specific) box under the cursor is whichever // one was appended last -- see the same reasoning on the @@ -1119,18 +1217,25 @@ void EquipotentialView::renderSchematic(const std::vector& equip if (wp.y < inst.y || wp.y > inst.y + inst.h) continue; auto it = g_occInfoByShapeId.find(inst.id); if (it == g_occInfoByShapeId.end()) break; + overBox = true; + if (inst.id != g_hintShapeId) { g_hintShapeId = inst.id; g_hintSince = ImGui::GetTime(); } + const bool showHint = inst.partialInterface && ImGui::GetTime() - g_hintSince > 0.6; auto diagnostics = DiagnosisStore::instanceDiagnostics(it->second.pathKey); - if (!diagnostics.empty()) { + if (!diagnostics.empty() || showHint) { ImGui::BeginTooltip(); for (const auto* d : diagnostics) { ImGui::TextColored(ImColor(DiagnosisStore::colorForSeverity(d->severity)).Value, "[%s] %s", toString(d->severity), d->message.c_str()); if (!d->source.empty()) ImGui::TextDisabled("source: %s", d->source.c_str()); } + if (showHint) ImGui::TextDisabled("Double-click: show all pins"); ImGui::EndTooltip(); } break; } + if (!overBox) g_hintShapeId = -1; + } else { + g_hintShapeId = -1; } g_schematic.render(dl, cpos, inner); diff --git a/src/EquipotentialView.h b/src/EquipotentialView.h index 434be17..82e6a53 100644 --- a/src/EquipotentialView.h +++ b/src/EquipotentialView.h @@ -6,6 +6,7 @@ class Equipotential; class INetlistProvider; +class SchematicView; class EquipotentialView { public: @@ -71,4 +72,11 @@ class EquipotentialView { // Called by AppLogic when an instance_internals_response arrives. static void applyInstanceInternals(const std::string& pathKey, const InstanceInternals& data); + + // Test hooks (read-only): the geometry the last renderSchematic() drew, + // and the screen position of its canvas's top-left corner -- enough for a + // headless ImGui test to turn a pin's world position into a mouse + // position (see tests/EquipotentialViewTest.cpp). + static const SchematicView& schematicForTesting(); + static ImVec2 canvasOriginForTesting(); }; diff --git a/src/GUIData.cpp b/src/GUIData.cpp index 75cd48d..3aa45df 100644 --- a/src/GUIData.cpp +++ b/src/GUIData.cpp @@ -8,8 +8,15 @@ std::string GUIData::getString() const { return result; } -void GUIData::addEquipotential(Equipotential* eq) { +bool GUIData::addEquipotential(Equipotential* eq) { + for (const auto* shown : equipotentials_) { + if (equipotentialCovers(*shown, *eq)) { + delete eq; + return false; + } + } equipotentials_.push_back(eq); + return true; } void GUIData::clearEquipotentials() { diff --git a/src/GUIData.h b/src/GUIData.h index 2372b61..9fb7659 100644 --- a/src/GUIData.h +++ b/src/GUIData.h @@ -10,7 +10,9 @@ struct GUIData { public: std::string getString() const; - void addEquipotential(Equipotential* eq); + // Takes ownership. Returns false (and deletes `eq`) when an equipotential + // already shown covers all of its endpoints -- see equipotentialCovers(). + bool addEquipotential(Equipotential* eq); void clearEquipotentials(); NetlistTree* netlist_ {nullptr}; diff --git a/src/SchematicInteraction.cpp b/src/SchematicInteraction.cpp new file mode 100644 index 0000000..bd9d9b0 --- /dev/null +++ b/src/SchematicInteraction.cpp @@ -0,0 +1,64 @@ +// SchematicInteraction.cpp — pure hit-testing/bookkeeping for schematic pins. +#include "SchematicInteraction.h" + +#include + +namespace SchematicInteraction { + +float pinHitRadius(float scale) { + return std::min(kPinHitRadiusPx / std::max(0.01f, scale), kPinHitMaxWorld); +} + +// Squared distance from p to the segment [a, b]. +static float distSqToSegment(ImVec2 p, ImVec2 a, ImVec2 b) { + float dx = b.x - a.x, dy = b.y - a.y; + float len2 = dx * dx + dy * dy; + float t = len2 > 0.f ? std::clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / len2, 0.f, 1.f) : 0.f; + float cx = a.x + t * dx - p.x, cy = a.y + t * dy - p.y; + return cx * cx + cy * cy; +} + +std::optional pickPin(const std::vector& instances, + ImVec2 world, float scale) { + const float r = pinHitRadius(scale); + float best = r * r; + std::optional hit; + for (const auto& inst : instances) { + if (inst.isHierGroup) continue; + const bool isFlag = inst.w <= 0.f && inst.h <= 0.f; + for (const auto& port : inst.ports) { + ImVec2 a = portAnchor(inst, port); + ImVec2 b = isFlag ? ImVec2(a.x + kFlagHitLenWorld, a.y) + : ImVec2(a.x + (port.lx < 0.f ? -kPinTickWorld : kPinTickWorld), a.y); + float d = distSqToSegment(world, a, b); + if (d <= best) { best = d; hit = PinHit{inst.id, port.id}; } + } + } + return hit; +} + +std::string pinRequestKey(const std::vector& pathIds, unsigned termId, + std::optional bit) { + std::string key; + for (unsigned id : pathIds) key += std::to_string(id) + "/"; + key += "#" + std::to_string(termId); + if (bit.has_value()) key += "[" + std::to_string(*bit) + "]"; + return key; +} + +bool PendingRequests::begin(const std::string& key, double now) { + if (isPending(key, now)) return false; + started_[key] = now; + return true; +} + +bool PendingRequests::isPending(const std::string& key, double now) const { + auto it = started_.find(key); + return it != started_.end() && now - it->second < timeout_; +} + +void PendingRequests::resolve(const std::string& key) { started_.erase(key); } + +void PendingRequests::clear() { started_.clear(); } + +} // namespace SchematicInteraction diff --git a/src/SchematicInteraction.h b/src/SchematicInteraction.h new file mode 100644 index 0000000..c967768 --- /dev/null +++ b/src/SchematicInteraction.h @@ -0,0 +1,68 @@ +// SchematicInteraction.h — pure hit-testing/bookkeeping behind the schematic's +// pin interactions (hover, click-to-extend, right-click). No ImGui frame, no +// provider: EquipotentialView feeds it world coordinates and the clock, which +// keeps it testable (see tests/SchematicInteractionTest.cpp). +#pragma once + +#include +#include +#include +#include +#include + +#include "Types.h" + +namespace SchematicInteraction { + +// Pin hit area: a capsule around the pin's tick (which reaches kPinTickWorld +// outward from the box edge, see SchematicView.cpp's drawPorts), with a +// radius of kPinHitRadiusPx on screen -- but never more than kPinHitMaxWorld +// in world units, so at low zoom a pin doesn't swallow clicks meant for the +// box body next to it. When several capsules overlap (dense pins at low +// zoom), the nearest pin wins, not whichever was built first. +inline constexpr float kPinHitRadiusPx = 18.0f; +inline constexpr float kPinHitMaxWorld = 20.0f; +inline constexpr float kPinTickWorld = 10.0f; +// Zero-size shapes are top-level port flags: the flag body extends right of +// the anchor (see drawBoundaryPortInstance), so that's where they're grabbed. +inline constexpr float kFlagHitLenWorld = 30.0f; + +struct PinHit { + int instanceId = -1; + int portId = -1; +}; + +// World-space radius of a pin's hit capsule at the given zoom scale. +float pinHitRadius(float scale); + +// The pin whose hit capsule contains `world`, nearest first; nullopt if none. +// Hierarchy frames have no pins and are skipped. +std::optional pickPin(const std::vector& instances, + ImVec2 world, float scale); + +// Identity of a load_equipotential request for one pin, so a pin whose net +// is already on its way isn't requested again. +std::string pinRequestKey(const std::vector& pathIds, unsigned termId, + std::optional bit); + +// Requests sent but not answered yet. An entry expires after `timeout` +// seconds, so a request whose reply never comes (server error, dropped +// socket) doesn't leave its pin stuck in the "loading" state forever. +class PendingRequests { + public: + explicit PendingRequests(double timeout = 5.0) : timeout_(timeout) {} + + // Registers `key` at time `now`. False (and no change) if it's already + // pending, i.e. the caller shouldn't send it again. + bool begin(const std::string& key, double now); + bool isPending(const std::string& key, double now) const; + // The reply arrived (the pin's net is now shown). + void resolve(const std::string& key); + void clear(); + + private: + double timeout_; + std::map started_; +}; + +} // namespace SchematicInteraction diff --git a/src/SchematicView.cpp b/src/SchematicView.cpp index 7e83c1a..74426dd 100644 --- a/src/SchematicView.cpp +++ b/src/SchematicView.cpp @@ -72,6 +72,14 @@ constexpr ImU32 kPinLineColor = IM_COL32(40, 40, 40, 235); // use red/amber/blue (DiagnosisStore.cpp), so it doesn't read as a finding. constexpr ImU32 kBoundaryPortFillColor = IM_COL32(196, 232, 204, 255); constexpr ImU32 kBoundaryPortLineColor = IM_COL32(28, 110, 60, 255); +// Interaction feedback, not a finding: the pin under the cursor, and an open +// pin whose net is loading. Blue/violet stay clear of the diagnosis +// 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); +// Open-pin stub circle (see Port::open), world-space "1x zoom" radius. +constexpr float kOpenPinBaseR = 3.5f; +constexpr float kMinOpenPinR = 2.0f; // Returns 0.0f when the label would render too small to read -- callers // should skip drawing (and any backing rect) in that case. @@ -179,10 +187,7 @@ Port* SchematicView::findPortById(InstanceShape& inst, int portId) const { // Compute absolute world position of a port given its instance and normalized local coords (lx,ly). ImVec2 SchematicView::portWorldPos(const InstanceShape& inst, const Port& port) const { - // port.lx, port.ly are normalized: -0.5..0.5 horizontally/vertically where 0 is center - float px = inst.x + (inst.w * (0.5f + port.lx)); // convert -0.5..0.5 to 0..1 then * width - float py = inst.y + (inst.h * (0.5f + port.ly)); - return ImVec2(px, py); + return portAnchor(inst, port); } // --------------------------------------------------------------------------- @@ -202,11 +207,28 @@ static void drawPorts(ImDrawList* dl, const InstanceShape& inst, // No direction color by default -- direction reads from the pin's // side of the box, not a red/green fill. p.color (diagnosis/ // selection override) is the one case color is still used here. - ImU32 portColor = p.color != 0 ? p.color : kPinLineColor; + const bool hovered = p.id == sv.hoveredPortId; + ImU32 portColor = hovered ? kPinHoverColor + : p.pending ? kPinPendingColor + : p.color != 0 ? p.color : kPinLineColor; float tickThickness = std::max(1.0f, 1.5f * sv.transform.scale); + if (hovered) tickThickness *= 2.0f; ImVec2 tickEnd = ImVec2(screenP.x + (isLeft ? -tickLen : tickLen), screenP.y); + if (hovered) + dl->AddCircleFilled(tickEnd, std::max(6.0f, tickLen * 0.9f), IM_COL32(20, 110, 235, 45)); dl->AddLine(screenP, tickEnd, portColor, tickThickness); + // An open pin (net not in the view yet) ends in a hollow circle, a + // loading one in a filled circle: "click here to see more" without + // needing color for the resting state. + ImVec2 labelAt = tickEnd; + if (p.open) { + float r = std::max(kMinOpenPinR, kOpenPinBaseR * sv.transform.scale); + ImVec2 c(tickEnd.x + (isLeft ? -r : r), tickEnd.y); + dl->AddCircleFilled(c, r, p.pending ? portColor : kCanvasBgColor); + dl->AddCircle(c, r, portColor, 0, std::max(1.0f, tickThickness * 0.8f)); + labelAt.x += isLeft ? -2.0f * r : 2.0f * r; // label goes past the circle + } // A merged bus pin gets the classic diagonal bus slash across its // tick instead of a plain line, in addition to its "[hi:lo]" label. if (p.isBus) { @@ -230,12 +252,12 @@ static void drawPorts(ImDrawList* dl, const InstanceShape& inst, // Anchored past the tick's tip (not the box edge) so the // label doesn't sit on top of the pin's own tick line. ImVec2 lblPos = isLeft - ? ImVec2(tickEnd.x - 3.0f - textSize.x, tickEnd.y - textSize.y * 0.5f) - : ImVec2(tickEnd.x + 3.0f, tickEnd.y - textSize.y * 0.5f); + ? ImVec2(labelAt.x - 3.0f - textSize.x, labelAt.y - textSize.y * 0.5f) + : ImVec2(labelAt.x + 3.0f, labelAt.y - textSize.y * 0.5f); dl->AddRectFilled( ImVec2(lblPos.x - 2.0f, lblPos.y - 1.0f), ImVec2(lblPos.x + textSize.x + 2.0f, lblPos.y + textSize.y + 1.0f), - IM_COL32(30, 30, 30, 190)); + hovered ? kPinHoverColor : IM_COL32(30, 30, 30, 190)); dl->AddText(font, fontSize, lblPos, IM_COL32(225, 225, 225, 235), label.c_str()); } } @@ -371,8 +393,10 @@ static void drawBoundaryPortInstance(ImDrawList* dl, const InstanceShape& inst, // (an output) -- flat base toward `anchor`, where the wire attaches. float outDir = 1.0f; - ImU32 outline = p.color != 0 ? p.color : kBoundaryPortLineColor; + const bool hovered = p.id == sv.hoveredPortId; + ImU32 outline = hovered ? kPinHoverColor : p.color != 0 ? p.color : kBoundaryPortLineColor; float lineThickness = std::max(1.0f, 1.25f * sv.transform.scale); + if (hovered) lineThickness *= 2.0f; // Name shown whenever instance names are (same visibility threshold), // drawn at pin-label size but never below the legible minimum. diff --git a/src/SchematicView.h b/src/SchematicView.h index 89fb9cc..ac4fa03 100644 --- a/src/SchematicView.h +++ b/src/SchematicView.h @@ -17,6 +17,9 @@ class SchematicView { std::vector instances; std::vector nets; Transform transform; + // Pin under the cursor (drawn highlighted), -1 = none. Set each frame by + // the owning view, which does the hit-testing. + int hoveredPortId = -1; float minScale = 0.1f; float maxScale = 6.0f; diff --git a/src/Types.cpp b/src/Types.cpp index da8a3c6..841d489 100644 --- a/src/Types.cpp +++ b/src/Types.cpp @@ -1,5 +1,7 @@ #include "Types.h" +#include + #include "Console.h" static std::optional parseSourceLoc(const json& j) { @@ -231,4 +233,24 @@ void from_json(const json& j, PropertiesResponseJson& r) { r.properties.push_back(raw.get()); } } -} \ No newline at end of file +} + +// One endpoint of an equipotential, by name: "T|[bit]" for a top-level +// term, "O||[bit]" for an instance pin. +static std::set endpointKeys(const Equipotential& e) { + std::set keys; + for (const auto& t : e.terms) keys.insert("T|" + t.getString()); + for (const auto& o : e.occurrences) { + std::string k = "O|"; + for (const auto& seg : o.path) k += seg + "/"; + keys.insert(k + "|" + o.term.getString()); + } + return keys; +} + +bool equipotentialCovers(const Equipotential& shown, const Equipotential& candidate) { + const auto have = endpointKeys(shown); + for (const auto& k : endpointKeys(candidate)) + if (!have.count(k)) return false; + return true; +} diff --git a/src/Types.h b/src/Types.h index 365b6c2..daec2a2 100644 --- a/src/Types.h +++ b/src/Types.h @@ -133,6 +133,13 @@ struct Equipotential { std::vector occurrences; }; +// True when every endpoint (top-level term or instance pin) of `candidate` is +// already one of `shown`'s -- adding it to the view would only redraw wires +// already there. Covers exact duplicates (clicking a pin whose net is shown) +// and the partial nets of a driver trace (which list only the receivers the +// trace entered through) whose full net is already displayed. +bool equipotentialCovers(const Equipotential& shown, const Equipotential& candidate); + // // --- Diagnosis overlay types --- // A diagnosis_response annotates an already-loaded netlist with findings from @@ -234,8 +241,14 @@ struct Port { ImU32 color = 0; // optional explicit color override (0 == no override) // True when this pin represents multiple merged bus bits rather than a // single bit/scalar terminal — drives a distinct draw style and expands - // the bus (instead of load_equipotential) on double-click. + // the bus (instead of load_equipotential) on click. bool isBus = false; + // True when this pin's net isn't in the view yet (only revealed by + // expanding the instance's full interface): clicking it adds that net. + // Drawn with an open-circle stub so it reads as "more to see here". + bool open = false; + // An open pin whose net has been requested but hasn't arrived yet. + bool pending = false; }; struct InstanceShape { @@ -312,6 +325,13 @@ struct NetWire { std::string netName; }; +// World-space point where a pin meets its box (and where its wire attaches): +// lx/ly are normalized -0.5..0.5 across the box, 0 at its center. +inline ImVec2 portAnchor(const InstanceShape& inst, const Port& port) { + return ImVec2(inst.x + inst.w * (0.5f + port.lx), + inst.y + inst.h * (0.5f + port.ly)); +} + // World-space rect of an instance's hierarchy expand/collapse glyph // (a small square straddling the top-center of the box). Shared by // SchematicView's draw code and EquipotentialView's click hit-test so the diff --git a/tests/EquipotentialViewTest.cpp b/tests/EquipotentialViewTest.cpp new file mode 100644 index 0000000..cb4ded8 --- /dev/null +++ b/tests/EquipotentialViewTest.cpp @@ -0,0 +1,296 @@ +// Headless ImGui tests of the schematic's pin interactions: a real ImGui +// context (no render backend), simulated mouse input, and the real +// EquipotentialView::renderSchematic() -- asserting on the requests it sends +// through a FakeNetlistProvider and on the geometry it builds. +#include "EquipotentialView.h" +#include "SchematicView.h" + +#include +#include +#include + +#include "FakeNetlistProvider.h" + +namespace { + +InstTermOccurrence occ(const std::string& inst, unsigned instId, const std::string& term, + unsigned termId, Direction dir, size_t bitTermCount, + std::optional bit = std::nullopt) { + InstTermOccurrence o; + o.path = {inst}; + o.pathIds = {instId}; + o.pathModels = {""}; + o.term = BitTerm{term, termId, dir, bit}; + o.bit_term_count = bitTermCount; + return o; +} + +class SchematicClicks : public ::testing::Test { + protected: + void SetUp() override { + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(1200.f, 800.f); + io.DeltaTime = 1.f / 60.f; + io.IniFilename = nullptr; + unsigned char* px; int w, h; + io.Fonts->GetTexDataAsRGBA32(&px, &w, &h); + EquipotentialView::setProvider(&provider); + EquipotentialView::resetLayout(); + } + + void TearDown() override { + EquipotentialView::setProvider(nullptr); + EquipotentialView::resetLayout(); + for (auto* eq : eqs) delete eq; + ImGui::DestroyContext(); + } + + void add(Equipotential eq) { eqs.push_back(new Equipotential(std::move(eq))); } + + void frame(int n = 1) { + for (int i = 0; i < n; ++i) { + ImGui::NewFrame(); + ImGui::SetNextWindowPos(ImVec2(0.f, 0.f)); + ImGui::SetNextWindowSize(ImGui::GetIO().DisplaySize); + ImGui::Begin("Schematic", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); + EquipotentialView::renderSchematic(eqs); + ImGui::End(); + ImGui::Render(); + } + } + + const SchematicView& sv() const { return EquipotentialView::schematicForTesting(); } + + const InstanceShape* shape(const std::string& name) const { + for (const auto& s : sv().instances) + if (!s.isHierGroup && s.name == name) return &s; + return nullptr; + } + + const Port* pin(const std::string& inst, const std::string& name) const { + const InstanceShape* s = shape(inst); + if (!s) return nullptr; + for (const auto& p : s->ports) + if (p.name == name) return &p; + return nullptr; + } + + ImVec2 toScreen(ImVec2 world) const { + return sv().worldToScreen(world, EquipotentialView::canvasOriginForTesting(), ImVec2()); + } + + // Middle of the pin's tick, where a user would aim. + ImVec2 pinScreen(const std::string& inst, const std::string& name) const { + const InstanceShape* s = shape(inst); + const Port* p = pin(inst, name); + EXPECT_NE(p, nullptr) << inst << "/" << name; + if (!p) return ImVec2(); + ImVec2 a = portAnchor(*s, *p); + return toScreen(ImVec2(a.x + (p->lx < 0.f ? -4.f : 4.f), a.y)); + } + + ImVec2 boxCenterScreen(const std::string& inst) const { + const InstanceShape* s = shape(inst); + EXPECT_NE(s, nullptr) << inst; + return s ? toScreen(ImVec2(s->x + s->w * 0.5f, s->y + s->h * 0.5f)) : ImVec2(); + } + + void moveTo(ImVec2 p) { + ImGui::GetIO().AddMousePosEvent(p.x, p.y); + frame(); + } + void click() { + ImGui::GetIO().AddMouseButtonEvent(ImGuiMouseButton_Left, true); + frame(); + ImGui::GetIO().AddMouseButtonEvent(ImGuiMouseButton_Left, false); + frame(); + } + void doubleClick() { click(); click(); } + + std::vector sent(const std::string& request) const { + std::vector out; + for (const auto& s : provider.sent) { + auto j = nlohmann::json::parse(s); + if (j.value("request", "") == request) out.push_back(j); + } + return out; + } + + // u1.Q -> u2.A, where u2's model has 3 bit terms (A, B, Y): u2 is drawn + // with a partial interface, and after expansion B and Y are open pins. + void showFirstNet() { + Equipotential e{true, {}, {}}; + e.occurrences.push_back(occ("u1", 1, "Q", 10, Direction::Output, 1)); + e.occurrences.push_back(occ("u2", 2, "A", 20, Direction::Input, 3)); + add(e); + frame(3); + } + void expandU2() { + EquipotentialView::applyInstanceExpansion("u2", { + {"A", Direction::Input, 20, std::nullopt}, + {"B", Direction::Input, 21, std::nullopt}, + {"Y", Direction::Output, 22, std::nullopt}, + }); + frame(2); + } + + FakeNetlistProvider provider; + std::vector eqs; +}; + +} // namespace + +TEST_F(SchematicClicks, DoubleClickOnPartialBoxRequestsItsFullInterface) { + showFirstNet(); + ASSERT_NE(shape("u2"), nullptr); + EXPECT_TRUE(shape("u2")->partialInterface); + + moveTo(boxCenterScreen("u2")); + doubleClick(); + + auto reqs = sent("expand_instance_terms"); + ASSERT_EQ(reqs.size(), 1u); + EXPECT_EQ(reqs[0]["path_key"], "u2"); +} + +TEST_F(SchematicClicks, DoubleClickOnAPinIsNotABoxDoubleClick) { + showFirstNet(); + moveTo(pinScreen("u2", "A")); + doubleClick(); + EXPECT_TRUE(sent("expand_instance_terms").empty()); + EXPECT_TRUE(sent("load_equipotential").empty()); // A's net is already shown +} + +TEST_F(SchematicClicks, ExpandedInterfaceMarksPinsNotInTheViewAsOpen) { + showFirstNet(); + expandU2(); + ASSERT_NE(pin("u2", "B"), nullptr); + EXPECT_FALSE(shape("u2")->partialInterface); + EXPECT_FALSE(pin("u2", "A")->open); // on the net already shown + EXPECT_TRUE(pin("u2", "B")->open); + EXPECT_TRUE(pin("u2", "Y")->open); +} + +TEST_F(SchematicClicks, HoveringAPinHighlightsIt) { + showFirstNet(); + expandU2(); + moveTo(pinScreen("u2", "B")); + EXPECT_EQ(sv().hoveredPortId, pin("u2", "B")->id); + + moveTo(boxCenterScreen("u2")); + EXPECT_EQ(sv().hoveredPortId, -1); +} + +TEST_F(SchematicClicks, SingleClickOnOpenPinLoadsItsNetOnce) { + showFirstNet(); + expandU2(); + moveTo(pinScreen("u2", "B")); + click(); + + auto reqs = sent("load_equipotential"); + ASSERT_EQ(reqs.size(), 1u); + EXPECT_EQ(reqs[0]["path"], nlohmann::json::array({2})); + EXPECT_EQ(reqs[0]["term_id"], 21); + EXPECT_TRUE(pin("u2", "B")->pending); + + // Impatient second click (or the second half of a double-click) while + // the net is loading: nothing more is sent. + click(); + doubleClick(); + EXPECT_EQ(sent("load_equipotential").size(), 1u); +} + +TEST_F(SchematicClicks, ClickOnAPinWhoseNetIsShownSendsNothing) { + showFirstNet(); + expandU2(); + moveTo(pinScreen("u2", "A")); + click(); + EXPECT_TRUE(provider.sent.empty()); +} + +TEST_F(SchematicClicks, ArrivingNetClosesThePinAndKeepsTheClickedBoxInPlace) { + showFirstNet(); + expandU2(); + moveTo(pinScreen("u2", "B")); + click(); + const ImVec2 before = boxCenterScreen("u2"); + const float scaleBefore = sv().transform.scale; + + // The reply: u9 (inside module "core", so hierarchy frames re-lay out the + // whole drawing) drives u2.B. + Equipotential e{true, {}, {}}; + InstTermOccurrence drv; + drv.path = {"core", "u9"}; drv.pathIds = {5, 9}; drv.pathModels = {"Core", ""}; + drv.term = BitTerm{"Z", 90, Direction::Output, std::nullopt}; + drv.bit_term_count = 1; + e.occurrences.push_back(drv); + e.occurrences.push_back(occ("u2", 2, "B", 21, Direction::Input, 3)); + add(e); + frame(2); + + ASSERT_NE(shape("core/u9"), nullptr); + EXPECT_FALSE(pin("u2", "B")->open); + EXPECT_FALSE(pin("u2", "B")->pending); + const ImVec2 after = boxCenterScreen("u2"); + EXPECT_NEAR(after.x, before.x, 0.5f); + EXPECT_NEAR(after.y, before.y, 0.5f); + EXPECT_EQ(sv().transform.scale, scaleBefore); // no re-fit zoom either +} + +TEST_F(SchematicClicks, ClickOnMergedBusPinShowsItsBits) { + for (int b = 0; b < 2; ++b) { + Equipotential e{true, {}, {}}; + e.occurrences.push_back(occ("u1", 1, "Q", 10, Direction::Output, 2, b)); + e.occurrences.push_back(occ("u2", 2, "D", 20, Direction::Input, 2, b)); + add(e); + } + frame(3); + ASSERT_NE(pin("u2", "D[1:0]"), nullptr); + EXPECT_TRUE(pin("u2", "D[1:0]")->isBus); + + moveTo(pinScreen("u2", "D[1:0]")); + click(); + frame(); + EXPECT_EQ(pin("u2", "D[1:0]"), nullptr); + EXPECT_NE(pin("u2", "D[0]"), nullptr); + EXPECT_NE(pin("u2", "D[1]"), nullptr); + EXPECT_TRUE(provider.sent.empty()); // all bits were already loaded +} + +// The same net listed twice (e.g. a trace re-listing a net already shown) +// used to be drawn as a thick "bus" wire. +TEST_F(SchematicClicks, SameNetTwiceIsOneThinWire) { + showFirstNet(); + Equipotential again{true, {}, {}}; + again.occurrences.push_back(occ("u1", 1, "Q", 10, Direction::Output, 1)); + again.occurrences.push_back(occ("u2", 2, "A", 20, Direction::Input, 3)); + add(again); + frame(2); + + ASSERT_EQ(sv().nets.size(), 1u); + EXPECT_FALSE(sv().nets[0].isBus); +} + +// Only the first press of a double-click acts: double-clicking the hierarchy +// glyph used to toggle it open and straight back closed. +TEST_F(SchematicClicks, DoubleClickOnHierarchyGlyphTogglesItOnce) { + Equipotential e{true, {}, {}}; + e.occurrences.push_back(occ("u1", 1, "Q", 10, Direction::Output, 1)); + auto sub = occ("u2", 2, "A", 20, Direction::Input, 1); + sub.has_instances = true; + e.occurrences.push_back(sub); + add(e); + frame(3); + ASSERT_NE(shape("u2"), nullptr); + ASSERT_TRUE(canShowHierToggle(*shape("u2"))); + + float x0, y0, x1, y1; + hierToggleGlyphRect(*shape("u2"), x0, y0, x1, y1); + moveTo(toScreen(ImVec2((x0 + x1) * 0.5f, (y0 + y1) * 0.5f))); + doubleClick(); + + EXPECT_TRUE(shape("u2")->hierExpanded); + EXPECT_EQ(sent("load_instance_internals").size(), 1u); +} diff --git a/tests/GUIDataTest.cpp b/tests/GUIDataTest.cpp new file mode 100644 index 0000000..67435b7 --- /dev/null +++ b/tests/GUIDataTest.cpp @@ -0,0 +1,102 @@ +#include "GUIData.h" +#include "Types.h" + +#include + +namespace { + +InstTermOccurrence occ(std::vector path, const std::string& term, Direction dir, + std::optional bit = std::nullopt) { + InstTermOccurrence o; + o.path = std::move(path); + o.term = BitTerm{term, 0, dir, bit}; + return o; +} + +// top input `in` -> u1.A, u2.A +Equipotential fullNet() { + Equipotential e{true, {BitTerm{"in", 0, Direction::Input, std::nullopt}}, {}}; + e.occurrences.push_back(occ({"u1"}, "A", Direction::Input)); + e.occurrences.push_back(occ({"sub", "u2"}, "A", Direction::Input)); + return e; +} + +} // namespace + +// --------------------------------------------------------------------------- +// equipotentialCovers +// --------------------------------------------------------------------------- + +TEST(EquipotentialCovers, SameNetIsCovered) { + EXPECT_TRUE(equipotentialCovers(fullNet(), fullNet())); +} + +// A driver trace lists only the receiver it entered each net through. +TEST(EquipotentialCovers, TracePartialNetIsCoveredByTheFullNet) { + Equipotential partial{true, {BitTerm{"in", 0, Direction::Input, std::nullopt}}, {}}; + partial.occurrences.push_back(occ({"u1"}, "A", Direction::Input)); + EXPECT_TRUE(equipotentialCovers(fullNet(), partial)); + EXPECT_FALSE(equipotentialCovers(partial, fullNet())); // but not the other way round +} + +TEST(EquipotentialCovers, DifferentPinBitOrPathIsNotCovered) { + auto otherPin = fullNet(); + otherPin.occurrences[0].term.name = "B"; + EXPECT_FALSE(equipotentialCovers(fullNet(), otherPin)); + + auto otherBit = fullNet(); + otherBit.occurrences[0].term.bit = 1; + EXPECT_FALSE(equipotentialCovers(fullNet(), otherBit)); + + // Same leaf name, different hierarchy. + auto otherPath = fullNet(); + otherPath.occurrences[1].path = {"u2"}; + EXPECT_FALSE(equipotentialCovers(fullNet(), otherPath)); + + auto otherTerm = fullNet(); + otherTerm.terms[0].name = "in2"; + EXPECT_FALSE(equipotentialCovers(fullNet(), otherTerm)); +} + +TEST(EquipotentialCovers, PathSegmentsAreNotConfusedWithSlashes) { + Equipotential a{true, {}, {occ({"a", "b"}, "A", Direction::Input)}}; + Equipotential b{true, {}, {occ({"a/b"}, "A", Direction::Input)}}; + // Both flatten to "a/b" -- that's also how the view keys instances, so + // they're the same box on screen and treating them alike is right. + EXPECT_TRUE(equipotentialCovers(a, b)); +} + +// --------------------------------------------------------------------------- +// GUIData::addEquipotential +// --------------------------------------------------------------------------- + +TEST(GUIData, AddsANewNet) { + GUIData data; + EXPECT_TRUE(data.addEquipotential(new Equipotential(fullNet()))); + EXPECT_EQ(data.equipotentials_.size(), 1u); + data.clearEquipotentials(); +} + +// Clicking a pin whose net is already shown, or a trace re-listing it, used +// to add it again -- drawing each wire twice, which then got merged into a +// fake "bus" wire. +TEST(GUIData, RejectsANetAlreadyShown) { + GUIData data; + data.addEquipotential(new Equipotential(fullNet())); + + Equipotential partial{true, {}, {occ({"u1"}, "A", Direction::Input)}}; + EXPECT_FALSE(data.addEquipotential(new Equipotential(fullNet()))); + EXPECT_FALSE(data.addEquipotential(new Equipotential(partial))); + EXPECT_EQ(data.equipotentials_.size(), 1u); + data.clearEquipotentials(); +} + +TEST(GUIData, AcceptsANetThatAddsEndpoints) { + GUIData data; + Equipotential partial{true, {}, {occ({"u1"}, "A", Direction::Input)}}; + data.addEquipotential(new Equipotential(partial)); + // The full net has endpoints the partial one lacks: it's new information. + EXPECT_TRUE(data.addEquipotential(new Equipotential(fullNet()))); + EXPECT_EQ(data.equipotentials_.size(), 2u); + data.clearEquipotentials(); +} diff --git a/tests/SchematicInteractionTest.cpp b/tests/SchematicInteractionTest.cpp new file mode 100644 index 0000000..23e14c5 --- /dev/null +++ b/tests/SchematicInteractionTest.cpp @@ -0,0 +1,173 @@ +#include "SchematicInteraction.h" + +#include + +using namespace SchematicInteraction; + +namespace { + +// A 180x70 box at (0,0) with pins given as {id, lx, ly}. +InstanceShape box(int id, std::vector> pins, + float x = 0.f, float y = 0.f) { + InstanceShape s; + s.id = id; s.name = "u" + std::to_string(id); + s.x = x; s.y = y; s.w = 180.f; s.h = 70.f; + for (auto [pid, lx, ly] : pins) { + Port p; + p.id = pid; p.lx = lx; p.ly = ly; + s.ports.push_back(p); + } + return s; +} + +// Top-level port flag: a zero-size shape whose single pin sits at (x, y). +InstanceShape flag(int id, int pid, float x, float y) { + InstanceShape s; + s.id = id; s.x = x; s.y = y; s.w = 0.f; s.h = 0.f; s.modelName = "port"; + Port p; + p.id = pid; p.lx = 1.0f; + s.ports.push_back(p); + return s; +} + +int pickedPort(const std::vector& shapes, ImVec2 world, float scale = 1.f) { + auto hit = pickPin(shapes, world, scale); + return hit ? hit->portId : -1; +} + +} // namespace + +// --------------------------------------------------------------------------- +// pickPin +// --------------------------------------------------------------------------- + +TEST(PickPin, HitsThePinAnchorAndItsTick) { + // Left pin anchored at (0, 35), its tick reaching out to (-10, 35). + std::vector shapes = {box(1, {{7, -0.5f, 0.f}})}; + EXPECT_EQ(pickedPort(shapes, {0.f, 35.f}), 7); + EXPECT_EQ(pickedPort(shapes, {-kPinTickWorld, 35.f}), 7); + // Past the tick tip, still within the radius around it. + EXPECT_EQ(pickedPort(shapes, {-kPinTickWorld - 15.f, 35.f}), 7); + + auto hit = pickPin(shapes, {-5.f, 35.f}, 1.f); + ASSERT_TRUE(hit.has_value()); + EXPECT_EQ(hit->instanceId, 1); +} + +TEST(PickPin, RightPinsReachOutToTheRight) { + std::vector shapes = {box(1, {{7, 0.5f, 0.f}})}; + EXPECT_EQ(pickedPort(shapes, {180.f + kPinTickWorld + 10.f, 35.f}), 7); + EXPECT_EQ(pickedPort(shapes, {-10.f, 35.f}), -1); // the other side of the box +} + +TEST(PickPin, MissesOutsideTheRadius) { + std::vector shapes = {box(1, {{7, -0.5f, 0.f}})}; + EXPECT_EQ(pickedPort(shapes, {0.f, 35.f + kPinHitRadiusPx + 1.f}), -1); + EXPECT_EQ(pickedPort(shapes, {-kPinTickWorld - kPinHitRadiusPx - 1.f, 35.f}), -1); +} + +TEST(PickPin, BoxBodyAwayFromPinsIsNotAPin) { + // A double-click on the box body must reach the box (full-interface + // expansion), not be swallowed by a pin -- even zoomed far out, where the + // pin's screen radius would otherwise cover most of the box. + std::vector shapes = {box(1, {{7, -0.5f, 0.f}, {8, 0.5f, 0.f}})}; + EXPECT_EQ(pickedPort(shapes, {90.f, 35.f}, 1.f), -1); + EXPECT_EQ(pickedPort(shapes, {90.f, 35.f}, 0.1f), -1); + EXPECT_EQ(pickedPort(shapes, {kPinHitMaxWorld + 5.f, 35.f}, 0.1f), -1); +} + +// The old hit test took the first pin within radius, so with pins closer +// together than the radius a click often landed on the neighbour above. +TEST(PickPin, NearestOfOverlappingPinsWins) { + // Pins 14 world units apart (ly step 0.2 * h 70): closer than the radius. + std::vector shapes = {box(1, {{1, -0.5f, -0.2f}, {2, -0.5f, 0.f}, {3, -0.5f, 0.2f}})}; + const float y1 = 21.f, y2 = 35.f, y3 = 49.f; + EXPECT_EQ(pickedPort(shapes, {-3.f, y1 + 1.f}), 1); + EXPECT_EQ(pickedPort(shapes, {-3.f, y2 + 6.f}), 2); // within reach of 1 and 3 too + EXPECT_EQ(pickedPort(shapes, {-3.f, y3 - 1.f}), 3); + EXPECT_EQ(pickedPort(shapes, {-3.f, y2 - 6.f}), 2); +} + +TEST(PickPin, NearestAcrossDifferentInstances) { + // Two boxes stacked with a small gap; the pin nearest the cursor wins + // regardless of which instance comes first. + std::vector shapes = {box(1, {{1, -0.5f, 0.5f}}), // anchor (0, 70) + box(2, {{2, -0.5f, -0.5f}}, 0.f, 80.f)}; // anchor (0, 80) + EXPECT_EQ(pickedPort(shapes, {-2.f, 72.f}), 1); + EXPECT_EQ(pickedPort(shapes, {-2.f, 78.f}), 2); +} + +TEST(PickPin, RadiusFollowsZoomOnScreenButIsCappedInWorld) { + EXPECT_FLOAT_EQ(pinHitRadius(1.f), kPinHitRadiusPx); + EXPECT_FLOAT_EQ(pinHitRadius(3.f), kPinHitRadiusPx / 3.f); // zoomed in: same 18px on screen + EXPECT_FLOAT_EQ(pinHitRadius(0.2f), kPinHitMaxWorld); // zoomed out: capped + + std::vector shapes = {box(1, {{7, -0.5f, 0.f}})}; + // 15 world units below the anchor: in reach at 1x, not at 3x zoom. + EXPECT_EQ(pickedPort(shapes, {0.f, 50.f}, 1.f), 7); + EXPECT_EQ(pickedPort(shapes, {0.f, 50.f}, 3.f), -1); +} + +TEST(PickPin, TopLevelPortFlagIsGrabbedAlongItsBody) { + std::vector shapes = {flag(1, 9, 100.f, 100.f)}; + EXPECT_EQ(pickedPort(shapes, {100.f, 100.f}), 9); + EXPECT_EQ(pickedPort(shapes, {100.f + kFlagHitLenWorld, 104.f}), 9); + EXPECT_EQ(pickedPort(shapes, {100.f + kFlagHitLenWorld + kPinHitRadiusPx + 1.f, 100.f}), -1); +} + +TEST(PickPin, HierarchyFramesAreSkipped) { + auto frame = box(1, {{7, -0.5f, 0.f}}); + frame.isHierGroup = true; + EXPECT_EQ(pickedPort({frame}, {0.f, 35.f}), -1); +} + +TEST(PickPin, NoShapesNoHit) { + EXPECT_FALSE(pickPin({}, {0.f, 0.f}, 1.f).has_value()); +} + +// --------------------------------------------------------------------------- +// pinRequestKey / PendingRequests +// --------------------------------------------------------------------------- + +TEST(PinRequestKey, DistinguishesPathTermAndBit) { + auto k = pinRequestKey({1, 2}, 3, std::nullopt); + EXPECT_EQ(k, pinRequestKey({1, 2}, 3, std::nullopt)); + EXPECT_NE(k, pinRequestKey({1, 2}, 3, 0)); + EXPECT_NE(k, pinRequestKey({1}, 3, std::nullopt)); + EXPECT_NE(k, pinRequestKey({1, 2}, 4, std::nullopt)); + EXPECT_NE(pinRequestKey({12}, 3, std::nullopt), pinRequestKey({1, 2}, 3, std::nullopt)); + EXPECT_NE(pinRequestKey({1}, 23, std::nullopt), pinRequestKey({12}, 3, std::nullopt)); +} + +TEST(PendingRequests, SecondClickWhileLoadingIsNotResent) { + PendingRequests pending(5.0); + EXPECT_TRUE(pending.begin("a", 10.0)); + EXPECT_TRUE(pending.isPending("a", 10.1)); + EXPECT_FALSE(pending.begin("a", 10.2)); + EXPECT_TRUE(pending.begin("b", 10.2)); // other pins are independent +} + +TEST(PendingRequests, ResolvedRequestIsNoLongerPending) { + PendingRequests pending; + pending.begin("a", 0.0); + pending.resolve("a"); + EXPECT_FALSE(pending.isPending("a", 0.1)); + EXPECT_TRUE(pending.begin("a", 0.2)); +} + +TEST(PendingRequests, UnansweredRequestExpiresSoThePinCanBeRetried) { + PendingRequests pending(5.0); + pending.begin("a", 0.0); + EXPECT_TRUE(pending.isPending("a", 4.9)); + EXPECT_FALSE(pending.isPending("a", 5.0)); + EXPECT_TRUE(pending.begin("a", 6.0)); +} + +TEST(PendingRequests, ClearDropsEverything) { + PendingRequests pending; + pending.begin("a", 0.0); + pending.begin("b", 0.0); + pending.clear(); + EXPECT_FALSE(pending.isPending("a", 0.0)); + EXPECT_FALSE(pending.isPending("b", 0.0)); +}