build: split core/ from frontends/; prepare for multiple GUI/TUI targets

Reorganise the tree into business vs frontend as separate directories:
  src/core/{domain,imports,app}   (was system/, imports/, app/)
  src/frontends/tui/              (was tui/ + main.cpp)
  tests/tui/                      (the FTXUI-coupled helper test)
All cross-dir #include paths rewritten; same-dir includes untouched.

CMake: essim_core is the frontend-agnostic business library — links libzip,
pugixml and bsdl, NO GUI toolkit. Each frontend is a self-contained
src/frontends/<name>/ (own CMakeLists, toolkit, main.cpp) that links
essim_core, selected with -DESSIM_FRONTEND=<name> (default tui; 'none' = core +
tests only, no toolkit fetched). FTXUI moved into the tui frontend. Tests are
split: essim_tests links essim_core (no FTXUI), essim_tui_tests links essim_tui.

Verified: default tui build green (ctest 2/2); ESSIM_FRONTEND=none builds the
core + tests with FTXUI never fetched and no `essim` binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:33:06 +02:00
parent 3010bb25eb
commit 63ca17d048
83 changed files with 282 additions and 228 deletions

View File

@@ -0,0 +1,757 @@
#include "frontends/tui/tui.hpp"
#include "frontends/tui/tui_helpers.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/nets.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/pin_role.hpp"
#include "core/domain/pin_model.hpp"
#include "core/domain/bsdl_model.hpp"
#include "core/domain/bsdl_check.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform.hpp"
#include "core/domain/transform_vpx.hpp"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <exception>
#include <fstream>
#include <unordered_set>
#include <utility>
void Tui::RegisterCommands() {
commands["help"] = {
{{"command name (optional)", Completion::Command}},
[this](const std::vector<std::string> &args) {
if (args.empty()) {
// Bare → textual list of commands. The feature-reference
// screen lives at `screen_idx = 6` and is reachable from
// the dashboard with `[h]`.
size_t maxw = 0;
for (const auto &kv : commands) maxw = std::max(maxw, kv.first.size());
auto print_group = [&](const std::string &title, bool want_interactive) {
bool printed_any = false;
for (const auto &kv : commands) {
if (kv.second.hidden) continue;
if (kv.second.interactive != want_interactive) continue;
if (!printed_any) { Print(title); printed_any = true; }
Print(" " + kv.first
+ std::string(maxw - kv.first.size() + 2, ' ')
+ kv.second.description);
}
};
Print("Commands — type `help <name>` for details.");
print_group("Interactive (open a full-screen mode):", true);
print_group("Other:", false);
Print("Keys: Esc cancels a multi-step prompt or returns to the dashboard;");
Print(" Tab completes commands/paths or cycles focus in screens;");
Print(" PageUp/PageDown scroll output, Home/End jump to top/bottom.");
return;
}
const std::string &name = args[0];
auto it = commands.find(name);
if (it == commands.end()) { Print("unknown command: " + name); return; }
const auto &spec = it->second;
std::string tag = spec.interactive ? " [interactive]" : "";
Print(name + tag + "" + spec.description);
if (spec.params.empty()) {
Print(" no arguments.");
} else {
for (size_t i = 0; i < spec.params.size(); ++i) {
Print(" arg " + std::to_string(i + 1) + ": " + spec.params[i].name);
}
}
if (spec.interactive) {
Print(" run with no args to open the interactive screen,");
Print(" or with all args for inline (scriptable) execution.");
} else if (!spec.prompt_for_missing) {
Print(" no per-arg prompt — provide all args inline (or use the bare form).");
} else if (!spec.params.empty()) {
Print(" missing args trigger a prompt for each one.");
}
},
/*prompt_for_missing=*/ false,
"list commands (or `help <name>` for one command's details)",
};
commands["clear"] = { {}, [this](auto &) { output.clear(); }, true,
"clear the console output" };
// quit / exit work from any screen: set the flag *and* call Exit() on the
// captured ScreenInteractive so the FTXUI loop returns immediately. The
// legacy main-screen Renderer also reads `quit` as a belt-and-braces
// backup when the screen_ptr hasn't been set yet (early-init / tests).
auto do_quit = [this](auto &) {
quit = true;
if (screen_ptr) screen_ptr->Exit();
};
commands["quit"] = { {}, do_quit, true, "leave essim" };
commands["exit"] = { {}, do_quit, true, "leave essim (alias of quit)" };
commands["new"] = { {}, [this](auto &) {
sys = std::make_unique<System>();
recorded.clear();
vars.clear();
Print("system created.");
}, true, "create a new (empty) system; resets the script-save buffer and $vars" };
commands["set"] = {
{{"name", Completion::None},
{"value", Completion::None}},
[this](const std::vector<std::string> &args) {
if (args.empty()) {
if (vars.empty()) { Print("(no variables defined)"); return; }
for (const auto &kv : vars)
Print(" $" + kv.first + " = " + kv.second);
return;
}
if (args.size() != 2) {
Print("usage: set <name> <value> (or no args to list)");
return;
}
const std::string &name = args[0];
if (name.empty()) { Print("set: empty name"); return; }
for (size_t i = 0; i < name.size(); ++i) {
char c = name[i];
bool ok = std::isalnum((unsigned char)c) || c == '_';
bool first_ok = i == 0 ? !std::isdigit((unsigned char)c) : true;
if (!ok || !first_ok) {
Print("set: invalid name '" + name
+ "' (must match [A-Za-z_][A-Za-z0-9_]*)");
return;
}
}
vars[name] = args[1];
},
/*prompt_for_missing=*/ false,
"define a $variable for substitution in subsequent commands "
"(no args = list defined vars)",
};
commands["load"] = {
{{"module name", Completion::None},
{"filename", Completion::Path},
{"import type [mentor|altium|ods]", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
std::string ls = ToLower(args[2]);
ImportType t;
if (ls == "mentor") t = ImportType::IMPORT_MENTOR;
else if (ls == "altium") t = ImportType::IMPORT_ALTIUM;
else if (ls == "ods") t = ImportType::IMPORT_ODS;
else { Print("unknown import type: " + args[2]); return; }
try {
sys->Load(args[0], args[1], t);
Module *mod = sys->modules()->get(args[0]);
int dropped = drop_singleton_signals(mod->signals);
auto inf = infer_signal_types(sys.get());
Print("loaded '" + args[0] + "' from " + args[1]);
Print(" parts: " + std::to_string(mod->size()));
Print(" signals: " + std::to_string(mod->signals->size())
+ (dropped ? " (dropped " + std::to_string(dropped)
+ " singleton/NC signal(s))" : ""));
Print(" types: " + std::to_string(inf.power) + " power, "
+ std::to_string(inf.gnd) + " gnd, "
+ std::to_string(inf.kept_other)
+ " suspect Power (name only — kept as Other)");
} catch (const std::exception &e) {
Print(std::string("load failed: ") + e.what());
}
},
/*prompt_for_missing=*/ true,
"load a module from a netlist / pinout file (mentor, altium, ods)",
};
commands["source"] = {
{{"filename", Completion::Path}},
[this](const std::vector<std::string> &args) {
Source(args[0]);
},
/*prompt_for_missing=*/ true,
"execute a file of commands line by line (interactive cmds rejected)",
};
commands["script-save"] = {
{{"filename", Completion::Path}},
[this](const std::vector<std::string> &args) {
std::string expanded = args[0];
if (!expanded.empty() && expanded[0] == '~') {
if (const char *home = std::getenv("HOME"))
expanded = std::string(home) + expanded.substr(1);
}
std::ofstream f(expanded);
if (!f) { Print("script-save: cannot open " + args[0]); return; }
for (const auto &cmd : recorded) f << cmd << '\n';
Print("script-save: " + std::to_string(recorded.size())
+ " line(s) → " + args[0]);
},
/*prompt_for_missing=*/ true,
"write commands run since last 'new' as a replay-ready script",
};
commands["save"] = {
{{"filename", Completion::Path}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
std::string err;
if (save_system(sys.get(), args[0], err)) {
Print("saved to " + args[0]);
} else {
Print("save failed: " + err);
}
},
/*prompt_for_missing=*/ true,
"write the current system snapshot to a file",
};
commands["restore"] = {
{{"filename", Completion::Path}},
[this](const std::vector<std::string> &args) {
std::string err;
System *fresh = restore_system(args[0], err);
if (!fresh) { Print("restore failed: " + err); return; }
sys.reset(fresh);
int mods = (int)sys->modules()->size();
int conns = (int)sys->connections()->size();
Print("restored from " + args[0]
+ " (" + std::to_string(mods) + " module(s), "
+ std::to_string(conns) + " connection(s))");
},
/*prompt_for_missing=*/ true,
"replace the current system with a saved snapshot",
};
commands["verify"] = { {}, [this](auto &) {
if (!sys) { Print("no system: run 'new' first."); return; }
int checked = 0;
int mismatches = 0;
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
for (auto &pkv : *mod) {
Part *prt = pkv.second;
if (prt->connector_type.empty()) continue;
for (auto &nkv : *prt) {
Pin *pin = nkv.second;
++checked;
SignalType expected = pin->expected_signal_type();
if (expected == SignalType::Other) continue;
Signal *s = pin->signal();
SignalType actual = s ? s->type : SignalType::Other;
if (actual == expected) continue;
++mismatches;
std::string sig_label = s ? s->name : std::string("(NC)");
Print(" " + mod->name + "/" + prt->name + "/" + pin->name
+ ": expected " + signal_type_name(expected)
+ ", got " + signal_type_name(actual)
+ " (signal: " + sig_label + ")");
}
}
}
Print("verify: " + std::to_string(mismatches) + " local mismatch(es) over "
+ std::to_string(checked) + " typed pin(s).");
auto nets = compute_all_nets(sys.get());
int bridged = 0, inconsistent = 0;
for (const auto &n : nets) {
if (n.members.size() < 2) continue;
++bridged;
SignalType dom;
if (net_type_consistent(n, dom)) continue;
++inconsistent;
std::string line = " net mixes Power and GndShield:";
for (const auto &mp : n.members) {
line += " " + mp.first->name + "/" + mp.second->name
+ "(" + signal_type_name(mp.second->type) + ")";
}
Print(line);
}
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).");
// Model-driven pin checks (drive contention / undriven net / NC-wired)
// from the PinSpec direction/function populated by connector/BSDL models.
auto pin_anoms = check_pin_specs(sys.get(), &nets);
for (const auto &a : pin_anoms)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(pin_anoms.size())
+ " model-driven pin anomaly(ies).");
// JTAG boundary-scan chain integrity (TAP pins → nets).
auto jtag_anoms = check_jtag_chain(sys.get(), &nets);
for (const auto &a : jtag_anoms)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(jtag_anoms.size())
+ " JTAG chain anomaly(ies).");
// Model-vs-netlist conflicts (e.g. a BSDL power pin left unconnected).
auto conflict_anoms = check_source_conflicts(sys.get());
for (const auto &a : conflict_anoms)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(conflict_anoms.size())
+ " source-conflict(s).");
// BSDL completeness: device power/ground pins missing from the netlist.
auto missing_anoms = check_bsdl_completeness(sys.get());
for (const auto &a : missing_anoms)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(missing_anoms.size())
+ " BSDL completeness issue(s).");
}, true,
"check pin roles, power/gnd net consistency, and (with BSDL) pin and JTAG checks" };
commands["dashboard"] = { {}, [this](auto &) {
screen_idx = 4;
}, true,
"open the dashboard (system overview)",
/*scriptable=*/ false,
/*interactive=*/ true };
commands["analyze"] = { {}, [this](auto &) {
if (!sys) { Print("no system: run 'new' first."); return; }
AnalysisReport rep = analyze_system(sys.get());
int n_diff = 0, n_bus = 0;
for (const auto &g : rep.groups) {
if (g.kind == GroupKind::DiffPair) ++n_diff;
else if (g.kind == GroupKind::Bus) ++n_bus;
}
int n_dp_orph = 0, n_bus_gap = 0;
for (const auto &a : rep.anomalies) {
if (a.kind == AnomalyKind::DiffPairOrphan) ++n_dp_orph;
else if (a.kind == AnomalyKind::BusGap) ++n_bus_gap;
}
Print("analyze: " + std::to_string(n_diff) + " diff pair(s), "
+ std::to_string(n_bus) + " bus(es).");
// Sort groups by module then label so output is stable.
auto by_label = [](const SignalGroup &a, const SignalGroup &b) {
std::string ma = a.module ? a.module->name : std::string{};
std::string mb = b.module ? b.module->name : std::string{};
if (ma != mb) return ma < mb;
return a.label < b.label;
};
auto groups = rep.groups; // copy: report stays untouched
std::sort(groups.begin(), groups.end(), by_label);
for (const auto &g : groups) {
std::string mname = g.module ? g.module->name : std::string("?");
std::string line = " " + mname + "/" + g.label
+ " [" + group_kind_name(g.kind) + "]"
+ "" + std::to_string(g.members.size())
+ " signal(s)";
Print(line);
}
if (rep.anomalies.empty()) {
Print("analyze: no anomaly.");
} else {
Print("analyze: " + std::to_string(rep.anomalies.size())
+ " anomaly(ies) ("
+ std::to_string(n_dp_orph) + " diff-pair orphan, "
+ std::to_string(n_bus_gap) + " bus gap):");
for (const auto &a : rep.anomalies)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] "
+ a.message);
}
}, true,
"detect signal groups (diff pairs, buses) and structural anomalies" };
commands["set-signal-type"] = {
{{"module", Completion::None},
{"signal name", Completion::None},
{"type [power|gnd|other]", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
Module *mod;
try { mod = sys->modules()->get(args[0]); }
catch (const std::exception &) {
Print("unknown module: " + args[0]); return;
}
Signal *sig;
try { sig = mod->signals->get(args[1]); }
catch (const std::exception &) {
Print("unknown signal: " + mod->name + "/" + args[1]); return;
}
SignalType t;
if (!signal_type_from_name(args[2], t)) {
Print("type must be one of: power, gnd, other (got: " + args[2] + ")");
return;
}
sig->type = t;
Print(mod->name + "/" + sig->name + ": signal type = "
+ signal_type_name(t));
},
/*prompt_for_missing=*/ true,
"override the auto-detected signal type (power | gnd | other)",
};
commands["set-connector-type"] = {
{{"module", Completion::None},
{"part (name or pattern)", Completion::None},
{"connector type (free string, e.g. vpx-bp, vpx-payload)", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
if (args.empty()) {
settype_modules.clear();
for (auto &m : *sys->modules()) settype_modules.push_back(m.first);
std::sort(settype_modules.begin(), settype_modules.end(), NaturalLess);
if (settype_modules.empty()) { Print("no modules loaded."); return; }
settype_m_idx = 0;
settype_p_filter.clear();
settype_p_idx = 0;
settype_type.clear();
settype_status.clear();
settype_focus_idx = 0;
screen_back_idx = -1; // standalone entry — Esc → dashboard
screen_idx = 2;
return;
}
if (args.size() != 3) {
Print("usage: set-connector-type <module> <part> <kind> (or no args for interactive)");
return;
}
Module *mod;
try { mod = sys->modules()->get(args[0]); }
catch (const std::exception &) {
Print("unknown module: " + args[0]); return;
}
Part *prt = nullptr;
try { prt = mod->get(args[1]); }
catch (const std::exception &) {
std::string needle = ToLower(args[1]);
std::vector<Part *> matches;
for (auto &p : *mod)
if (ToLower(p.first).find(needle) != std::string::npos)
matches.push_back(p.second);
if (matches.size() == 1) prt = matches[0];
else {
Print(std::to_string(matches.size())
+ " match(es) for part '" + args[1] + "' in " + mod->name);
return;
}
}
std::string err = ValidatePartForKind(prt, args[2]);
if (!err.empty()) {
Print("set-connector-type refused: " + err);
return;
}
prt->connector_type = args[2];
ConnectorModel model(args[2]);
ApplyReport rep = apply_model(prt, model);
Print(mod->name + "/" + prt->name + ": connector_type = "
+ (args[2].empty() ? "(none)" : args[2]));
if (rep.materialised > 0)
Print("set-connector-type: added " + std::to_string(rep.materialised)
+ " NC pin(s) from the connector layout");
},
/*prompt_for_missing=*/ false,
"tag a part's connector type (tells connect how to wire its pins)",
/*scriptable=*/ true,
/*interactive=*/ true,
};
commands["attach-bsdl"] = {
{{"module", Completion::None},
{"part (name or pattern)", Completion::None},
{"bsdl file (.bsd path)", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
if (args.size() != 3) {
Print("usage: attach-bsdl <module> <part> <file.bsd>");
return;
}
Module *mod;
try { mod = sys->modules()->get(args[0]); }
catch (const std::exception &) {
Print("unknown module: " + args[0]); return;
}
Part *prt = nullptr;
try { prt = mod->get(args[1]); }
catch (const std::exception &) {
std::string needle = ToLower(args[1]);
std::vector<Part *> matches;
for (auto &p : *mod)
if (ToLower(p.first).find(needle) != std::string::npos)
matches.push_back(p.second);
if (matches.size() == 1) prt = matches[0];
else {
Print(std::to_string(matches.size())
+ " match(es) for part '" + args[1] + "' in " + mod->name);
return;
}
}
BsdlModel model = BsdlModel::from_file(args[2]);
if (!model.valid()) {
Print("attach-bsdl: cannot parse " + args[2]
+ (model.error().empty() ? "" : (": " + model.error())));
return;
}
BsdlApplyReport r = apply_bsdl(prt, model);
prt->bsdl_path = args[2];
Print(mod->name + "/" + prt->name + ": attached BSDL '" + model.entity()
+ "' — " + std::to_string(r.bound) + "/"
+ std::to_string((int)model.ports().size()) + " ports bound"
+ (r.unbound ? (", " + std::to_string(r.unbound) + " unbound") : ""));
},
/*prompt_for_missing=*/ false,
"attach a BSDL (.bsd) model to a part (fills in each pin's role and direction)",
/*scriptable=*/ true,
/*interactive=*/ false,
};
commands["connect"] = {
{{"module1", Completion::None},
{"part1 (name or pattern)", Completion::None},
{"module2", Completion::None},
{"part2 (name or pattern)", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
if (args.empty()) {
connect_modules.clear();
for (auto &m : *sys->modules()) connect_modules.push_back(m.first);
std::sort(connect_modules.begin(), connect_modules.end(), NaturalLess);
if (connect_modules.empty()) { Print("no modules loaded."); return; }
connect_m1_idx = 0;
connect_m2_idx = std::min(1, (int)connect_modules.size() - 1);
connect_p1_filter.clear();
connect_p2_filter.clear();
connect_p1_idx = 0;
connect_p2_idx = 0;
connect_focus_idx = 0;
screen_idx = 1;
return;
}
if (args.size() != 4) {
Print("usage: connect <m1> <p1> <m2> <p2> (or no args for interactive)");
return;
}
auto resolve_module = [this](const std::string &name)
-> std::pair<Module*, std::vector<std::string>> {
try { return {sys->modules()->get(name), {}}; }
catch (const std::exception &) {}
std::string needle = ToLower(name);
std::vector<Module*> matches;
std::vector<std::string> names;
for (auto &m : *sys->modules()) {
if (ToLower(m.first).find(needle) != std::string::npos) {
matches.push_back(m.second);
names.push_back(m.first);
}
}
if (matches.size() == 1) return {matches[0], {}};
return {nullptr, names};
};
auto resolve_part = [](Module *mod, const std::string &name)
-> std::pair<Part*, std::vector<std::string>> {
try { return {mod->get(name), {}}; }
catch (const std::exception &) {}
std::string needle = ToLower(name);
std::vector<Part*> matches;
std::vector<std::string> names;
for (auto &p : *mod) {
if (ToLower(p.first).find(needle) != std::string::npos) {
matches.push_back(p.second);
names.push_back(p.first);
}
}
if (matches.size() == 1) return {matches[0], {}};
return {nullptr, names};
};
auto report_ambiguous = [this](const std::string &what,
const std::string &needle,
const std::vector<std::string> &names) {
if (names.empty()) {
Print(what + " not found: " + needle);
} else {
Print(what + " ambiguous for '" + needle + "': "
+ std::to_string(names.size()) + " match(es)");
int shown = 0;
for (const auto &n : names) {
if (shown++ >= 8) { Print(""); break; }
Print(" " + n);
}
}
};
auto [m1, m1_alts] = resolve_module(args[0]);
if (!m1) { report_ambiguous("module", args[0], m1_alts); return; }
auto [p1, p1_alts] = resolve_part(m1, args[1]);
if (!p1) { report_ambiguous("part in " + m1->name, args[1], p1_alts); return; }
auto [m2, m2_alts] = resolve_module(args[2]);
if (!m2) { report_ambiguous("module", args[2], m2_alts); return; }
auto [p2, p2_alts] = resolve_part(m2, args[3]);
if (!p2) { report_ambiguous("part in " + m2->name, args[3], p2_alts); return; }
auto &reg = TransformRegistry::get();
Transform *t = reg.lookup(p1->connector_type, p2->connector_type);
bool both_empty = p1->connector_type.empty() && p2->connector_type.empty();
if (t == reg.identity()) {
if (!both_empty) {
Print("connect refused: no transform for types '"
+ (p1->connector_type.empty() ? "(none)" : p1->connector_type)
+ "' ↔ '"
+ (p2->connector_type.empty() ? "(none)" : p2->connector_type)
+ "'. Set matching types via 'set-connector-type' first.");
return;
}
std::string info;
std::string err = CheckIdentityCompatible(p1, p2, &info);
if (!err.empty()) {
Print("connect refused: " + err);
return;
}
if (!info.empty()) {
int added = FillIdentityNCs(p1, p2);
Print("connect: " + info);
if (added > 0)
Print("connect: added " + std::to_string(added)
+ " NC pin(s) so both sides match");
}
}
auto pin_map = t->apply(p1, p2);
std::string conn_name = m1->name + "/" + p1->name
+ " <-> " + m2->name + "/" + p2->name;
try {
Connection *c = new Connection(conn_name, m1, p1, m2, p2);
c->transform_name = t->name;
c->pin_map = std::move(pin_map);
sys->connections()->add(c);
Print("connected: " + conn_name
+ " via " + t->name
+ " (" + std::to_string(c->pin_map.size()) + " wires)");
} catch (const std::exception &e) {
Print(std::string("connect failed: ") + e.what());
}
},
/*prompt_for_missing=*/ false,
"connect a part across two modules (interactive screen if no args)",
/*scriptable=*/ true,
/*interactive=*/ true,
};
// UI alias: the dashboard surfaces this command as `plug`. Keep the
// canonical `connect` for script + save/restore stability.
// `plug` is the user-facing name (dashboard shortcut [p]). `connect`
// stays registered for script + save/restore backward compatibility but
// is hidden from `help`.
commands["plug"] = commands["connect"];
commands["connect"].hidden = true;
commands["explore"] = { {}, [this](auto &) {
if (!sys) { Print("no system: run 'new' first."); return; }
explore_modules.clear();
for (auto &m : *sys->modules()) explore_modules.push_back(m.first);
std::sort(explore_modules.begin(), explore_modules.end(), NaturalLess);
if (explore_modules.empty()) { Print("no modules loaded."); return; }
explore_module_idx = 0;
explore_type_idx = 0;
explore_child_idx = 0;
explore_detail_idx = 0;
explore_child_filter.clear();
explore_detail_filter.clear();
explore_focus_idx = 0;
screen_idx = 3;
}, true, "browse modules → parts/signals/connections → details (interactive)",
/*scriptable=*/ false,
/*interactive=*/ true };
commands["duplicate"] = {
{{"source module", Completion::None},
{"new module name", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
Module *src;
try { src = sys->modules()->get(args[0]); }
catch (const std::exception &) {
Print("unknown module: " + args[0]); return;
}
if (sys->modules()->exists(args[1])) {
Print("duplicate refused: module '" + args[1] + "' already exists.");
return;
}
Module *dst = new Module(args[1]);
// 1. Copy signals (preserve type overrides).
for (auto &skv : *src->signals) {
Signal *ss = skv.second;
Signal *ds = new Signal(ss->name);
ds->type = ss->type;
dst->signals->add(ds);
}
// 2. Copy parts, pins, and re-wire pin→signal.
for (auto &pkv : *src) {
Part *sp = pkv.second;
Part *dp = new Part(sp->name);
dp->connector_type = sp->connector_type;
for (auto &nkv : *sp) {
Pin *sn = nkv.second;
Pin *dn = new Pin(sn->name);
dn->spec = sn->spec;
dn->nc_origin = sn->nc_origin;
dp->add(dn);
if (sn->signal()) {
Signal *ds = dst->signals->get(sn->signal()->name);
ds->add(dn);
dn->connect(ds);
}
}
dst->add(dp);
}
sys->modules()->add(dst);
Print("duplicate: '" + args[0] + "' → '" + args[1] + "'"
+ " (" + std::to_string(dst->size()) + " part(s), "
+ std::to_string(dst->signals->size()) + " signal(s))");
},
/*prompt_for_missing=*/ true,
"clone a module under a new name (parts, pins, signals; no connections)",
};
// Per-group registrators living in their own files. Keeps each
// self-contained concern out of this orchestrator.
RegisterExportCommands();
}