New user-facing features:
- `export connections <file>` writes a tabular dump of every wire pair:
connection, transform, left/right module/part/pin/signal/type/suspect,
mixed-types flag. Dispatch on extension: `.csv` (flat file) or `.ods`
(one sheet per connection). Any other extension shows an error and
writes nothing.
- Bare `export` (or dashboard `[x]`, or palette `export`) opens an
interactive file-picker dialog with a CSV/ODS toggle at the top.
Picking a filter rewrites the filename's extension. Last-used
directory and filename are remembered per-call-site.
- Two new CLI flags on the binary: `--source FILE` to run a script at
boot, `--restore FILE` to restore a snapshot at boot. Combinable.
Reusable infrastructure:
- `OdsWriter` (`src/imports/ods_writer.{hpp,cpp}`): minimal .ods writer
using libzip + pugixml (already in the build for the importer).
Multi-sheet workbook of string cells. ~180 lines, no new dep.
- Generic file-picker dialog (`screen_filedialog.cpp`): one Modal
reused for any "pick a path" interaction via
`OpenFileDialog(title, persist_key, default_filename, filters, cb)`.
Validates the picked extension against the filter whitelist;
unknown ones stay in the dialog with a status message. Persists
(dir, filename) per `persist_key`.
- Generic error modal (`screen_error.cpp`, `ShowError(msg)`): centred
red-titled popup, dismissable with Esc/Enter. Used by the export
failures (open-for-write, ODS save, unknown extension/kind);
ready for adoption elsewhere.
- Per-key path persistence (`SaveLastUsed`/`LoadLastUsed` in
`shell.cpp`): two-line file per key under the user-data dir.
- `UserDataDir()` extracted from the history path helper so the new
per-key persistence shares the same XDG/AppData logic.
- New help-screen topic "Export"; user-facing `doc/user/analysis.md`
gains an "Exporting" section; `DESIGN.md` gains a generics
section covering the dialog / error modal / persistence / ODS
writer; `DumpCommandsMd` now respects the `hidden` flag (the
`connect` alias no longer appears in the auto-gen reference).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
318 lines
12 KiB
C++
318 lines
12 KiB
C++
#include "tui/tui.hpp"
|
|
#include "tui/tui_helpers.hpp"
|
|
|
|
#include "system/analysis.hpp"
|
|
#include "system/connect.hpp"
|
|
#include "system/modules.hpp"
|
|
#include "system/nets.hpp"
|
|
#include "system/parts.hpp"
|
|
#include "system/pins.hpp"
|
|
#include "system/signals.hpp"
|
|
#include "system/system.hpp"
|
|
|
|
#include <ftxui/component/component.hpp>
|
|
#include <ftxui/dom/elements.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
using namespace ftxui;
|
|
|
|
namespace {
|
|
|
|
// Two-column key/value row, fixed-width key so columns line up.
|
|
Element kv(const std::string &k, const std::string &v) {
|
|
return hbox({
|
|
text(" " + k) | dim | size(WIDTH, EQUAL, 16),
|
|
text(v),
|
|
});
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Component Tui::BuildDashboardScreen() {
|
|
return Renderer([this] {
|
|
auto title = hbox({
|
|
text(" essim ") | bold,
|
|
text("→ ") | dim,
|
|
text("dashboard") | bold,
|
|
text(" — system overview at a glance") | dim,
|
|
});
|
|
|
|
Element early_help = RenderHelpPanel("dashboard", {
|
|
{"c", "console"},
|
|
{"a", "analyze"},
|
|
{"h", "help screen"},
|
|
{"q", "quit"},
|
|
{"Ctrl-P", "palette"},
|
|
});
|
|
|
|
if (!sys) {
|
|
return vbox({
|
|
title,
|
|
separator(),
|
|
hbox({
|
|
vbox({
|
|
text(" no system loaded — run 'new' or 'restore <file>'") | dim,
|
|
text(" (press 'c' for the console, or Ctrl-P for the palette)") | dim,
|
|
filler(),
|
|
}) | flex,
|
|
separator(),
|
|
early_help,
|
|
}) | flex,
|
|
}) | border;
|
|
}
|
|
|
|
// ---- counters ----
|
|
int n_modules = (int)sys->modules()->size();
|
|
int n_parts = 0, n_signals = 0;
|
|
for (auto &mkv : *sys->modules()) {
|
|
n_parts += (int)mkv.second->size();
|
|
n_signals += (int)mkv.second->signals->size();
|
|
}
|
|
int n_conn = (int)sys->connections()->size();
|
|
|
|
// ---- verify-style health (recomputed; cheap on realistic sizes) ----
|
|
int n_role_mismatches = 0, n_typed_pins = 0;
|
|
for (auto &mkv : *sys->modules())
|
|
for (auto &pkv : *mkv.second) {
|
|
Part *prt = pkv.second;
|
|
if (prt->connector_type.empty()) continue;
|
|
for (auto &nkv : *prt) {
|
|
Pin *pin = nkv.second;
|
|
++n_typed_pins;
|
|
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) ++n_role_mismatches;
|
|
}
|
|
}
|
|
|
|
auto nets = compute_all_nets(sys.get());
|
|
int n_bridged = 0, n_inconsistent = 0;
|
|
for (const auto &n : nets) {
|
|
if (n.members.size() < 2) continue;
|
|
++n_bridged;
|
|
SignalType dom;
|
|
if (!net_type_consistent(n, dom)) ++n_inconsistent;
|
|
}
|
|
|
|
// ---- NC orphan summary (matches verify pass 3) ----
|
|
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;
|
|
// Per-module list of dropped-singleton pins, for the detail rows below
|
|
// the NC health line. The signal name is gone (the Signal object was
|
|
// deleted by `drop_singleton_signals`), but the pin's full path is
|
|
// enough to locate it in `explore`.
|
|
std::map<std::string, std::vector<std::string>> dropped_by_module;
|
|
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;
|
|
dropped_by_module[mkv.first].push_back(
|
|
pkv.first + "/" + nkv.first);
|
|
}
|
|
}
|
|
|
|
auto health_line = [](bool ok, const std::string &s) {
|
|
return hbox({
|
|
text(ok ? " ✓ " : " ⚠ ") | (ok ? color(Color::Green) : color(Color::Yellow)),
|
|
text(s),
|
|
});
|
|
};
|
|
|
|
Elements health_rows;
|
|
health_rows.push_back(health_line(n_role_mismatches == 0,
|
|
"verify: " + std::to_string(n_role_mismatches)
|
|
+ " pin-role mismatch(es) over " + std::to_string(n_typed_pins)
|
|
+ " typed pin(s)"));
|
|
health_rows.push_back(health_line(n_inconsistent == 0,
|
|
"nets: " + std::to_string(n_inconsistent) + " inconsistent over "
|
|
+ std::to_string(n_bridged) + " bridged (" + std::to_string(nets.size())
|
|
+ " total)"));
|
|
int orph_total = orph_imported + orph_dropped;
|
|
health_rows.push_back(health_line(orph_total == 0,
|
|
"NC: " + std::to_string(orph_total) + " orphan pin(s) ("
|
|
+ std::to_string(orph_imported) + " imported, "
|
|
+ std::to_string(orph_dropped) + " dropped)"));
|
|
|
|
// ---- analysis summary ----
|
|
AnalysisReport rep = analyze_system(sys.get());
|
|
int n_diff = 0, n_diff_bus = 0, n_bus = 0;
|
|
for (const auto &g : rep.groups) {
|
|
if (g.kind == GroupKind::DiffPair) ++n_diff;
|
|
else if (g.kind == GroupKind::DiffBus) ++n_diff_bus;
|
|
else if (g.kind == GroupKind::Bus) ++n_bus;
|
|
}
|
|
|
|
// ---- per-module table ----
|
|
std::vector<std::string> mod_names;
|
|
for (auto &mkv : *sys->modules()) mod_names.push_back(mkv.first);
|
|
std::sort(mod_names.begin(), mod_names.end(), NaturalLess);
|
|
size_t maxw = 1;
|
|
for (const auto &n : mod_names) maxw = std::max(maxw, n.size());
|
|
Elements mod_rows;
|
|
for (const auto &name : mod_names) {
|
|
Module *m = sys->modules()->get(name);
|
|
int total = (int)m->size();
|
|
|
|
// Group parts by connector_type so the table answers "which type
|
|
// is on which part?" rather than just "how many are typed?".
|
|
std::map<std::string, std::vector<std::string>> by_type;
|
|
for (auto &pkv : *m)
|
|
if (!pkv.second->connector_type.empty())
|
|
by_type[pkv.second->connector_type].push_back(pkv.first);
|
|
|
|
mod_rows.push_back(hbox({
|
|
text(" " + name + std::string(maxw - name.size(), ' '))
|
|
| size(WIDTH, EQUAL, (int)maxw + 4),
|
|
text(std::to_string(total) + " part(s)") | size(WIDTH, EQUAL, 14),
|
|
text(std::to_string(m->signals->size()) + " signal(s)"),
|
|
}));
|
|
|
|
// Power-signal breakdown for this module — same classification as
|
|
// the analyze screen so the dashboard summary stays consistent.
|
|
int n_pwr_ok = 0, n_pwr_suspect = 0, n_gnd = 0;
|
|
for (auto &skv : *m->signals) {
|
|
Signal *s = skv.second;
|
|
SignalType named = infer_signal_type(s->name);
|
|
if (named == SignalType::GndShield && s->type == SignalType::GndShield) ++n_gnd;
|
|
else if (named == SignalType::Power && s->type == SignalType::Power) ++n_pwr_ok;
|
|
else if (named == SignalType::Power && s->type == SignalType::Other) ++n_pwr_suspect;
|
|
}
|
|
if (n_pwr_ok + n_pwr_suspect + n_gnd > 0) {
|
|
std::string label = "power: " + std::to_string(n_pwr_ok)
|
|
+ " confirmed, " + std::to_string(n_pwr_suspect)
|
|
+ " suspect gnd: " + std::to_string(n_gnd);
|
|
auto el = text(" " + label);
|
|
if (n_pwr_suspect > 0) el = el | color(Color::Yellow);
|
|
mod_rows.push_back(el);
|
|
}
|
|
|
|
if (by_type.empty()) {
|
|
mod_rows.push_back(hbox({
|
|
text(" "),
|
|
text("(no connector types assigned)") | dim,
|
|
}));
|
|
} else {
|
|
for (auto &tkv : by_type) {
|
|
std::sort(tkv.second.begin(), tkv.second.end(), NaturalLess);
|
|
std::string parts_csv;
|
|
for (size_t i = 0; i < tkv.second.size(); ++i) {
|
|
if (i) parts_csv += ", ";
|
|
parts_csv += tkv.second[i];
|
|
}
|
|
mod_rows.push_back(hbox({
|
|
text(" "),
|
|
text(tkv.first) | bold,
|
|
text(": "),
|
|
text(parts_csv),
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flatten the dashboard into a list of lines so we can scroll it as a
|
|
// whole when the content overflows. The pattern mirrors the shell's
|
|
// scrollback: pick one focused line and wrap in `yframe`.
|
|
Elements lines;
|
|
lines.push_back(text(" Overview") | bold);
|
|
lines.push_back(hbox({
|
|
vbox({
|
|
kv("Modules", std::to_string(n_modules)),
|
|
kv("Parts", std::to_string(n_parts)),
|
|
}) | flex,
|
|
vbox({
|
|
kv("Signals", std::to_string(n_signals)),
|
|
kv("Connections", std::to_string(n_conn)),
|
|
}) | flex,
|
|
}));
|
|
lines.push_back(separator());
|
|
lines.push_back(text(" Health") | bold);
|
|
for (auto &h : health_rows) lines.push_back(std::move(h));
|
|
// Detail rows for the dropped-singleton NCs. Imported NCs are not
|
|
// expanded — they were already explicit in the netlist. Dropped NCs
|
|
// come from a heuristic, so listing them gives the user a chance to
|
|
// spot a false positive.
|
|
if (orph_dropped > 0) {
|
|
lines.push_back(hbox({
|
|
text(" dropped detail:") | dim,
|
|
}));
|
|
for (auto &dkv : dropped_by_module) {
|
|
std::sort(dkv.second.begin(), dkv.second.end(), NaturalLess);
|
|
std::string csv;
|
|
for (size_t i = 0; i < dkv.second.size(); ++i) {
|
|
if (i) csv += ", ";
|
|
csv += dkv.second[i];
|
|
}
|
|
lines.push_back(hbox({
|
|
text(" " + dkv.first + ": ") | bold,
|
|
text(csv),
|
|
}));
|
|
}
|
|
}
|
|
lines.push_back(separator());
|
|
lines.push_back(text(" Analysis") | bold);
|
|
lines.push_back(hbox({text(" • ") | dim,
|
|
text(std::to_string(n_diff) + " diff pair(s)")}));
|
|
lines.push_back(hbox({text(" • ") | dim,
|
|
text(std::to_string(n_diff_bus) + " diff bus(es)")}));
|
|
lines.push_back(hbox({text(" • ") | dim,
|
|
text(std::to_string(n_bus) + " bus(es)")}));
|
|
lines.push_back(hbox({text(" • ") | dim,
|
|
rep.anomalies.empty()
|
|
? text(std::to_string(rep.anomalies.size()) + " anomaly(ies)")
|
|
: text(std::to_string(rep.anomalies.size()) + " anomaly(ies)")
|
|
| color(Color::Yellow)}));
|
|
lines.push_back(separator());
|
|
lines.push_back(text(" Modules") | bold);
|
|
for (auto &r : mod_rows) lines.push_back(std::move(r));
|
|
|
|
// Clamp scroll, mark a focused line so `yframe` positions the view.
|
|
int line_count = (int)lines.size();
|
|
if (dashboard_scroll_offset < 0) dashboard_scroll_offset = 0;
|
|
if (dashboard_scroll_offset > line_count - 1)
|
|
dashboard_scroll_offset = std::max(0, line_count - 1);
|
|
lines[dashboard_scroll_offset] = lines[dashboard_scroll_offset] | focus;
|
|
|
|
Element main_col = vbox(std::move(lines))
|
|
| vscroll_indicator
|
|
| yframe
|
|
| flex;
|
|
|
|
Element help = RenderHelpPanel("dashboard", {
|
|
{"c", "console"},
|
|
{"p", "plug"},
|
|
{"e", "explore"},
|
|
{"a", "analyze (verify + groups)"},
|
|
{"h", "help screen"},
|
|
{"x", "export (CSV)"},
|
|
{"PgUp", "scroll up"},
|
|
{"PgDn", "scroll down"},
|
|
{"Home", "scroll top"},
|
|
{"End", "scroll bottom"},
|
|
{"Ctrl-P", "palette"},
|
|
{"q", "quit"},
|
|
});
|
|
|
|
return vbox({
|
|
title,
|
|
separator(),
|
|
hbox({main_col, separator(), help}) | flex,
|
|
}) | border;
|
|
});
|
|
}
|