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

@@ -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); });