Files
essim/src/tui/screen_dashboard.cpp
François 7d307dad57 Export command (CSV + ODS), file dialog, error modal, path persistence.
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>
2026-05-16 12:03:39 +02:00

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