Altium import, nets, canonical pins, component kinds, set/$var, scrollback, source modal.

Major additions, all wired end-to-end with doctest coverage:

- Altium netlist importer (`imports/import_altium.{hpp,cpp}`): two-pass
  parser for `[ ]` parts and `( )` signals; `System::Load` no longer has
  the IMPORT_ALTIUM hole.
- `duplicate <src> <dst>` deep-copies a module (signals, parts, pins,
  rewired signals); connections excluded by design.
- Nets (`system/nets.{hpp,cpp}`): BFS over `Connection::pin_map` to
  return the transitive (Module, Signal) closure. `verify` extended with
  a second pass flagging Power↔GndShield inconsistencies in bridged
  nets; new `net <module> <signal>` command for inspection.
- Canonical pin names (`system/pin_name.{hpp,cpp}`): zero-padded digit
  suffix lets A1 ↔ A001 pair via `IdentityTransform` and
  `CheckIdentityCompatible` without losing the imported notation.
- Component classification (`system/component_kind.{hpp,cpp}`):
  `Part::kind` inferred at construction from the reference-designator
  prefix (longest-match: LED/TP/SW/FB/MK/MP/MH/HS/RA/RN/RP/RV first,
  then R/C/L/F/D/Q/U/J/P/Y/X/S).
- Identity wiring tolerance: `CheckIdentityCompatible` accepts the
  subset case (typical when one importer drops NC pins, e.g. Altium)
  and surfaces orphans as an info string. `FillIdentityNCs`
  materialises orphan canonical positions as NC pins on the missing
  side at connect time.
- Connector layout preparation: `pin_layout(kind)` and
  `FillPartFromLayout(part, kind)` stubs in `pin_role`, called from
  `set-type`. Empty today; populate alongside `vpx_3u_role`.
- TUI scrollback: PageUp/PageDown step 10 lines, Home/End jump to
  ends; `Print()` snaps back to the tail.
- `set <name> <value>` declares session variables; `$name` / `${name}`
  expanded inside `Finalize` between canonical-form recording and the
  action call — history and script-save preserve `$var` references.
- Long `source` scripts now show a centred "Computing…" modal with a
  N/M progress counter. Driven by a ticker thread that posts one
  paced `Event::Special` per processed line, ack'd by the main thread,
  so heavy lines don't backlog ticks and freeze the counter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 20:28:21 +02:00
parent 477f3abd40
commit c3bb00cb4d
24 changed files with 1163 additions and 61 deletions

View File

@@ -3,6 +3,7 @@
#include "system/connect.hpp"
#include "system/modules.hpp"
#include "system/nets.hpp"
#include "system/parts.hpp"
#include "system/persist.hpp"
#include "system/pin_role.hpp"
@@ -32,7 +33,8 @@ void Tui::RegisterCommands() {
+ std::string(maxw - kv.first.size() + 2, ' ')
+ kv.second.description);
}
Print("Keys: Esc cancels a multi-step prompt; Tab completes commands or paths.");
Print("Keys: Esc cancels a multi-step prompt; Tab completes commands or paths;");
Print(" PageUp/PageDown scroll output (10 lines), Home/End jump to top/bottom.");
return;
}
const std::string &name = args[0];
@@ -66,8 +68,42 @@ void Tui::RegisterCommands() {
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" };
}, 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},
@@ -180,10 +216,60 @@ void Tui::RegisterCommands() {
}
}
}
Print("verify: " + std::to_string(mismatches) + " mismatch(es) over "
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).");
}, true,
"check that each pin's connected signal matches its connector_type's expected role" };
"check pin roles locally and signal-type consistency across bridged nets" };
commands["net"] = {
{{"module", Completion::None},
{"signal name", 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;
}
Net n = find_net(sys.get(), mod, sig);
SignalType dom;
bool ok = net_type_consistent(n, dom);
Print("net containing " + mod->name + "/" + sig->name
+ "" + std::to_string(n.members.size()) + " signal(s)"
+ (ok ? "" : " [INCONSISTENT]")
+ ", dominant: " + signal_type_name(dom));
for (const auto &mp : n.members) {
Print(" " + mp.first->name + "/" + mp.second->name
+ " (" + signal_type_name(mp.second->type) + ")");
}
},
/*prompt_for_missing=*/ true,
"show all signals reachable from <module>/<signal> through connections",
};
commands["set-signal-type"] = {
{{"module", Completion::None},
@@ -267,10 +353,14 @@ void Tui::RegisterCommands() {
return;
}
prt->connector_type = args[2];
int filled = FillPartFromLayout(prt, args[2]);
for (auto &kv : *prt)
kv.second->expected_signal_type = pin_role(args[2], kv.first);
Print(mod->name + "/" + prt->name + ": connector_type = "
+ (args[2].empty() ? "(none)" : args[2]));
if (filled > 0)
Print("set-type: materialised " + std::to_string(filled)
+ " NC pin(s) from connector layout");
},
/*prompt_for_missing=*/ false,
"tag a part's connector type for transform lookup",
@@ -376,11 +466,19 @@ void Tui::RegisterCommands() {
+ "'. Set matching types via 'set-type' first.");
return;
}
std::string err = CheckIdentityCompatible(p1, p2);
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: materialised " + std::to_string(added)
+ " NC pin(s) so both sides match");
}
}
auto pin_map = t->apply(p1, p2);
@@ -478,4 +576,58 @@ void Tui::RegisterCommands() {
/*prompt_for_missing=*/ false,
"list parts/signals matching a pattern (interactive screen if no args)",
};
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->expected_signal_type = sn->expected_signal_type;
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)",
};
}

View File

@@ -22,10 +22,21 @@ Component Tui::BuildMainScreen(ScreenInteractive &screen) {
return Renderer(input_component, [this, &screen, input_component] {
if (quit) screen.Exit();
// Clamp scroll offset to a meaningful range and pick the line to focus.
int n = (int)output.size();
if (scroll_offset < 0) scroll_offset = 0;
if (scroll_offset > n - 1) scroll_offset = std::max(0, n - 1);
int focus_idx = std::max(0, n - 1 - scroll_offset);
Elements lines;
for (const auto &l : output) lines.push_back(text(l));
lines.reserve(output.size());
for (int i = 0; i < n; ++i) {
auto el = text(output[i]);
if (i == focus_idx) el = el | focus;
lines.push_back(el);
}
auto view = vbox(std::move(lines))
| focusPositionRelative(0, 1)
| vscroll_indicator
| yframe
| flex;
@@ -33,10 +44,29 @@ Component Tui::BuildMainScreen(ScreenInteractive &screen) {
? "> "
: pending.front().question + "? ";
return vbox({
std::string status = scroll_offset > 0
? " [scroll: -" + std::to_string(scroll_offset)
+ " / PgUp PgDn Home End to navigate]"
: "";
auto base = vbox({
view,
separator(),
hbox({text(label), input_component->Render()}),
hbox({text(label), input_component->Render(), filler(), text(status) | dim}),
}) | border;
if (loading) {
int total = (int)loading_lines.size();
std::string progress = std::to_string(loading_executed) + " / "
+ std::to_string(total) + " lines";
auto modal = vbox({
text(" Computing… ") | bold | center,
separator(),
text(loading_filename) | center,
text(progress) | center,
}) | borderDouble | size(WIDTH, GREATER_THAN, 40);
return dbox({base, modal | center});
}
return base;
});
}

View File

@@ -2,14 +2,17 @@
#include "tui/tui_helpers.hpp"
#include <cctype>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <set>
#include <system_error>
#include <thread>
void Tui::Print(const std::string &line) {
output.push_back(line);
scroll_offset = 0; // any new line snaps the view back to the tail
}
void Tui::HistoryUp() {
@@ -109,6 +112,8 @@ void Tui::Dispatch(const std::string &raw) {
void Tui::Finalize(const std::string &name,
const CommandSpec &spec,
const std::vector<std::string> &args) {
// Build the canonical form from the *raw* args (pre-expansion) so that
// history and script-save preserve `$var` references.
std::string canonical = name;
for (const auto &a : args) {
if (a.find_first_of(" \t\"") != std::string::npos)
@@ -120,7 +125,12 @@ void Tui::Finalize(const std::string &name,
history.push_back(canonical);
AppendHistory(canonical);
}
spec.action(args);
// Expand variables only for the action call so commands see resolved values.
std::vector<std::string> exec_args;
exec_args.reserve(args.size());
for (const auto &a : args) exec_args.push_back(ExpandVars(a));
spec.action(exec_args);
static const std::set<std::string> no_record = {
"clear", "help", "quit", "exit", "source", "script-save",
@@ -128,6 +138,34 @@ void Tui::Finalize(const std::string &name,
if (spec.scriptable && !no_record.count(name)) recorded.push_back(canonical);
}
std::string Tui::ExpandVars(const std::string &s) const {
std::string out;
out.reserve(s.size());
size_t i = 0;
while (i < s.size()) {
if (s[i] != '$') { out.push_back(s[i++]); continue; }
size_t j = i + 1;
bool braces = (j < s.size() && s[j] == '{');
if (braces) ++j;
size_t start = j;
while (j < s.size() && (std::isalnum((unsigned char)s[j]) || s[j] == '_')) ++j;
std::string name = s.substr(start, j - start);
if (braces) {
if (j >= s.size() || s[j] != '}') {
// Unmatched brace — emit literally and resume after the '$'.
out.push_back('$'); ++i; continue;
}
++j;
}
if (name.empty()) { out.push_back('$'); ++i; continue; }
auto it = vars.find(name);
if (it != vars.end()) out += it->second;
else out += s.substr(i, j - i); // keep unknown as-is
i = j;
}
return out;
}
namespace {
std::filesystem::path HistoryPath() {
@@ -168,19 +206,59 @@ void Tui::Source(const std::string &filename) {
std::ifstream f(expanded);
if (!f) { Print("source failed: cannot open " + filename); return; }
bool prev = in_source;
in_source = true;
int executed = 0;
int lineno = 0;
bool aborted = false;
// Slurp the whole file so we can drive line-by-line processing from the
// event loop (one line per posted task). This lets the screen redraw
// between lines and surface the "Computing…" modal.
loading_lines.clear();
std::string line;
while (std::getline(f, line)) {
++lineno;
size_t start = line.find_first_not_of(" \t");
while (std::getline(f, line)) loading_lines.push_back(line);
loading_filename = filename;
loading_idx = 0;
loading_executed = 0;
loading_lineno = 0;
loading_prev_in_source = in_source;
in_source = true;
loading = true;
if (!screen_ptr) {
// Headless fallback (e.g. tests): drain synchronously.
while (loading.load()) ProcessNextSourceLine();
return;
}
// Pacing thread: post one tick at a time and wait for the main thread
// to ack it (by clearing tick_in_flight from ProcessNextSourceLine)
// before sleeping & posting the next. Without this, a long-running line
// (e.g. a Mentor parse) lets the ticker queue many ticks; FTXUI then
// drains them in a batch without redrawing between, so the modal
// counter freezes.
tick_in_flight.store(false);
std::thread([this]() {
using namespace std::chrono_literals;
while (loading.load()) {
// Wait until main thread is ready for a new tick.
while (loading.load() && tick_in_flight.load())
std::this_thread::sleep_for(5ms);
if (!loading.load()) break;
std::this_thread::sleep_for(30ms);
if (!loading.load()) break;
tick_in_flight.store(true);
if (screen_ptr)
screen_ptr->PostEvent(ftxui::Event::Special("\x02tick"));
}
}).detach();
}
void Tui::ProcessNextSourceLine() {
if (!loading.load()) return;
while (loading_idx < loading_lines.size()) {
const std::string &raw = loading_lines[loading_idx++];
++loading_lineno;
size_t start = raw.find_first_not_of(" \t");
if (start == std::string::npos) continue;
if (line[start] == '#') continue;
std::string trimmed = line.substr(start);
if (raw[start] == '#') continue;
std::string trimmed = raw.substr(start);
while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back()))
trimmed.pop_back();
if (trimmed.empty()) continue;
@@ -188,21 +266,27 @@ void Tui::Source(const std::string &filename) {
input = trimmed;
cursor_pos = (int)input.size();
Submit();
++executed;
++loading_executed;
if (screen_idx != 0) {
Print("source: line " + std::to_string(lineno)
Print("source: line " + std::to_string(loading_lineno)
+ " is interactive (would open a screen) — aborting.");
screen_idx = 0;
aborted = true;
break;
loading.store(false);
tick_in_flight.store(false);
in_source = loading_prev_in_source;
return;
}
// One effective line per tick — ack so the ticker can pace the next.
tick_in_flight.store(false);
return;
}
in_source = prev;
if (!aborted)
Print("source: " + filename + " (" + std::to_string(executed) + " line(s))");
Print("source: " + loading_filename
+ " (" + std::to_string(loading_executed) + " line(s))");
loading.store(false);
tick_in_flight.store(false);
in_source = loading_prev_in_source;
}
void Tui::AppendHistory(const std::string &cmd) {

View File

@@ -9,7 +9,10 @@
using namespace ftxui;
Tui::Tui()
: cursor_pos(0), history_idx(-1), quit(false), in_source(false),
: cursor_pos(0), history_idx(-1), scroll_offset(0), quit(false), in_source(false),
loading(false), tick_in_flight(false),
loading_idx(0), loading_executed(0), loading_lineno(0),
loading_prev_in_source(false), screen_ptr(nullptr),
screen_idx(0),
search_types{"parts", "signals"},
search_module_idx(0), search_type_idx(0), search_focus_idx(0),
@@ -31,6 +34,7 @@ Tui::~Tui() = default;
void Tui::Run() {
auto screen = ScreenInteractive::Fullscreen();
screen_ptr = &screen;
auto main_screen = BuildMainScreen(screen);
auto search_screen = BuildSearchScreen();
@@ -69,7 +73,12 @@ void Tui::Run() {
return false;
default: // main
if (e == Event::Special("\x02tick")) { ProcessNextSourceLine(); return true; }
if (e == Event::Escape && !pending.empty()) { CancelPending(); return true; }
if (e == Event::PageUp) { scroll_offset += 10; return true; }
if (e == Event::PageDown) { scroll_offset = std::max(0, scroll_offset - 10); return true; }
if (e == Event::Home) { scroll_offset = (int)output.size(); return true; }
if (e == Event::End) { scroll_offset = 0; return true; }
if (e == Event::ArrowUp || e == Event::ArrowDown) {
if (pending.empty()) {
if (e == Event::ArrowUp) HistoryUp();

View File

@@ -1,6 +1,7 @@
#ifndef _TUI_HPP_
#define _TUI_HPP_
#include <atomic>
#include <deque>
#include <functional>
#include <map>
@@ -41,12 +42,14 @@ class Tui {
std::string input;
int cursor_pos;
int history_idx;
int scroll_offset; ///< Lines scrolled up from the tail; 0 = follow newest output.
bool quit;
bool in_source;
std::unique_ptr<System> sys;
std::deque<Prompt> pending;
std::map<std::string, CommandSpec> commands;
std::map<std::string, std::string> vars; ///< $var-style substitution table.
// ---- Screen orchestration ----
int screen_idx;
@@ -85,6 +88,17 @@ class Tui {
std::string explore_header;
int explore_focus_idx;
// ---- Source-file loading (event-driven, one line per tick) ----
std::atomic<bool> loading; ///< true while a script is being processed; read by tick thread.
std::atomic<bool> tick_in_flight; ///< main thread acks each tick by clearing this; ticker waits.
std::string loading_filename;
std::vector<std::string> loading_lines;
size_t loading_idx;
int loading_executed;
int loading_lineno;
bool loading_prev_in_source;
ftxui::ScreenInteractive *screen_ptr; ///< set in Run() so Source() can post events.
// ---- Set-type screen state ----
std::vector<std::string> settype_modules;
int settype_m_idx;
@@ -117,6 +131,8 @@ private:
void LoadHistory();
void AppendHistory(const std::string &cmd);
void Source(const std::string &filename);
void ProcessNextSourceLine();
std::string ExpandVars(const std::string &s) const;
// Completion (completion.cpp)
void CompleteCommand(size_t start = 0);