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

190
src/core/app/export.cpp Normal file
View File

@@ -0,0 +1,190 @@
#include "core/app/export.hpp"
#include "core/imports/ods_writer.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signal_type.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include <cctype>
#include <fstream>
#include <string>
namespace app {
namespace {
std::string to_lower(std::string s)
{
for (char &c : s) c = (char)std::tolower((unsigned char)c);
return s;
}
// Minimal CSV quoter — wraps in "…" and doubles internal quotes when the field
// contains a comma, quote, or newline.
std::string csv_quote(const std::string &s)
{
if (s.find_first_of(",\"\n") == std::string::npos)
return s;
std::string out = "\"";
for (char c : s) { if (c == '"') out += '"'; out += c; }
out += '"';
return out;
}
// Flatten one pin into the string slots an export row uses.
void pin_side(Pin *p, std::string &mod, std::string &part, std::string &pin,
std::string &sig, std::string &type, std::string &suspect)
{
if (!p) { mod = part = pin = sig = type = suspect = ""; return; }
mod = (p->prnt && p->prnt->prnt) ? p->prnt->prnt->name : "";
part = p->prnt ? p->prnt->name : "";
pin = p->name;
Signal *s = p->signal();
if (!s) {
sig = ""; type = "(NC)"; suspect = "";
} else {
sig = s->name;
type = signal_type_name(s->type);
suspect = (infer_signal_type(s->name) == SignalType::Power
&& s->type == SignalType::Other) ? "yes" : "no";
}
}
// Excel rejects /\?*:[] in sheet names; ODS forbids < > & in raw table names.
// Sanitise to underscores and clip to Excel's 31-char hard limit.
std::string sanitise_sheet_name(std::string name)
{
for (char &ch : name)
if (ch == '/' || ch == '\\' || ch == '?' || ch == '*' || ch == ':'
|| ch == '[' || ch == ']' || ch == '<' || ch == '>' || ch == '&')
ch = '_';
if (name.size() > 31) name = name.substr(0, 31);
return name;
}
ExportResult write_ods(const System *sys, const std::string &path)
{
ExportResult r;
OdsWriter w;
for (auto &ckv : *sys->connections()) {
Connection *c = ckv.second;
OdsSheet *s = w.add_sheet(sanitise_sheet_name(c->name));
std::string lmod = c->m1 ? c->m1->name : "";
std::string lprt = c->p1 ? c->p1->name : "";
std::string rmod = c->m2 ? c->m2->name : "";
std::string rprt = c->p2 ? c->p2->name : "";
// Meta header: label/value rows, a blank row, then the column headers.
auto meta = [&](int row, const std::string &k, const std::string &v) {
s->set(row, 0, k);
s->set(row, 1, v);
};
meta(0, "Connection", c->name);
meta(1, "Transform", c->transform_name);
meta(2, "Left", lmod + " / " + lprt);
meta(3, "Right", rmod + " / " + rprt);
// Row 4 left blank by design.
const int HDR = 5;
s->set_header_row(HDR);
const char *hdr[] = {
"left_pin", "left_signal", "left_type", "left_suspect",
"right_pin", "right_signal", "right_type", "right_suspect",
"type_mismatch"};
for (int i = 0; i < 9; ++i) s->set(HDR, i, hdr[i]);
int row = HDR + 1;
for (auto &wp : c->pin_map) {
std::string lm, lp, ln, ls, lt, lsus;
std::string rm, rp, rn, rs, rt, rsus;
pin_side(wp.first, lm, lp, ln, ls, lt, lsus);
pin_side(wp.second, rm, rp, rn, rs, rt, rsus);
std::string tm = (lt != "(NC)" && rt != "(NC)" && lt != rt) ? "yes" : "no";
s->set(row, 0, ln); s->set(row, 1, ls);
s->set(row, 2, lt); s->set(row, 3, lsus);
s->set(row, 4, rn); s->set(row, 5, rs);
s->set(row, 6, rt); s->set(row, 7, rsus);
s->set(row, 8, tm);
++row; ++r.rows;
}
}
std::string err;
if (!w.save(path, err)) {
r.error = err;
return r;
}
r.ok = true;
r.sheets = (int)sys->connections()->size();
return r;
}
ExportResult write_csv(const System *sys, const std::string &path)
{
ExportResult r;
std::ofstream f(path);
if (!f) {
r.error = "cannot open '" + path + "' for writing";
return r;
}
// One rectangular table: each row repeats the per-connection constants so
// the file stays parser-friendly (pandas / awk / spreadsheet).
f << "connection,transform,"
"left_module,left_part,"
"left_pin,left_signal,left_type,left_suspect,"
"right_module,right_part,"
"right_pin,right_signal,right_type,right_suspect,"
"type_mismatch\n";
for (auto &ckv : *sys->connections()) {
Connection *c = ckv.second;
for (auto &wp : c->pin_map) {
std::string lm, lp, ln, ls, lt, lsus;
std::string rm, rp, rn, rs, rt, rsus;
pin_side(wp.first, lm, lp, ln, ls, lt, lsus);
pin_side(wp.second, rm, rp, rn, rs, rt, rsus);
std::string tm = (lt != "(NC)" && rt != "(NC)" && lt != rt) ? "yes" : "no";
f << csv_quote(c->name) << ','
<< csv_quote(c->transform_name) << ','
<< csv_quote(lm) << ',' << csv_quote(lp) << ','
<< csv_quote(ln) << ',' << csv_quote(ls) << ','
<< csv_quote(lt) << ',' << csv_quote(lsus) << ','
<< csv_quote(rm) << ',' << csv_quote(rp) << ','
<< csv_quote(rn) << ',' << csv_quote(rs) << ','
<< csv_quote(rt) << ',' << csv_quote(rsus) << ','
<< tm << '\n';
++r.rows;
}
}
r.ok = f.good();
if (!r.ok) r.error = "write error on '" + path + "'";
return r;
}
} // namespace
bool export_format_from_path(const std::string &path, ExportFormat &out)
{
size_t dot = path.rfind('.');
std::string ext = (dot == std::string::npos) ? "" : to_lower(path.substr(dot));
if (ext == ".csv") { out = ExportFormat::Csv; return true; }
if (ext == ".ods") { out = ExportFormat::Ods; return true; }
return false;
}
ExportResult export_connections(const System *sys, const std::string &path,
ExportFormat format)
{
ExportResult r;
if (!sys) { r.error = "no system"; return r; }
return (format == ExportFormat::Ods) ? write_ods(sys, path)
: write_csv(sys, path);
}
} // namespace app

34
src/core/app/export.hpp Normal file
View File

@@ -0,0 +1,34 @@
#ifndef _APP_EXPORT_HPP_
#define _APP_EXPORT_HPP_
#include <string>
class System;
// Application layer: UI-independent operations that any frontend (TUI, GUI, …)
// can call. No console, no dialogs, no FTXUI — just System in, result out.
namespace app {
enum class ExportFormat { Csv, Ods };
// Outcome of an export. The only side effect is writing the target file; the
// caller renders `error` / the stats however it likes.
struct ExportResult {
bool ok = false;
std::string error; ///< human-readable, set when !ok
int sheets = 0; ///< ODS: number of sheets (one per connection); 0 for CSV
int rows = 0; ///< wires written
};
// Map a filename extension (.csv / .ods, case-insensitive) to a format.
// Returns false if the extension is neither.
bool export_format_from_path(const std::string &path, ExportFormat &out);
// Export the system's connections to `path` in `format`. Builds the file and
// returns stats or an error. Pure core — safe to call from any frontend.
ExportResult export_connections(const System *sys, const std::string &path,
ExportFormat format);
} // namespace app
#endif // _APP_EXPORT_HPP_