Signal-type popup, NC pin tagging, interactive viewer hygiene.

- Enter on a signal entry (net / explore) opens a modal popup to pick
  power / gnd / other. Recording is deduped: a sequence of toggles on
  the same signal collapses to a single `set-signal-type` line; no-op
  selections record nothing.
- Bare interactive commands (the ones that open a full-screen mode)
  are no longer recorded by `script-save`. Their inline forms still
  are. Mutating actions inside a screen record their own canonical
  line.
- Mentor importer treats signals whose name starts with `unconnected`
  as no-connect — the pin is kept on the part without a signal and
  tagged `ImportedUnconnected`.
- `drop_singleton_signals` runs at the end of `load`: any signal with
  exactly one pin is detached (singletons are NC by definition); the
  pin is tagged `DroppedSingleton`. Count is reported inline.
- `verify` gains a one-line orphan summary (imported NC / dropped
  singleton totals). Pins materialised by `FillIdentityNCs` are
  excluded via a `pin_map` filter — they are bridged to a real signal
  on the peer module and are not real NCs at system level.
- NcOrigin tag is serialized in save snapshots as an optional 4th
  field on N records (backward-compatible).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 07:56:46 +02:00
parent 043fef0a31
commit 280526304d
16 changed files with 295 additions and 16 deletions

View File

@@ -2,12 +2,26 @@
#include "system/pins.hpp"
#include "system/parts.hpp"
#include <cctype>
#include <vector>
#include <string>
#include <regex>
using namespace std;
// Mentor netlists tag a no-connect pin with a signal name starting with
// 'unconnected' (e.g. 'unconnected', 'unconnected (by TERM)'). We keep the
// pin on the part but leave it unattached — it surfaces as `(NC)` in
// `explore` and is excluded from every net.
static bool is_nc_signal_name(const string &s) {
static const string nc = "unconnected";
if (s.size() < nc.size()) return false;
for (size_t i = 0; i < nc.size(); ++i)
if (std::tolower(static_cast<unsigned char>(s[i])) != nc[i])
return false;
return true;
}
/**
* @brief Enum representing the parsing state.
*
@@ -104,11 +118,15 @@ void ImportMentor::parse(Signals *signals)
if (is_name_match)
{
auto pin = new Pin(names[0]); // Create a new pin with the first name.
Signal *s = nullptr;
prt->add(pin); // Add the pin to the current part.
s = signals->merge(names[2]); // Merge the signal with module signals.
s->add(pin); // Add the pin to the signal pins list.
pin->connect(s); // Connect the pin to a signal.
if (names.size() > 2 && !is_nc_signal_name(names[2])) {
Signal *s = signals->merge(names[2]);
s->add(pin);
pin->connect(s);
} else {
// NC pin — kept on the part with no signal, tagged.
pin->nc_origin = NcOrigin::ImportedUnconnected;
}
}
}
break;

View File

@@ -46,7 +46,10 @@ bool save_system(const System *sys, const std::string &filename, std::string &er
for (auto &nkv : *p) {
Pin *pin = nkv.second;
Signal *s = pin->signal();
f << "N\t" << pin->name << "\t" << (s ? s->name : "") << "\n";
f << "N\t" << pin->name << "\t" << (s ? s->name : "");
const char *otag = nc_origin_tag(pin->nc_origin);
if (*otag) f << "\t" << otag;
f << "\n";
}
}
// Signal types: only persist non-default (Other) overrides.
@@ -127,6 +130,9 @@ System *restore_system(const std::string &filename, std::string &error)
Signal *s = cur_mod->signals->merge(fs[2]);
s->add(pin);
pin->connect(s);
} else if (fs.size() >= 4) {
NcOrigin o;
if (nc_origin_from_tag(fs[3], o)) pin->nc_origin = o;
}
} else if (tag == "S") {
if (!cur_mod) return fail("S outside module");

View File

@@ -6,6 +6,21 @@ Pin::Pin(std::string name)
: SystemElement(name), sig(nullptr), prnt(nullptr),
expected_signal_type(SignalType::Other) {};
const char *nc_origin_tag(NcOrigin o) {
switch (o) {
case NcOrigin::ImportedUnconnected: return "U";
case NcOrigin::DroppedSingleton: return "D";
case NcOrigin::None: return "";
}
return "";
}
bool nc_origin_from_tag(const std::string &tag, NcOrigin &out) {
if (tag == "U") { out = NcOrigin::ImportedUnconnected; return true; }
if (tag == "D") { out = NcOrigin::DroppedSingleton; return true; }
return false;
}
bool Pin::connected()
{
return sig != nullptr;

View File

@@ -10,6 +10,19 @@ class Part;
#pragma once
class Signal;
// Why a pin has no Signal attached. Set at import/post-load; preserved by
// save/restore. Pins materialised by FillIdentityNCs keep `None` — they have
// no local signal but are bridged via a Connection::pin_map and verify is
// expected to filter them out via that bridge, not via this tag.
enum class NcOrigin {
None, ///< pin->sig != nullptr
ImportedUnconnected, ///< Mentor 'unconnected*' encountered at parse
DroppedSingleton, ///< drop_singleton_signals at end of load
};
const char *nc_origin_tag(NcOrigin o); ///< "", "U", "D"
bool nc_origin_from_tag(const std::string &tag, NcOrigin &out);
class Pin : public SystemElement
{
Signal *sig;
@@ -17,6 +30,7 @@ public:
Pin(std::string name);
Part *prnt; ///< Pointer to the parent part.
SignalType expected_signal_type; ///< Set from connector_type at set-type.
NcOrigin nc_origin = NcOrigin::None;
bool connected();
Signal *signal() const { return sig; }
void connect(Signal *signal);

View File

@@ -8,5 +8,6 @@ enum class SignalType { Power, GndShield, Other };
const char *signal_type_name(SignalType t);
bool signal_type_from_name(const std::string &s, SignalType &out);
SignalType infer_signal_type(const std::string &signal_name);
SignalType next_signal_type(SignalType t); // Power → GndShield → Other → Power
#endif // _SIGNAL_TYPE_HPP_

View File

@@ -4,7 +4,7 @@
#include <algorithm>
#include <cctype>
#include <cstring>
#include <vector>
const char *signal_type_name(SignalType t) {
switch (t) {
@@ -15,6 +15,15 @@ const char *signal_type_name(SignalType t) {
return "other";
}
SignalType next_signal_type(SignalType t) {
switch (t) {
case SignalType::Power: return SignalType::GndShield;
case SignalType::GndShield: return SignalType::Other;
case SignalType::Other: return SignalType::Power;
}
return SignalType::Other;
}
bool signal_type_from_name(const std::string &s, SignalType &out) {
std::string l = s;
std::transform(l.begin(), l.end(), l.begin(),
@@ -83,6 +92,23 @@ Signals::~Signals() {
}
}
int drop_singleton_signals(Signals *signals) {
if (!signals) return 0;
std::vector<Signal *> doomed;
for (auto &kv : *signals)
if (kv.second->size() == 1) doomed.push_back(kv.second);
for (Signal *s : doomed) {
// Detach the lone pin so it surfaces as `(NC)` in views.
for (auto &pkv : *s) {
pkv.second->connect(nullptr);
pkv.second->nc_origin = NcOrigin::DroppedSingleton;
}
signals->remove(s->name);
delete s;
}
return (int)doomed.size();
}
void Signals::add(Signal *signal)
{
SystemElementContainer<Signal>::add(signal);

View File

@@ -26,4 +26,9 @@ public:
~Signals();
};
// Drop every signal whose pin set is of size 1 — by definition unconnected.
// The lone pin is detached (sig=nullptr) and the Signal object is removed
// from the container and deleted. Returns the number of signals dropped.
int drop_singleton_signals(Signals *signals);
#endif // _SIGNALS_HPP_

View File

@@ -161,6 +161,16 @@ public:
* @param name Name of the element.
* @return Pointer to the merged or newly created element.
*/
/**
* @brief Removes the element with the given name from the container.
* The element itself is NOT deleted — caller owns it.
* @return True if an element was removed, false if the name was absent.
*/
bool remove(string name)
{
return content.erase(name) > 0;
}
T *merge(string name)
{
if (exists(name))

View File

@@ -18,6 +18,7 @@
#include <cstdlib>
#include <exception>
#include <fstream>
#include <unordered_set>
#include <utility>
void Tui::RegisterCommands() {
@@ -132,9 +133,12 @@ void Tui::RegisterCommands() {
try {
sys->Load(args[0], args[1], t);
Module *mod = sys->modules()->get(args[0]);
int dropped = drop_singleton_signals(mod->signals);
Print("loaded '" + args[0] + "' from " + args[1]);
Print(" parts: " + std::to_string(mod->size()));
Print(" signals: " + std::to_string(mod->signals->size()));
Print(" signals: " + std::to_string(mod->signals->size())
+ (dropped ? " (dropped " + std::to_string(dropped)
+ " singleton/NC signal(s))" : ""));
} catch (const std::exception &e) {
Print(std::string("load failed: ") + e.what());
}
@@ -249,6 +253,30 @@ void Tui::RegisterCommands() {
Print("verify: " + std::to_string(inconsistent) + " inconsistent net(s) over "
+ std::to_string(bridged) + " bridged net(s) ("
+ std::to_string(nets.size()) + " total).");
// Orphan pin report. A pin is "orphan" if it came out of import (or
// post-import drop) with no signal, and is still not bridged to a
// real signal via any Connection::pin_map. Use `nc-export` for the
// per-pin list.
std::unordered_set<Pin *> bridged_pins;
for (auto &ckv : *sys->connections())
for (auto &wp : ckv.second->pin_map) {
if (wp.first) bridged_pins.insert(wp.first);
if (wp.second) bridged_pins.insert(wp.second);
}
int orph_imported = 0, orph_dropped = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second)
for (auto &nkv : *pkv.second) {
Pin *pin = nkv.second;
if (pin->signal() || bridged_pins.count(pin)) continue;
if (pin->nc_origin == NcOrigin::ImportedUnconnected) ++orph_imported;
else if (pin->nc_origin == NcOrigin::DroppedSingleton) ++orph_dropped;
}
Print("verify: " + std::to_string(orph_imported + orph_dropped)
+ " orphan pin(s) at import ("
+ std::to_string(orph_imported) + " imported NC, "
+ std::to_string(orph_dropped) + " dropped singleton).");
}, true,
"check pin roles locally and signal-type consistency across bridged nets" };
@@ -653,6 +681,7 @@ void Tui::RegisterCommands() {
Pin *sn = nkv.second;
Pin *dn = new Pin(sn->name);
dn->expected_signal_type = sn->expected_signal_type;
dn->nc_origin = sn->nc_origin;
dp->add(dn);
if (sn->signal()) {
Signal *ds = dst->signals->get(sn->signal()->name);

View File

@@ -29,9 +29,30 @@ Component Tui::BuildExploreScreen() {
auto module_menu = Menu(&explore_modules, &explore_module_idx);
auto type_menu = Menu(&explore_types, &explore_type_idx);
auto child_filter = Input(&explore_child_filter, "filter…", pf_opt);
auto children_menu = Menu(&explore_children, &explore_child_idx);
MenuOption child_opt = MenuOption::Vertical();
child_opt.entries = &explore_children;
child_opt.selected = &explore_child_idx;
child_opt.on_enter = [this]() {
if (explore_type_idx != 1 || explore_children.empty()) return;
OpenSignalTypeDialog(explore_modules[explore_module_idx],
explore_children[explore_child_idx]);
};
auto children_menu = Menu(child_opt);
auto detail_filter = Input(&explore_detail_filter, "filter…", pf_opt);
auto detail_menu = Menu(&explore_detail, &explore_detail_idx);
MenuOption detail_opt = MenuOption::Vertical();
detail_opt.entries = &explore_detail;
detail_opt.selected = &explore_detail_idx;
detail_opt.on_enter = [this]() {
if (explore_detail_idx < 0
|| explore_detail_idx >= (int)explore_detail_sig.size()) return;
const std::string &sig = explore_detail_sig[explore_detail_idx];
if (sig.empty()) return;
OpenSignalTypeDialog(explore_modules[explore_module_idx], sig);
};
auto detail_menu = Menu(detail_opt);
auto components = Container::Vertical(
{module_menu, type_menu, child_filter, children_menu, detail_filter, detail_menu},
@@ -93,6 +114,7 @@ Component Tui::BuildExploreScreen() {
// bound to it can scroll with arrow keys when focused).
explore_header = "(no system)";
explore_detail.clear();
explore_detail_sig.clear();
if (cur_mod && !explore_children.empty()) {
const std::string &cname = explore_children[explore_child_idx];
try {
@@ -116,8 +138,12 @@ Component Tui::BuildExploreScreen() {
std::string line = " " + r.first
+ std::string(maxw - r.first.size() + 2, ' ')
+ r.second;
if (keep_detail(line))
if (keep_detail(line)) {
explore_detail.push_back(line);
// "(NC)" → no underlying Signal to retype.
explore_detail_sig.push_back(
r.second == "(NC)" ? std::string{} : r.second);
}
}
} else if (explore_type_idx == 1) {
Signal *s = cur_mod->signals->get(cname);
@@ -165,6 +191,9 @@ Component Tui::BuildExploreScreen() {
}
if (explore_detail.empty()) explore_detail.push_back("(empty)");
// Pad the parallel sig vector so any index into explore_detail is safe.
while (explore_detail_sig.size() < explore_detail.size())
explore_detail_sig.push_back({});
if (explore_detail_idx < 0
|| explore_detail_idx >= (int)explore_detail.size()) {
explore_detail_idx = 0;
@@ -207,7 +236,7 @@ Component Tui::BuildExploreScreen() {
title,
separator(),
hbox({col1, separator(), col2, separator(), col3, separator(), col4}) | flex,
text(" Tab: cycle focus (incl. detail to scroll) | Esc: leave explore ") | dim,
text(" Tab: cycle focus | Enter (on a signal): set signal type | Esc: leave ") | dim,
}) | border;
} catch (const std::exception &e) {
return vbox({

View File

@@ -25,7 +25,15 @@ Component Tui::BuildNetScreen() {
};
auto filter_input = Input(&net_sig_filter, "filter signals…", filter_opt);
auto module_menu = Menu(&net_modules, &net_module_idx);
auto signal_menu = Menu(&net_sigs, &net_sig_idx);
MenuOption sig_opt = MenuOption::Vertical();
sig_opt.entries = &net_sigs;
sig_opt.selected = &net_sig_idx;
sig_opt.on_enter = [this]() {
if (net_modules.empty() || net_sigs.empty()) return;
OpenSignalTypeDialog(net_modules[net_module_idx], net_sigs[net_sig_idx]);
};
auto signal_menu = Menu(sig_opt);
auto components = Container::Vertical(
{filter_input, module_menu, signal_menu}, &net_focus_idx);
@@ -113,7 +121,7 @@ Component Tui::BuildNetScreen() {
title,
separator(),
hbox({left, separator(), middle, separator(), right}) | flex,
text(" Tab: cycle focus (filter ↔ module ↔ signal) | Esc: leave net ") | dim,
text(" Tab: cycle focus | Enter (on signal): set signal type | Esc: leave ") | dim,
}) | border;
});
}

View File

@@ -0,0 +1,35 @@
#include "tui/tui.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp>
#include <ftxui/component/event.hpp>
#include <ftxui/dom/elements.hpp>
using namespace ftxui;
Component Tui::BuildSignalTypeModal() {
sigtype_dialog_entries = {"power", "gnd", "other"};
MenuOption opt = MenuOption::Vertical();
opt.entries = &sigtype_dialog_entries;
opt.selected = &sigtype_dialog_choice;
opt.on_enter = [this]() { ApplySignalTypeChoice(); };
auto menu = Menu(opt);
auto with_esc = CatchEvent(menu, [this](Event e) {
if (e == Event::Escape) { sigtype_dialog_open = false; return true; }
return false;
});
return Renderer(with_esc, [this, menu] {
return vbox({
text(" signal type ") | bold,
separator(),
text(sigtype_dialog_mod + "/" + sigtype_dialog_sig) | dim,
separator(),
menu->Render(),
separator(),
text(" ↑/↓ select • Enter apply • Esc cancel ") | dim,
}) | border | size(WIDTH, GREATER_THAN, 32);
});
}

View File

@@ -1,9 +1,14 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "system/modules.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <cctype>
#include <chrono>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <fstream>
#include <ostream>
@@ -11,6 +16,55 @@
#include <system_error>
#include <thread>
void Tui::OpenSignalTypeDialog(const std::string &mod_name,
const std::string &sig_name) {
if (!sys) return;
Signal *sig = nullptr;
try {
Module *m = sys->modules()->get(mod_name);
sig = m->signals->get(sig_name);
} catch (const std::exception &) { return; }
sigtype_dialog_mod = mod_name;
sigtype_dialog_sig = sig_name;
switch (sig->type) {
case SignalType::Power: sigtype_dialog_choice = 0; break;
case SignalType::GndShield: sigtype_dialog_choice = 1; break;
default: sigtype_dialog_choice = 2; break;
}
sigtype_dialog_open = true;
}
void Tui::ApplySignalTypeChoice() {
sigtype_dialog_open = false;
if (!sys) return;
SignalType t;
switch (sigtype_dialog_choice) {
case 0: t = SignalType::Power; break;
case 1: t = SignalType::GndShield; break;
default: t = SignalType::Other; break;
}
Signal *sig = nullptr;
try {
Module *m = sys->modules()->get(sigtype_dialog_mod);
sig = m->signals->get(sigtype_dialog_sig);
} catch (const std::exception &) { return; }
if (sig->type == t) return; // no-op, no record
sig->type = t;
if (in_source) return;
// Dedup: if the immediately previous recorded line targets the same
// signal, replace it so a sequence of toggles collapses to one line.
std::string line = "set-signal-type " + sigtype_dialog_mod + " "
+ sigtype_dialog_sig + " " + signal_type_name(t);
std::string prefix = "set-signal-type " + sigtype_dialog_mod + " "
+ sigtype_dialog_sig + " ";
if (!recorded.empty() && recorded.back().rfind(prefix, 0) == 0)
recorded.back() = std::move(line);
else
recorded.push_back(std::move(line));
}
void Tui::Print(const std::string &line) {
output.push_back(line);
scroll_offset = 0; // any new line snaps the view back to the tail
@@ -136,7 +190,12 @@ void Tui::Finalize(const std::string &name,
static const std::set<std::string> no_record = {
"clear", "help", "quit", "exit", "source", "script-save",
};
if (spec.scriptable && !no_record.count(name)) recorded.push_back(canonical);
// A bare invocation of an `interactive` command opens a full-screen mode
// rather than mutating state — skip it. Any mutating action taken inside
// that screen records its own canonical line via the action callbacks.
bool opens_screen = spec.interactive && args.empty();
if (spec.scriptable && !opens_screen && !no_record.count(name))
recorded.push_back(canonical);
}
std::string Tui::ExpandVars(const std::string &s) const {

View File

@@ -41,8 +41,10 @@ void Tui::Run() {
auto search_screen = BuildSearchScreen();
auto connect_screen = BuildConnectScreen();
auto settype_screen = BuildSettypeScreen();
auto explore_screen = BuildExploreScreen();
auto net_screen = BuildNetScreen();
auto explore_screen = BuildExploreScreen() | Modal(BuildSignalTypeModal(),
&sigtype_dialog_open);
auto net_screen = BuildNetScreen() | Modal(BuildSignalTypeModal(),
&sigtype_dialog_open);
auto tab = Container::Tab(
{main_screen, search_screen, connect_screen, settype_screen, explore_screen,
@@ -50,6 +52,10 @@ void Tui::Run() {
&screen_idx);
auto root = CatchEvent(tab, [this](Event e) {
// The signal-type popup must own Escape / Tab while it's open so the
// outer switch doesn't yank us back to the main screen.
if (sigtype_dialog_open) return false;
switch (screen_idx) {
case 5: // net
if (e == Event::Escape) { screen_idx = 0; return true; }

View File

@@ -86,6 +86,7 @@ class Tui {
std::string explore_child_filter;
std::string explore_detail_filter;
std::vector<std::string> explore_detail;
std::vector<std::string> explore_detail_sig; ///< parallel: signal name per detail line (empty = no signal)
int explore_detail_idx;
std::string explore_header;
int explore_focus_idx;
@@ -109,6 +110,13 @@ class Tui {
int net_sig_idx;
int net_focus_idx;
// ---- Signal-type popup (shared between net + explore screens) ----
bool sigtype_dialog_open = false;
std::string sigtype_dialog_mod;
std::string sigtype_dialog_sig;
std::vector<std::string> sigtype_dialog_entries; ///< ["power","gnd","other"]
int sigtype_dialog_choice = 0;
// ---- Set-type screen state ----
std::vector<std::string> settype_modules;
int settype_m_idx;
@@ -150,6 +158,14 @@ private:
void CompletePath(size_t start = 0);
void CompleteInline();
// Open the signal-type popup for <mod>/<sig> (no-op if names don't resolve).
void OpenSignalTypeDialog(const std::string &mod_name,
const std::string &sig_name);
// Apply the selected type to the targeted signal, record a
// `set-signal-type` line (deduping consecutive edits of the same signal),
// and close the popup.
void ApplySignalTypeChoice();
// Filtered part list rebuild (used by connect & set-type screens)
void RefreshFilteredPartList(const std::vector<std::string> &modules,
int m_idx,
@@ -164,6 +180,7 @@ private:
ftxui::Component BuildSettypeScreen();
ftxui::Component BuildExploreScreen();
ftxui::Component BuildNetScreen();
ftxui::Component BuildSignalTypeModal();
};
#endif // _TUI_HPP_

View File

@@ -3,6 +3,7 @@
#include <algorithm>
#include <cctype>
std::string ToLower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return std::tolower(c); });