ODS import, persistence, scripting, connector types + VPX transforms.

- ODS importer (libzip + pugixml): each sheet → Part, rows → Pin/Signal.
- save / restore commands: tab-delimited snapshot of modules, parts,
  signals, connections + pin_map. `restore` replaces the System.
- source / script-save: replay a file of commands; record canonical
  commands since last `new` for replay later. Interactive screens
  refused during source. `explore` marked non-scriptable.
- TUI screen_explore: 4 columns (modules, type, children, detail) with
  filters on children and detail; detail is a Menu so arrows scroll
  long pin lists.
- Connector types & transforms: each Part carries a `connector_type`
  string. `set-type` validates the part's pin layout against the type
  (cols set check). `connect` strict pair: rejects when lookup falls
  back to identity unless types are both empty AND pin sets match.
- VPX 3U transforms: 3 registered pairs (vpx-3u-bkp-pN ↔ vpx-3u-payload-pN,
  N=0/1/2) with row-pattern correspondence tables ported from the user's
  Python reference.
- Code split for maintainability: src/tui/{shell,completion,commands,
  screen_main,screen_search,screen_connect,screen_settype,screen_explore,
  tui_helpers}.cpp.
- Bug fixes: Module::add(Part*) override sets part->prnt (was always
  null, breaking save's W lines). Defensive guards in explore against
  empty Menu lists. Renderer wrapped in try/catch so domain throws
  surface as on-screen errors instead of SIGABRT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 19:58:51 +02:00
parent 3395469810
commit f3920964f0
31 changed files with 2369 additions and 467 deletions

236
src/tui/shell.cpp Normal file
View File

@@ -0,0 +1,236 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <set>
#include <system_error>
void Tui::Print(const std::string &line) {
output.push_back(line);
}
void Tui::HistoryUp() {
if (history.empty()) return;
if (history_idx == -1) history_idx = (int)history.size() - 1;
else if (history_idx > 0) history_idx--;
input = history[history_idx];
cursor_pos = (int)input.size();
}
void Tui::HistoryDown() {
if (history_idx == -1) return;
history_idx++;
if (history_idx >= (int)history.size()) {
history_idx = -1;
input.clear();
} else {
input = history[history_idx];
}
cursor_pos = (int)input.size();
}
void Tui::CancelPending() {
if (pending.empty()) return;
pending.clear();
input.clear();
cursor_pos = 0;
history_idx = -1;
Print("(cancelled)");
}
std::vector<std::string> Tui::Tokenize(const std::string &s) {
std::vector<std::string> out;
std::string cur;
bool in_q = false;
for (char c : s) {
if (c == '"') { in_q = !in_q; continue; }
if (!in_q && std::isspace((unsigned char)c)) {
if (!cur.empty()) { out.push_back(std::move(cur)); cur.clear(); }
} else {
cur.push_back(c);
}
}
if (!cur.empty()) out.push_back(std::move(cur));
return out;
}
void Tui::Submit() {
if (!pending.empty()) {
if (input.empty()) { Print("(empty — Esc to cancel)"); return; }
Prompt p = std::move(pending.front());
pending.pop_front();
Print(" " + p.question + ": " + input);
std::string answer = std::move(input);
input.clear();
cursor_pos = 0;
history_idx = -1;
p.on_answer(answer);
return;
}
if (input.empty()) return;
history_idx = -1;
Print("> " + input);
std::string raw = std::move(input);
input.clear();
cursor_pos = 0;
Dispatch(raw);
}
void Tui::Dispatch(const std::string &raw) {
auto tokens = Tokenize(raw);
if (tokens.empty()) return;
auto it = commands.find(tokens[0]);
if (it == commands.end()) {
Print("unknown command: " + tokens[0]);
if (!in_source) { history.push_back(raw); AppendHistory(raw); }
return;
}
const std::string name = it->first;
const CommandSpec &spec = it->second;
if (tokens.size() - 1 > spec.params.size()) {
Print("too many arguments for '" + name + "'");
if (!in_source) { history.push_back(raw); AppendHistory(raw); }
return;
}
auto args = std::make_shared<std::vector<std::string>>(
tokens.begin() + 1, tokens.end());
if (args->size() == spec.params.size() || !spec.prompt_for_missing) {
Finalize(name, spec, *args);
return;
}
for (size_t i = args->size(); i < spec.params.size(); ++i) {
bool last = (i + 1 == spec.params.size());
const auto &param = spec.params[i];
pending.push_back({
param.name,
[this, name, &spec, args, last](const std::string &s) {
args->push_back(s);
if (last) Finalize(name, spec, *args);
},
param.completion,
});
}
}
void Tui::Finalize(const std::string &name,
const CommandSpec &spec,
const std::vector<std::string> &args) {
std::string canonical = name;
for (const auto &a : args) {
if (a.find_first_of(" \t\"") != std::string::npos)
canonical += " \"" + a + "\"";
else
canonical += " " + a;
}
if (!in_source) {
history.push_back(canonical);
AppendHistory(canonical);
}
spec.action(args);
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);
}
namespace {
std::filesystem::path HistoryPath() {
namespace fs = std::filesystem;
#ifdef _WIN32
if (const char *p = std::getenv("LOCALAPPDATA"); p && *p)
return fs::path(p) / "essim" / "history";
if (const char *p = std::getenv("APPDATA"); p && *p)
return fs::path(p) / "essim" / "history";
if (const char *p = std::getenv("USERPROFILE"); p && *p)
return fs::path(p) / "AppData" / "Local" / "essim" / "history";
#else
if (const char *p = std::getenv("XDG_DATA_HOME"); p && *p)
return fs::path(p) / "essim" / "history";
if (const char *p = std::getenv("HOME"); p && *p)
return fs::path(p) / ".local" / "share" / "essim" / "history";
#endif
return {};
}
} // namespace
void Tui::LoadHistory() {
auto p = HistoryPath();
if (p.empty()) return;
std::ifstream f(p);
std::string line;
while (std::getline(f, line))
if (!line.empty()) history.push_back(line);
}
void Tui::Source(const std::string &filename) {
std::string expanded = filename;
if (!expanded.empty() && expanded[0] == '~') {
if (const char *home = std::getenv("HOME"))
expanded = std::string(home) + expanded.substr(1);
}
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;
std::string line;
while (std::getline(f, line)) {
++lineno;
size_t start = line.find_first_not_of(" \t");
if (start == std::string::npos) continue;
if (line[start] == '#') continue;
std::string trimmed = line.substr(start);
while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back()))
trimmed.pop_back();
if (trimmed.empty()) continue;
input = trimmed;
cursor_pos = (int)input.size();
Submit();
++executed;
if (screen_idx != 0) {
Print("source: line " + std::to_string(lineno)
+ " is interactive (would open a screen) — aborting.");
screen_idx = 0;
aborted = true;
break;
}
}
in_source = prev;
if (!aborted)
Print("source: " + filename + " (" + std::to_string(executed) + " line(s))");
}
void Tui::AppendHistory(const std::string &cmd) {
auto p = HistoryPath();
if (p.empty()) return;
std::string trimmed = cmd;
while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back()))
trimmed.pop_back();
if (trimmed.empty()) return;
std::error_code ec;
std::filesystem::create_directories(p.parent_path(), ec);
if (ec) return;
std::ofstream f(p, std::ios::app);
if (f) f << trimmed << '\n';
}