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>
This commit is contained in:
2026-05-16 12:03:39 +02:00
parent f62f4a0c9b
commit 7d307dad57
18 changed files with 1085 additions and 103 deletions

View File

@@ -38,11 +38,12 @@ src/
analysis.{hpp,cpp} analyze_system → AnalysisReport (diff pairs, buses, anomalies)
persist.{hpp,cpp} save / restore (tab-delimited)
system.{hpp,cpp} System: owns Modules + Connections, exposes Load()
imports/ -- adapters that populate the domain
imports/ -- adapters that populate or emit the domain
import_base.hpp ImportBase interface
import_mentor.{hpp,cpp} Mentor Graphics netlist parser
import_altium.{hpp,cpp} Altium netlist parser (`[ ]` parts, `( )` signals)
import_ods.{hpp,cpp} ODS spreadsheet pinout (libzip + pugixml)
ods_writer.{hpp,cpp} Minimal .ods writer (multi-sheet, string cells)
tui/ -- FTXUI shell, split by responsibility
tui.{hpp,cpp} Class Tui (state + Run() orchestrator + screen-mode event dispatcher)
tui_helpers.{hpp,cpp} Free helpers: ToLower, NaturalLess, LongestCommonPrefix
@@ -56,6 +57,9 @@ src/
screen_dashboard.cpp BuildDashboardScreen (read-only system overview)
screen_analyze.cpp BuildAnalyzeScreen (anomalies / groups / power decisions)
screen_palette.cpp BuildPaletteModal (global Ctrl-P fuzzy launcher)
screen_filedialog.cpp BuildFileDialog + OpenFileDialog (reusable file picker)
screen_error.cpp BuildErrorModal + ShowError (centred error popup)
screen_help.cpp BuildHelpScreen (topic-driven feature reference)
screen_sigtype_modal.cpp BuildSignalTypeModal (popup attached to explore via Modal())
doc/classes.puml -- PlantUML class diagram
```
@@ -158,6 +162,26 @@ Exposed as the `analyze` shell command which prints groups (sorted by module + l
Everything is recomputed every frame so manual overrides via the signal-type popup are reflected immediately. Esc returns to the dashboard. The dashboard's previous `[v]erify` letter shortcut was removed — its content is fully covered by this screen. The textual `verify` / `analyze` commands still exist for scripts.
**Generic file-picker dialog** (`screen_filedialog.cpp`): one reusable modal for every "pick a path" interaction. State lives in `Tui::file_dialog` (a single `FileDialogState`); attached to the tab tree via `Modal(BuildFileDialog(), &file_dialog.open)` in `Run()`. API:
- `OpenFileDialog(title, persist_key, default_filename, filters, on_confirm)` — opens the modal, restoring the last-used `(dir, filename)` for `persist_key` if previously saved.
- `ConfirmFileDialog()` runs on Enter on the OK button: validates against the filter whitelist (rejects unknown extensions with an in-dialog status message), persists `(dir, filename)` under `persist_key`, closes the modal, then calls `on_confirm(full_path)`.
The optional `filters` vector (`{label, extension}` pairs) renders a horizontal Toggle at the top of the dialog. The first frame after Open seeds the filter index from the filename's current extension; subsequent index changes rewrite the filename extension so the caller's extension-based dispatch picks the right format. Empty filter list ⇒ no Toggle shown, no extension validation.
Today the only caller is `export` (`{"CSV", ".csv"}, {"ODS", ".ods"}` filters, key `export.connections`), but future callers (`save`, `restore`, `source`, …) plug in by changing four arguments.
**Generic error modal** (`screen_error.cpp`, `Tui::ShowError(msg)`): centred `borderRounded` popup with red title, the message (wrapped via `paragraph`), and an OK button. Esc / Enter dismiss. Stacked at the top of the Modal chain in `Run()` so it overlays every screen and every other modal. The error is also `Print()`-ed to the console log, so it remains inspectable after dismissal. Used by the export action (unknown extension, open-for-write failure, ODS save failure, unknown kind); other actions can adopt by replacing user-visible `Print("...failed...")` calls with `ShowError(...)`.
**Per-key path persistence** (`SaveLastUsed(key, dir, filename)` / `LoadLastUsed(key, &dir, &filename)` in `shell.cpp`): each key writes a tiny two-line file (`dir\nfilename\n`) under `UserDataDir() / <key>.last`. `UserDataDir()` is the cross-platform `XDG_DATA_HOME` / `LOCALAPPDATA` etc. helper also used by the command history file. Free functions, not Tui members, so any module (the file dialog today; could be the script-save buffer or the save command tomorrow) can use them with the same minimal API.
**ODS writer** (`src/imports/ods_writer.{hpp,cpp}`): minimal OpenDocument Spreadsheet writer. Backed by libzip + pugixml (both already pulled in by the ODS importer). Class hierarchy is two structs:
- `OdsSheet` — sparse row-major grid of string cells (`set(row, col, value)`).
- `OdsWriter` — owns the sheets, emits a valid `.ods` archive with `mimetype` (stored uncompressed, magic header), `META-INF/manifest.xml`, and `content.xml`.
`content.xml` is built with pugixml: one `<table:table>` per sheet, each row a `<table:table-row>` of string `<table:table-cell>` cells. String-only by design (no numbers, dates, formulas, styles, merges) — the format minimum for "rectangular text data" is concise enough to live in ~200 lines without depending on a heavyweight ODS library.
**Command palette** (`screen_palette.cpp`): a global modal launcher attached to the whole tab tree via `tab | Modal(BuildPaletteModal(), &palette_open)` in `Run()`. Trigger: `Event::CtrlP` (FTXUI Input does not consume Ctrl-P, so the outer `CatchEvent` reliably picks it up first). Behaviour: a single Input bound to `palette_query` plus a result list rebuilt on every frame. Indexes three kinds of entries: commands (from the `commands` map), modules and per-module signals (qualified as `module/signal`). Fuzzy match is subsequence-based, case-insensitive: lower score wins, computed as `first_match_position * 100 + sum_of_gaps`. Kinds are biased by a constant offset (commands +0, modules +1000, signals +2000) so command matches come first when scores tie. Output capped at 20 rows to keep render cheap on big systems. Activation (`Enter`): commands → `Dispatch(name)` (which dispatches like the shell, including opening interactive screens), module → prefill `explore_*` state and jump to `screen_idx = 4`, signal → prefill `net_modules` + seed `net_sig_filter` to the exact signal name and jump to `screen_idx = 5`. `Esc` closes the palette. While the palette is open, the outer `CatchEvent` cedes events to it so Tab/Esc/etc. don't leak into the underlying screen.
**Dashboard** (`screen_dashboard.cpp`, `dashboard` command, `screen_idx = 4`): read-only system overview. Single Renderer, no Input child. Recomputes everything per frame (cheap on realistic sizes): counters (modules/parts/signals/connections), three health rows (verify pin-role mismatches, bridged-net inconsistencies, NC orphans — green check / yellow warning prefix), an analysis summary line (diff pairs / buses / anomaly count, coloured if non-zero), and a per-module table (parts / signals / `connector_type`-tagged parts). Letter shortcuts handled in the outer `CatchEvent`: `c`=console, `p`=plug (connect), `t`=set-connector-type, `e`=explore, `a`=analyze, `q`=quit. `Esc` is swallowed on the dashboard (home). The dashboard is `interactive = true`, `scriptable = false`; running `dashboard` inside `source` aborts the script.

View File

@@ -142,3 +142,21 @@ Every classification is advisory. To force a different type:
Overrides survive `save`/`restore` but are recomputed at every
`load` (i.e. the inference re-runs).
## Exporting
Dump structured data to an external file:
- `export connections <file>` — every wire pair, one row, with the
signals and types on both sides plus a `suspect` flag (name says
Power but the structural check disagreed) and a `mixed` flag
(both sides typed but disagreeing).
- The dashboard's `[x]` shortcut, the `export` command without
arguments, or the palette entry `export`: opens an interactive
file-picker dialog. A small toggle at the top of the dialog lets
you pick the format — **CSV** (flat, one row per wire) or
**ODS** (one sheet per connection, opens directly in
LibreOffice / Excel). The dialog remembers the directory and
filename you used last so the next export resumes from there.
- Inline scriptable form: `export connections /path/foo.csv` (or
`.ods`). Unknown extensions raise an error popup.

View File

@@ -13,7 +13,43 @@ history.
## Interactive commands
### `connect` *(interactive)*
### `dashboard` *(interactive)*
open the dashboard (system overview)
**No arguments.**
**Notes**
- bare form opens an interactive screen; inline form (all args) is scriptable
- not recorded by `script-save` and rejected by `source`
---
### `explore` *(interactive)*
browse modules → parts/signals/connections → details (interactive)
**No arguments.**
**Notes**
- bare form opens an interactive screen; inline form (all args) is scriptable
- not recorded by `script-save` and rejected by `source`
---
### `export` *(interactive)*
export structured data to CSV (kinds: connections; bare form opens the file-picker dialog)
**Arguments**
1. `kind [connections]`
2. `filename (.csv)` *(Tab → path completion)*
**Notes**
- bare form opens an interactive screen; inline form (all args) is scriptable
---
### `plug` *(interactive)*
connect a part across two modules (interactive screen if no args)
@@ -29,47 +65,7 @@ connect a part across two modules (interactive screen if no args)
- bare form opens an interactive screen; inline form (all args) is scriptable
---
### `explore` *(interactive)*
browse modules → parts/signals/connections → details (interactive)
**No arguments.**
**Notes**
- bare form opens an interactive screen; inline form (all args) is scriptable
- not recorded by `script-save` and rejected by `source`
---
### `net` *(interactive)*
show all signals reachable from <module>/<signal> through connections (interactive screen if no args)
**Arguments**
1. `module`
2. `signal name`
**Notes**
- bare form opens an interactive screen; inline form (all args) is scriptable
---
### `search` *(interactive)*
list parts/signals matching a pattern (interactive screen if no args)
**Arguments**
1. `module`
2. `kind [parts|signals]`
3. `pattern`
**Notes**
- bare form opens an interactive screen; inline form (all args) is scriptable
---
### `set-type` *(interactive)*
### `set-connector-type` *(interactive)*
tag a part's connector type for transform lookup
@@ -87,6 +83,12 @@ tag a part's connector type for transform lookup
## Other commands
### `analyze`
detect signal groups (diff pairs, buses) and structural anomalies
**No arguments.**
---
### `clear`
clear the visualization area
@@ -115,7 +117,7 @@ leave essim (alias of quit)
---
### `help`
show command help (optionally for a specific command)
list commands (or `help <name>` for one command's details)
**Arguments**

148
src/imports/ods_writer.cpp Normal file
View File

@@ -0,0 +1,148 @@
#include "ods_writer.hpp"
#include <pugixml.hpp>
#include <zip.h>
#include <sstream>
#include <utility>
void OdsSheet::set(int row, int col, std::string value) {
if (row < 0 || col < 0) return;
if ((int)cells_.size() <= row) cells_.resize(row + 1);
if ((int)cells_[row].size() <= col) cells_[row].resize(col + 1);
cells_[row][col] = std::move(value);
}
int OdsSheet::cols() const {
int max = 0;
for (const auto &r : cells_)
if ((int)r.size() > max) max = (int)r.size();
return max;
}
const std::string &OdsSheet::cell(int row, int col) const {
static const std::string empty;
if (row < 0 || row >= (int)cells_.size()) return empty;
const auto &r = cells_[row];
if (col < 0 || col >= (int)r.size()) return empty;
return r[col];
}
OdsSheet *OdsWriter::add_sheet(const std::string &name) {
sheets_.emplace_back(name);
return &sheets_.back();
}
namespace {
// Build the `content.xml` payload for the spreadsheet. Returns the
// serialised UTF-8 string. The minimum schema the format requires for
// a string-only multi-sheet workbook.
std::string build_content_xml(const std::vector<OdsSheet> &sheets) {
pugi::xml_document doc;
auto decl = doc.append_child(pugi::node_declaration);
decl.append_attribute("version") = "1.0";
decl.append_attribute("encoding") = "UTF-8";
auto root = doc.append_child("office:document-content");
root.append_attribute("xmlns:office") =
"urn:oasis:names:tc:opendocument:xmlns:office:1.0";
root.append_attribute("xmlns:table") =
"urn:oasis:names:tc:opendocument:xmlns:table:1.0";
root.append_attribute("xmlns:text") =
"urn:oasis:names:tc:opendocument:xmlns:text:1.0";
root.append_attribute("office:version") = "1.2";
auto body = root.append_child("office:body");
auto sheet_root = body.append_child("office:spreadsheet");
for (const auto &s : sheets) {
auto table = sheet_root.append_child("table:table");
table.append_attribute("table:name") = s.name().c_str();
int rows = s.rows();
int cols = s.cols();
for (int r = 0; r < rows; ++r) {
auto row = table.append_child("table:table-row");
for (int c = 0; c < cols; ++c) {
auto cell = row.append_child("table:table-cell");
cell.append_attribute("office:value-type") = "string";
auto p = cell.append_child("text:p");
p.append_child(pugi::node_pcdata).set_value(s.cell(r, c).c_str());
}
}
}
std::ostringstream oss;
doc.save(oss, " ", pugi::format_no_declaration | pugi::format_raw);
// pugi's `format_no_declaration` drops the `<?xml…?>` line; we want
// it. Re-emit explicitly to control encoding.
return std::string(R"(<?xml version="1.0" encoding="UTF-8"?>)" "\n")
+ oss.str();
}
// Minimum META-INF/manifest.xml that satisfies the spec.
std::string build_manifest_xml() {
return R"(<?xml version="1.0" encoding="UTF-8"?>)" "\n"
R"(<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">)" "\n"
R"( <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>)" "\n"
R"( <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>)" "\n"
R"(</manifest:manifest>)" "\n";
}
// libzip wrapper for "add a string as a file with the given name". The
// buffer must outlive zip_close, which is why callers keep a vector of
// owned strings alive until after the close.
bool add_string_entry(zip_t *zip, const std::string &path,
const std::string &data, bool store_only,
std::string &error) {
zip_source_t *src = zip_source_buffer(zip, data.data(),
data.size(), 0);
if (!src) { error = "zip_source_buffer failed"; return false; }
zip_int64_t idx = zip_file_add(zip, path.c_str(), src, ZIP_FL_OVERWRITE);
if (idx < 0) {
zip_source_free(src);
error = std::string("zip_file_add(") + path + "): "
+ zip_strerror(zip);
return false;
}
if (store_only) {
// Mandatory for the `mimetype` entry per ODF/OOXML magic-header
// convention: stored uncompressed so the package can be
// detected by reading the first 80 bytes.
zip_set_file_compression(zip, idx, ZIP_CM_STORE, 0);
}
return true;
}
} // namespace
bool OdsWriter::save(const std::string &path, std::string &error) const {
int zerr = 0;
zip_t *zip = zip_open(path.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &zerr);
if (!zip) {
zip_error_t err;
zip_error_init_with_code(&err, zerr);
error = std::string("zip_open(") + path + "): " + zip_error_strerror(&err);
zip_error_fini(&err);
return false;
}
// Buffers must outlive zip_close — keep them owned here.
std::string mimetype = "application/vnd.oasis.opendocument.spreadsheet";
std::string manifest = build_manifest_xml();
std::string content = build_content_xml(sheets_);
if (!add_string_entry(zip, "mimetype", mimetype, /*store=*/true, error) ||
!add_string_entry(zip, "META-INF/manifest.xml", manifest, /*store=*/false, error) ||
!add_string_entry(zip, "content.xml", content, /*store=*/false, error)) {
zip_discard(zip);
return false;
}
if (zip_close(zip) != 0) {
error = std::string("zip_close: ") + zip_strerror(zip);
zip_discard(zip);
return false;
}
return true;
}

View File

@@ -0,0 +1,53 @@
#ifndef _IMPORTS_ODS_WRITER_HPP_
#define _IMPORTS_ODS_WRITER_HPP_
#include <string>
#include <vector>
// Minimal OpenDocument Spreadsheet (.ods) writer. Backed by libzip +
// pugixml (already in the build for the ODS *importer*). Produces
// multi-sheet workbooks; each sheet is a 2-D grid of string cells.
//
// Usage:
// OdsWriter w;
// auto *s1 = w.add_sheet("connection A");
// s1->set(0, 0, "header1");
// s1->set(0, 1, "header2");
// s1->set(1, 0, "value");
// std::string err;
// bool ok = w.save("out.ods", err);
//
// Limitations (intentional, the format is huge):
// - string cells only (no numbers / dates / formulas)
// - no styles, no merged cells, no formatting
// - a single empty row is emitted when a row has at least one set cell;
// trailing empty rows are skipped.
class OdsSheet {
public:
explicit OdsSheet(std::string name) : name_(std::move(name)) {}
void set(int row, int col, std::string value);
const std::string &name() const { return name_; }
int rows() const { return (int)cells_.size(); }
int cols() const;
const std::string &cell(int row, int col) const;
private:
std::string name_;
// Row-major sparse storage: cells_[r][c] = value. Rows/cols are grown
// lazily on set().
std::vector<std::vector<std::string>> cells_;
};
class OdsWriter {
public:
OdsSheet *add_sheet(const std::string &name);
bool save(const std::string &path, std::string &error) const;
private:
std::vector<OdsSheet> sheets_;
};
#endif // _IMPORTS_ODS_WRITER_HPP_

View File

@@ -8,17 +8,29 @@ namespace {
void print_usage(const char *prog) {
std::cerr <<
"usage: " << prog << " [--commands-md [file] | --help]\n"
" (no args) launch the interactive TUI\n"
" --commands-md [file] dump the command registry as Markdown.\n"
" With <file>: write there. Without: stdout.\n"
"usage: " << prog << " [--source FILE] [--restore FILE]\n"
" " << prog << " --commands-md [FILE]\n"
" " << prog << " --help\n"
" (no args) launch the TUI on an empty system.\n"
" --source FILE after boot, run FILE as an essim script\n"
" (one command per line; same as the `source`\n"
" command). Output is in the console screen.\n"
" --restore FILE after boot, restore the system snapshot in\n"
" FILE (same as the `restore` command).\n"
" Combine with --source to layer a script on\n"
" top of a restored snapshot.\n"
" --commands-md [FILE] dump the command registry as Markdown.\n"
" With FILE: write there. Without: stdout.\n"
" (Used by `cmake --build build --target doc`.)\n"
" --help, -h show this help\n";
" --help, -h show this help.\n";
}
} // namespace
int main(int argc, char **argv) {
std::string boot_restore;
std::string boot_source;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--commands-md") {
@@ -35,6 +47,22 @@ int main(int argc, char **argv) {
}
return 0;
}
if (a == "--source") {
if (i + 1 >= argc) {
std::cerr << "essim: --source needs a filename\n";
return 2;
}
boot_source = argv[++i];
continue;
}
if (a == "--restore") {
if (i + 1 >= argc) {
std::cerr << "essim: --restore needs a filename\n";
return 2;
}
boot_restore = argv[++i];
continue;
}
if (a == "--help" || a == "-h") {
print_usage(argv[0]);
return 0;
@@ -45,6 +73,11 @@ int main(int argc, char **argv) {
}
Tui tui;
// Order matters: a `--restore` brings up a snapshot, then `--source`
// can layer additional commands on top of it (useful e.g. for "load
// snapshot, then re-run a small script that adds a new card").
if (!boot_restore.empty()) tui.BootDispatch("restore " + boot_restore);
if (!boot_source.empty()) tui.BootDispatch("source " + boot_source);
tui.Run();
return 0;
}

View File

@@ -1,6 +1,7 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include "imports/ods_writer.hpp"
#include "system/analysis.hpp"
#include "system/connect.hpp"
#include "system/modules.hpp"
@@ -205,6 +206,181 @@ void Tui::RegisterCommands() {
"write the current system snapshot to a file",
};
commands["export"] = {
{{"kind [connections]", Completion::None},
{"filename (.csv)", Completion::Path}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
if (args.empty()) {
// Bare → reuse the generic file dialog. The kind defaults
// to "connections" (the only one today); when more kinds
// are added, prompt for it inside the dialog before the
// filename step. Filters give the user a one-keystroke
// toggle between CSV and ODS — picking either rewrites
// the filename's extension, and the action below
// dispatches on that extension.
OpenFileDialog(
"Export — connections",
"export.connections",
"connections.csv",
{{"CSV", ".csv"}, {"ODS", ".ods"}},
[this](const std::string &path) {
Dispatch("export connections " + path);
});
return;
}
if (args.size() != 2) {
Print("usage: export <kind> <file> (or no args for the dialog)");
return;
}
const std::string &kind = args[0];
const std::string &path = args[1];
// Minimal CSV quoter — wraps in `"…"` and doubles internal
// quotes when the field contains a comma, quote, or newline.
auto q = [](const std::string &s) -> std::string {
bool needs = s.find_first_of(",\"\n") != std::string::npos;
if (!needs) return s;
std::string out = "\"";
for (char c : s) { if (c == '"') out += '"'; out += c; }
out += '"';
return out;
};
if (kind == "connections") {
auto 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";
}
};
// Dispatch on extension. Accepted: `.csv` (flat file) and
// `.ods` (one sheet per connection). Anything else is an
// error — silently writing a CSV under a `.xyz` filename
// would be confusing.
std::string ext;
{
size_t dot = path.rfind('.');
if (dot != std::string::npos) ext = ToLower(path.substr(dot));
}
bool ods = (ext == ".ods");
bool csv = (ext == ".csv");
if (!ods && !csv) {
ShowError("export: unknown extension '"
+ (ext.empty() ? std::string("(none)") : ext)
+ "'\nAccepted: .csv, .ods");
return;
}
if (ods) {
OdsWriter w;
int total = 0;
for (auto &ckv : *sys->connections()) {
Connection *c = ckv.second;
// Sheet names can't contain '/' in some viewers
// (LibreOffice tolerates it, Excel rejects it).
// Sanitise just in case.
std::string sname = c->name;
for (char &ch : sname)
if (ch == '/' || ch == '\\' || ch == '?' || ch == '*'
|| ch == ':' || ch == '[' || ch == ']') ch = '_';
OdsSheet *s = w.add_sheet(sname);
const char *hdr[] = {
"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",
"mixed"};
for (int i = 0; i < 14; ++i) s->set(0, i, hdr[i]);
int row = 1;
for (auto &wp : c->pin_map) {
std::string lm, lp, ln, ls, lt, lsus;
std::string rm, rp, rn, rs, rt, rsus;
side(wp.first, lm, lp, ln, ls, lt, lsus);
side(wp.second, rm, rp, rn, rs, rt, rsus);
std::string mixed = (lt != "(NC)" && rt != "(NC)" && lt != rt)
? "yes" : "no";
s->set(row, 0, c->transform_name);
s->set(row, 1, lm); s->set(row, 2, lp);
s->set(row, 3, ln); s->set(row, 4, ls);
s->set(row, 5, lt); s->set(row, 6, lsus);
s->set(row, 7, rm); s->set(row, 8, rp);
s->set(row, 9, rn); s->set(row, 10, rs);
s->set(row, 11, rt); s->set(row, 12, rsus);
s->set(row, 13, mixed);
++row; ++total;
}
}
std::string err;
if (!w.save(path, err)) {
ShowError("export (.ods) failed:\n" + err);
return;
}
Print("export connections (.ods): "
+ std::to_string(sys->connections()->size())
+ " sheet(s), " + std::to_string(total)
+ " wire(s) → " + path);
return;
}
// CSV fallback.
std::ofstream f(path);
if (!f) {
ShowError("export: cannot open '" + path + "' for writing");
return;
}
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,"
"mixed\n";
int rows = 0;
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;
side(wp.first, lm, lp, ln, ls, lt, lsus);
side(wp.second, rm, rp, rn, rs, rt, rsus);
std::string mixed = "no";
if (lt != "(NC)" && rt != "(NC)" && lt != rt) mixed = "yes";
f << q(c->name) << ',' << q(c->transform_name) << ','
<< q(lm) << ',' << q(lp) << ',' << q(ln) << ','
<< q(ls) << ',' << q(lt) << ',' << q(lsus) << ','
<< q(rm) << ',' << q(rp) << ',' << q(rn) << ','
<< q(rs) << ',' << q(rt) << ',' << q(rsus) << ','
<< mixed << '\n';
++rows;
}
}
Print("export connections (.csv): " + std::to_string(rows)
+ " wire(s) → " + path);
return;
}
ShowError("export: unknown kind '" + kind + "'\n"
"Known kinds: connections");
},
/*prompt_for_missing=*/ false,
"export structured data to CSV (kinds: connections; "
"bare form opens the file-picker dialog)",
/*scriptable=*/ true,
/*interactive=*/ true,
};
commands["restore"] = {
{{"filename", Completion::Path}},
[this](const std::vector<std::string> &args) {

View File

@@ -269,10 +269,14 @@ Component Tui::BuildAnalyzeScreen() {
term("Gnd",
"Name matches GND, SHIELD, CHASSIS or EARTH. Name alone is "
"enough — false positives are essentially nil."),
}) | borderRounded | size(WIDTH, EQUAL, 32);
}) | borderRounded
| size(WIDTH, LESS_THAN, 36)
| size(WIDTH, GREATER_THAN, 20);
Element side = (analyze_focus_idx == 2)
? vbox({help, text(""), types_glossary}) | size(WIDTH, EQUAL, 32)
? vbox({help, text(""), types_glossary})
| size(WIDTH, LESS_THAN, 36)
| size(WIDTH, GREATER_THAN, 20)
: help;
return vbox({

View File

@@ -299,6 +299,7 @@ Component Tui::BuildDashboardScreen() {
{"e", "explore"},
{"a", "analyze (verify + groups)"},
{"h", "help screen"},
{"x", "export (CSV)"},
{"PgUp", "scroll up"},
{"PgDn", "scroll down"},
{"Home", "scroll top"},

35
src/tui/screen_error.cpp Normal file
View File

@@ -0,0 +1,35 @@
#include "tui/tui.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/event.hpp>
#include <ftxui/dom/elements.hpp>
using namespace ftxui;
void Tui::ShowError(const std::string &msg) {
error_message = msg;
error_open = true;
// Echo to the console log too — the modal is dismissable, the log
// is a record for later inspection.
Print("error: " + msg);
}
Component Tui::BuildErrorModal() {
auto button = Button(" OK ", [this]() { error_open = false; });
auto handler = CatchEvent(button, [this](Event e) {
if (e == Event::Escape) { error_open = false; return true; }
if (e == Event::Return) { error_open = false; return true; }
return false;
});
return Renderer(handler, [this, button] {
return vbox({
text(" Error ") | bold | center | color(Color::Red),
separator(),
paragraph(error_message),
separator(),
hbox({filler(), button->Render(), filler()}),
}) | borderRounded
| size(WIDTH, LESS_THAN, 70)
| size(WIDTH, GREATER_THAN, 40);
});
}

View File

@@ -84,29 +84,47 @@ Component Tui::BuildExploreScreen() {
auto detail_filter = Input(&explore_detail_filter, "filter…", pf_opt);
MenuOption detail_opt = MenuOption::Vertical();
detail_opt.entries = &explore_detail;
detail_opt.selected = &explore_detail_idx;
// Each `explore_detail_sig` slot is either empty (no action) or a
// `module\tsignal` pair. The cross-module form is what lets Enter on a
// net member row open the popup for that peer module's signal.
detail_opt.on_enter = [this]() {
if (explore_detail_idx < 0
|| explore_detail_idx >= (int)explore_detail_sig.size()) return;
const std::string &payload = explore_detail_sig[explore_detail_idx];
auto fire = [this](const std::vector<std::string> &sigs, int idx) {
if (idx < 0 || idx >= (int)sigs.size()) return;
const std::string &payload = sigs[idx];
if (payload.empty()) return;
size_t tab = payload.find('\t');
if (tab == std::string::npos) return;
OpenSignalTypeDialog(payload.substr(0, tab), payload.substr(tab + 1));
};
MenuOption detail_opt = MenuOption::Vertical();
detail_opt.entries = &explore_detail;
detail_opt.selected = &explore_detail_idx;
detail_opt.focused_entry = &explore_detail_idx;
detail_opt.on_enter = [this, fire]() {
fire(explore_detail_sig, explore_detail_idx);
};
auto detail_menu = Menu(detail_opt);
// Second detail menu — only the signals tab populates it (with the
// BFS net members). Vertically split inside col4 so the user sees the
// local-pin section AND the cross-module section without scrolling.
MenuOption detail2_opt = MenuOption::Vertical();
detail2_opt.entries = &explore_detail2;
detail2_opt.selected = &explore_detail2_idx;
detail2_opt.focused_entry = &explore_detail2_idx;
detail2_opt.on_enter = [this, fire]() {
fire(explore_detail2_sig, explore_detail2_idx);
};
auto detail2_menu = Menu(detail2_opt);
auto components = Container::Vertical(
{module_menu, type_menu, child_filter, children_menu, detail_filter, detail_menu},
{module_menu, type_menu, child_filter, children_menu, detail_filter,
detail_menu, detail2_menu},
&explore_focus_idx);
return Renderer(components,
[this, module_menu, type_menu, child_filter, children_menu, detail_filter, detail_menu] {
[this, module_menu, type_menu, child_filter, children_menu,
detail_filter, detail_menu, detail2_menu] {
try {
// Clamp the module index defensively (covers fresh systems / restore).
if (explore_module_idx < 0
@@ -162,6 +180,8 @@ Component Tui::BuildExploreScreen() {
explore_header = "(no system)";
explore_detail.clear();
explore_detail_sig.clear();
explore_detail2.clear();
explore_detail2_sig.clear();
if (cur_mod && !explore_children.empty()) {
const std::string &cname = explore_children[explore_child_idx];
try {
@@ -219,12 +239,9 @@ Component Tui::BuildExploreScreen() {
+ " pins • type: " + signal_type_name(s->type)
+ "" + net_hdr;
// Local pins first. Enter on any of these reopens the
// signal-type popup for the current signal (= same as
// Enter on the signal in the children menu — redundant
// but natural).
explore_detail.push_back(" Local pins:");
explore_detail_sig.push_back({});
// Local pins go to the TOP sub-panel (explore_detail).
// Enter on any of these reopens the signal-type popup
// for the current signal.
std::string self_payload = cur_mod->name + "\t" + s->name;
std::vector<std::string> rows;
for (auto &pin_kv : *s) {
@@ -237,19 +254,15 @@ Component Tui::BuildExploreScreen() {
std::sort(rows.begin(), rows.end(), NaturalLess);
for (const auto &r : rows)
if (keep_detail(r)) {
explore_detail.push_back(" " + r);
explore_detail.push_back(" " + r);
explore_detail_sig.push_back(self_payload);
}
// Cross-module net members (only when truly bridged).
// Each row carries its own `module\tsignal` payload so
// Enter opens the popup for that peer-module signal —
// not the locally-selected module.
// Cross-module net members → BOTTOM sub-panel
// (explore_detail2). Each row carries its own
// `module\tsignal` payload so Enter opens the popup for
// that peer-module signal, not the locally selected one.
if (n.members.size() >= 2) {
explore_detail.push_back("");
explore_detail_sig.push_back({});
explore_detail.push_back(" Net members (across connections):");
explore_detail_sig.push_back({});
std::vector<std::pair<std::string, std::string>> net_rows;
for (const auto &mp : n.members) {
std::string label = mp.first->name + "/" + mp.second->name
@@ -262,8 +275,8 @@ Component Tui::BuildExploreScreen() {
[](const auto &a, const auto &b) { return NaturalLess(a.first, b.first); });
for (const auto &r : net_rows)
if (keep_detail(r.first)) {
explore_detail.push_back(" " + r.first);
explore_detail_sig.push_back(r.second);
explore_detail2.push_back(" " + r.first);
explore_detail2_sig.push_back(r.second);
}
}
} else {
@@ -295,23 +308,35 @@ 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.
// Pad parallel sig vectors so any index 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;
}
// detail2 is only populated for the signals tab when the net has
// ≥ 2 members. Otherwise hide it by leaving it empty.
while (explore_detail2_sig.size() < explore_detail2.size())
explore_detail2_sig.push_back({});
if (explore_detail2_idx < 0
|| explore_detail2_idx >= (int)explore_detail2.size()) {
explore_detail2_idx = 0;
}
// Columns use LESS_THAN/GREATER_THAN bounds rather than strict
// EQUAL so the layout adapts when the terminal is narrow — without
// these, a fixed total >= viewport clips the rightmost element
// (the help panel) to nothing.
auto col1 = vbox({
FocusLabel(text(" module "), explore_focus_idx == 0) | bold,
module_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 24);
}) | size(WIDTH, LESS_THAN, 26) | size(WIDTH, GREATER_THAN, 14);
auto col2 = vbox({
FocusLabel(text(" type "), explore_focus_idx == 1) | bold,
type_menu->Render() | flex,
}) | size(WIDTH, EQUAL, 12);
}) | size(WIDTH, LESS_THAN, 14) | size(WIDTH, GREATER_THAN, 10);
auto col3 = vbox({
FocusLabel(text(" " + explore_types[explore_type_idx] + " "),
@@ -319,15 +344,37 @@ Component Tui::BuildExploreScreen() {
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 2),
child_filter->Render() | flex}) | border,
children_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 36);
}) | size(WIDTH, LESS_THAN, 38) | size(WIDTH, GREATER_THAN, 22);
auto col4 = vbox({
FocusLabel(text(" " + explore_header + " "),
explore_focus_idx == 5) | bold,
// col4 is split horizontally inside the same column width: top
// sub-panel = `explore_detail` (Local pins), bottom sub-panel =
// `explore_detail2` (Net members across connections). The bottom
// panel is shown only when there is something to show — for the
// parts / connections tabs, it stays hidden so col4 looks like a
// single panel.
Elements col4_rows;
col4_rows.push_back(FocusLabel(text(" " + explore_header + " "),
explore_focus_idx == 5) | bold);
col4_rows.push_back(
hbox({FocusLabel(text(" filter: "), explore_focus_idx == 4),
detail_filter->Render() | flex}) | border,
detail_menu->Render() | vscroll_indicator | yframe | flex,
}) | flex;
detail_filter->Render() | flex}) | border);
if (!explore_detail2.empty()) {
col4_rows.push_back(
FocusLabel(text(" local pins "),
explore_focus_idx == 5) | dim);
col4_rows.push_back(
detail_menu->Render() | vscroll_indicator | yframe | flex);
col4_rows.push_back(separator());
col4_rows.push_back(
FocusLabel(text(" net members "),
explore_focus_idx == 6) | dim);
col4_rows.push_back(
detail2_menu->Render() | vscroll_indicator | yframe | flex);
} else {
col4_rows.push_back(
detail_menu->Render() | vscroll_indicator | yframe | flex);
}
auto col4 = vbox(std::move(col4_rows)) | flex;
auto title = hbox({
text(" essim ") | bold,

View File

@@ -0,0 +1,279 @@
#include "tui/tui.hpp"
#include "tui/tui_helpers.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp>
#include <ftxui/component/event.hpp>
#include <ftxui/dom/elements.hpp>
#include <algorithm>
#include <exception>
#include <filesystem>
using namespace ftxui;
namespace fs = std::filesystem;
// Free helpers defined in shell.cpp.
void SaveLastUsed(const std::string &key,
const std::string &dir,
const std::string &filename);
bool LoadLastUsed(const std::string &key,
std::string &dir, std::string &filename);
namespace {
// Return the lowercased extension (with leading dot) of `name`, or empty.
std::string ext_of(const std::string &name) {
size_t dot = name.rfind('.');
if (dot == std::string::npos) return {};
std::string e = name.substr(dot);
for (char &c : e) c = (char)std::tolower((unsigned char)c);
return e;
}
// Replace the trailing extension of `name` with `new_ext` (which already
// includes the dot). If no dot is present, append it.
void replace_ext(std::string &name, const std::string &new_ext) {
size_t dot = name.rfind('.');
if (dot == std::string::npos) { name += new_ext; return; }
name = name.substr(0, dot) + new_ext;
}
// Index of the filter whose extension matches `e`, or -1.
int filter_for_ext(const std::vector<Tui::FilenameFilter> &filters,
const std::string &e) {
for (int i = 0; i < (int)filters.size(); ++i)
if (filters[i].extension == e) return i;
return -1;
}
} // namespace
void Tui::OpenFileDialog(std::string title,
std::string persist_key,
std::string default_filename,
std::vector<FilenameFilter> filters,
std::function<void(const std::string &)> on_confirm) {
file_dialog.title = std::move(title);
file_dialog.persist_key = std::move(persist_key);
file_dialog.on_confirm = std::move(on_confirm);
file_dialog.filters = std::move(filters);
file_dialog.filter_labels.clear();
for (const auto &f : file_dialog.filters)
file_dialog.filter_labels.push_back(f.label);
file_dialog.status.clear();
std::string saved_dir, saved_fn;
if (LoadLastUsed(file_dialog.persist_key, saved_dir, saved_fn)) {
file_dialog.dir = std::move(saved_dir);
file_dialog.filename = std::move(saved_fn);
} else {
std::error_code ec;
fs::path cwd = fs::current_path(ec);
file_dialog.dir = ec ? std::string(".") : cwd.string();
file_dialog.filename = std::move(default_filename);
}
// Sync filter_idx to the current filename extension if one matches.
file_dialog.filter_idx = 0;
if (!file_dialog.filters.empty()) {
int i = filter_for_ext(file_dialog.filters, ext_of(file_dialog.filename));
if (i >= 0) file_dialog.filter_idx = i;
}
file_dialog.entry_idx = 0;
// Default focus: filename input. With filters present that's slot 2;
// without, slot 1.
file_dialog.focus_idx = file_dialog.filters.empty() ? 1 : 2;
file_dialog.open = true;
}
void Tui::ConfirmFileDialog() {
if (file_dialog.filename.empty()) {
file_dialog.status = "filename is empty";
return;
}
// When the caller provided a filter list, treat it as a whitelist of
// acceptable extensions. An unrecognised extension keeps the dialog
// open with an explanatory status — never silently fall through to a
// wrong-format write.
if (!file_dialog.filters.empty()) {
std::string e = ext_of(file_dialog.filename);
if (filter_for_ext(file_dialog.filters, e) < 0) {
std::string allowed;
for (size_t i = 0; i < file_dialog.filters.size(); ++i) {
if (i) allowed += ", ";
allowed += file_dialog.filters[i].extension;
}
file_dialog.status = "unknown extension '"
+ (e.empty() ? std::string("(none)") : e)
+ "' — accepted: " + allowed;
return;
}
}
fs::path full = fs::path(file_dialog.dir) / file_dialog.filename;
auto cb = file_dialog.on_confirm;
SaveLastUsed(file_dialog.persist_key,
file_dialog.dir, file_dialog.filename);
file_dialog.open = false;
if (cb) cb(full.string());
}
Component Tui::BuildFileDialog() {
InputOption fn_opt;
fn_opt.multiline = false;
fn_opt.transform = [](InputState s) {
auto el = s.element;
if (s.is_placeholder) el |= dim;
return el;
};
auto filename_input = Input(&file_dialog.filename, "filename…", fn_opt);
MenuOption entries_opt = MenuOption::Vertical();
entries_opt.entries = &file_dialog.entries;
entries_opt.selected = &file_dialog.entry_idx;
entries_opt.focused_entry = &file_dialog.entry_idx;
entries_opt.on_enter = [this]() {
if (file_dialog.entry_idx < 0
|| file_dialog.entry_idx >= (int)file_dialog.entries.size()) return;
const std::string &entry = file_dialog.entries[file_dialog.entry_idx];
if (!file_dialog.entries_is_dir[file_dialog.entry_idx]) {
file_dialog.filename = entry;
// sync filter to the picked file's extension
if (!file_dialog.filters.empty()) {
int i = filter_for_ext(file_dialog.filters,
ext_of(file_dialog.filename));
if (i >= 0) file_dialog.filter_idx = i;
}
file_dialog.focus_idx = file_dialog.filters.empty() ? 2 : 3;
return;
}
std::error_code ec;
fs::path next;
if (entry == "../") next = fs::path(file_dialog.dir).parent_path();
else next = fs::path(file_dialog.dir) / entry;
fs::path canon = fs::canonical(next, ec);
if (ec) return;
file_dialog.dir = canon.string();
file_dialog.entry_idx = 0;
};
auto entries_menu = Menu(entries_opt);
// Horizontal filter selector (Toggle). The sync between the picked
// filter and the filename extension is done inside the Renderer
// lambda below (FTXUI's plain Toggle API has no on_change hook).
auto filter_toggle = Toggle(&file_dialog.filter_labels, &file_dialog.filter_idx);
auto button = Button(" OK ", [this]() { ConfirmFileDialog(); });
auto container = Container::Vertical(
{filter_toggle, entries_menu, filename_input, button},
&file_dialog.focus_idx);
auto handler = CatchEvent(container, [this](Event e) {
if (e == Event::Escape) { file_dialog.open = false; return true; }
// The number of focusable slots depends on whether filters are
// present (4 with, 3 without). Skip slot 0 (filters) when empty.
int n = file_dialog.filters.empty() ? 3 : 4;
int base = file_dialog.filters.empty() ? 1 : 0;
if (e == Event::Tab) {
do { file_dialog.focus_idx = (file_dialog.focus_idx + 1) % 4;
} while (file_dialog.focus_idx < base);
(void)n;
return true;
}
if (e == Event::TabReverse) {
do { file_dialog.focus_idx = (file_dialog.focus_idx + 3) % 4;
} while (file_dialog.focus_idx < base);
return true;
}
return false;
});
return Renderer(handler,
[this, entries_menu, filename_input, button, filter_toggle,
last_filter_idx = int(-1)]() mutable {
// Sync filename extension to the currently picked filter when it
// changes (since FTXUI Toggle has no on_change hook).
if (!file_dialog.filters.empty()
&& file_dialog.filter_idx != last_filter_idx
&& file_dialog.filter_idx >= 0
&& file_dialog.filter_idx < (int)file_dialog.filters.size()) {
// On the first frame after Open, just record the index — don't
// rewrite the user's chosen filename. From then on, every
// change rewrites the extension.
if (last_filter_idx >= 0) {
replace_ext(file_dialog.filename,
file_dialog.filters[file_dialog.filter_idx].extension);
}
last_filter_idx = file_dialog.filter_idx;
}
file_dialog.entries.clear();
file_dialog.entries_is_dir.clear();
file_dialog.entries.push_back("../");
file_dialog.entries_is_dir.push_back(true);
try {
std::vector<std::pair<std::string, bool>> raw;
for (const auto &e : fs::directory_iterator(file_dialog.dir)) {
std::string n = e.path().filename().string();
bool is_dir = e.is_directory();
raw.emplace_back(is_dir ? n + "/" : n, is_dir);
}
std::sort(raw.begin(), raw.end(),
[](const auto &a, const auto &b) {
if (a.second != b.second) return a.second;
return NaturalLess(a.first, b.first);
});
for (auto &p : raw) {
file_dialog.entries.push_back(std::move(p.first));
file_dialog.entries_is_dir.push_back(p.second);
}
} catch (const std::exception &) {}
if (file_dialog.entry_idx >= (int)file_dialog.entries.size())
file_dialog.entry_idx = (int)file_dialog.entries.size() - 1;
if (file_dialog.entry_idx < 0) file_dialog.entry_idx = 0;
Element status = file_dialog.status.empty()
? text("") | dim
: text(" " + file_dialog.status + " ") | bold;
Elements rows;
rows.push_back(text(" " + file_dialog.title + " ") | bold | center);
rows.push_back(separator());
if (!file_dialog.filters.empty()) {
rows.push_back(hbox({
FocusLabel(text(" format: "), file_dialog.focus_idx == 0) | bold,
filter_toggle->Render(),
}));
rows.push_back(separator());
}
rows.push_back(hbox({text(" dir: ") | dim, text(file_dialog.dir)}));
rows.push_back(separator());
int entries_focus = file_dialog.filters.empty() ? 0 : 1;
int fname_focus = file_dialog.filters.empty() ? 1 : 2;
int btn_focus = file_dialog.filters.empty() ? 2 : 3;
rows.push_back(FocusLabel(text(" entries "),
file_dialog.focus_idx == entries_focus) | bold);
rows.push_back(entries_menu->Render() | vscroll_indicator | yframe
| size(HEIGHT, LESS_THAN, 16) | size(HEIGHT, GREATER_THAN, 6));
rows.push_back(separator());
rows.push_back(hbox({
FocusLabel(text(" filename: "), file_dialog.focus_idx == fname_focus),
filename_input->Render() | flex,
}) | border);
rows.push_back(separator());
rows.push_back(hbox({filler(),
FocusLabel(button->Render(),
file_dialog.focus_idx == btn_focus),
filler()}));
rows.push_back(status);
rows.push_back(text(" Tab cycle • Enter: cd / pick / confirm • Esc cancel ") | dim);
return vbox(std::move(rows))
| borderRounded
| size(WIDTH, LESS_THAN, 80)
| size(WIDTH, GREATER_THAN, 50);
});
}

View File

@@ -159,6 +159,24 @@ const std::vector<std::pair<std::string, std::string>> &topics() {
"v1` header. The S-record line tags a signal type only when it "
"differs from the default (Other), keeping snapshots small."},
{"Export",
"`export connections <file>` dumps every wire pair to an "
"external file. Picking the extension picks the format: "
"`.csv` writes one flat file (one row per pin↔pin), `.ods` "
"writes a multi-sheet OpenDocument workbook (one sheet per "
"connection). Each row carries the signals on both sides, "
"their types, a `suspect` flag (name suggests Power but the "
"structural check disagreed) and a `mixed` flag (both sides "
"typed but disagreeing).\n\n"
"Bare `export` (or the dashboard `[x]` shortcut, or the "
"palette entry `export`) opens an interactive file-picker "
"dialog. A toggle at the top of the dialog switches between "
"CSV and ODS — picking either rewrites the filename's "
"extension to match. The dialog remembers the last used "
"directory and filename for next time.\n\n"
"An unknown extension produces an error popup and writes "
"nothing — never a silent wrong-format dump."},
{"Quitting",
"From the dashboard: press `q`, or run the `quit` (or `exit`) "
"command from the console / palette. Quit works from any "
@@ -216,7 +234,7 @@ Component Tui::BuildHelpScreen() {
text(" topic ") | bold,
separator(),
topic_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 28);
}) | size(WIDTH, LESS_THAN, 30) | size(WIDTH, GREATER_THAN, 16);
auto center = vbox(std::move(body))
| vscroll_indicator | yframe | flex;

View File

@@ -115,7 +115,7 @@ Component Tui::BuildSettypeScreen() {
auto left = vbox({
FocusLabel(text(" module "), settype_focus_idx == 0) | bold,
module_menu->Render() | yframe | flex,
}) | size(WIDTH, EQUAL, 28);
}) | size(WIDTH, LESS_THAN, 30) | size(WIDTH, GREATER_THAN, 16);
auto middle = vbox({
hbox({FocusLabel(text(" filter: "), settype_focus_idx == 1),

View File

@@ -16,6 +16,21 @@
#include <system_error>
#include <thread>
void Tui::BootDispatch(const std::string &raw) {
// Called before Run() — no FTXUI screen yet, so any `Source()` invoked
// from here takes the headless synchronous branch (drains the script
// without the event-paced loop). Output goes to `output` and is
// visible as soon as the screen starts.
//
// We pin `screen_idx = 0` (console) for the duration of the dispatch
// so the source loop's "is interactive (would open a screen)" guard
// doesn't trip on the constructor's boot value of 4 (dashboard).
// After execution, restore to 4 so the user lands on the dashboard.
screen_idx = 0;
Dispatch(raw);
screen_idx = 4;
}
void Tui::OpenSignalTypeDialog(const std::string &mod_name,
const std::string &sig_name) {
if (!sys) return;
@@ -228,26 +243,73 @@ std::string Tui::ExpandVars(const std::string &s) const {
namespace {
std::filesystem::path HistoryPath() {
// User-data dir, platform-aware. Returns the base directory under which
// every persistent file lives (history, export-last, …). Empty if none
// of the expected env vars are set.
std::filesystem::path UserDataDir() {
namespace fs = std::filesystem;
#ifdef _WIN32
if (const char *p = std::getenv("LOCALAPPDATA"); p && *p)
return fs::path(p) / "essim" / "history";
return fs::path(p) / "essim";
if (const char *p = std::getenv("APPDATA"); p && *p)
return fs::path(p) / "essim" / "history";
return fs::path(p) / "essim";
if (const char *p = std::getenv("USERPROFILE"); p && *p)
return fs::path(p) / "AppData" / "Local" / "essim" / "history";
return fs::path(p) / "AppData" / "Local" / "essim";
#else
if (const char *p = std::getenv("XDG_DATA_HOME"); p && *p)
return fs::path(p) / "essim" / "history";
return fs::path(p) / "essim";
if (const char *p = std::getenv("HOME"); p && *p)
return fs::path(p) / ".local" / "share" / "essim" / "history";
return fs::path(p) / ".local" / "share" / "essim";
#endif
return {};
}
std::filesystem::path HistoryPath() {
auto d = UserDataDir();
return d.empty() ? std::filesystem::path{} : (d / "history");
}
} // namespace
// Free helpers for "last used (dir, filename) per purpose". Stored as a
// tiny two-line file per key under the user-data dir. Used by the file
// dialog so each call site (export, save, restore, …) gets its own
// memory without further plumbing.
namespace {
std::filesystem::path LastUsedPath(const std::string &key) {
auto d = UserDataDir();
return d.empty() ? std::filesystem::path{} : (d / (key + ".last"));
}
} // namespace
void SaveLastUsed(const std::string &key,
const std::string &dir,
const std::string &filename) {
auto p = LastUsedPath(key);
if (p.empty()) return;
std::error_code ec;
std::filesystem::create_directories(p.parent_path(), ec);
std::ofstream f(p);
if (!f) return;
f << dir << '\n' << filename << '\n';
}
bool LoadLastUsed(const std::string &key,
std::string &dir, std::string &filename) {
auto p = LastUsedPath(key);
if (p.empty()) return false;
std::ifstream f(p);
if (!f) return false;
std::string d, n;
if (std::getline(f, d) && std::getline(f, n) && !d.empty() && !n.empty()) {
dir = d; filename = n;
return true;
}
return false;
}
void Tui::LoadHistory() {
auto p = HistoryPath();
if (p.empty()) return;
@@ -365,6 +427,7 @@ void Tui::DumpCommandsMd(std::ostream &out) const {
bool printed_title = false;
for (const auto &kv : commands) {
const CommandSpec &spec = kv.second;
if (spec.hidden) continue; // aliases & internal entries
if (spec.interactive != want_interactive) continue;
if (!printed_title) {
out << "\n## " << title << "\n\n";

View File

@@ -48,12 +48,18 @@ void Tui::Run() {
dashboard_screen, analyze_screen, help_screen},
&screen_idx);
// Palette is a global Modal — overlays the tab on every screen.
// Palette + export are global Modals — overlay the tab on every
// screen. Stack the decorators (last one wraps innermost).
auto with_palette = tab | Modal(BuildPaletteModal(), &palette_open);
auto with_dialog = with_palette
| Modal(BuildFileDialog(), &file_dialog.open);
auto with_error = with_dialog
| Modal(BuildErrorModal(), &error_open);
auto root = CatchEvent(with_palette, [this](Event e) {
// Modals (palette + sigtype popup) own their events while open.
if (palette_open || sigtype_dialog_open) return false;
auto root = CatchEvent(with_error, [this](Event e) {
// Modals own their events while open. Error modal has priority.
if (error_open || palette_open || sigtype_dialog_open
|| file_dialog.open) return false;
// Ctrl-P opens the palette from any screen.
if (e == Event::CtrlP) { OpenPalette(); return true; }
@@ -92,12 +98,15 @@ void Tui::Run() {
if (e == Event::Character("e")) { Dispatch("explore"); return true; }
if (e == Event::Character("a")) { screen_idx = 5; return true; }
if (e == Event::Character("h")) { screen_idx = 6; return true; }
if (e == Event::Character("x")) { Dispatch("export"); return true; }
return false;
case 3: // explore
if (e == Event::Escape) { screen_idx = 4; return true; }
if (e == Event::Tab) { explore_focus_idx = (explore_focus_idx + 1) % 6; return true; }
if (e == Event::TabReverse) { explore_focus_idx = (explore_focus_idx + 5) % 6; return true; }
// 7 focusable fields: module, type, child-filter, children,
// detail-filter, detail-pins (top), detail-nets (bottom).
if (e == Event::Tab) { explore_focus_idx = (explore_focus_idx + 1) % 7; return true; }
if (e == Event::TabReverse) { explore_focus_idx = (explore_focus_idx + 6) % 7; return true; }
return false;
case 2: // set-connector-type

View File

@@ -84,8 +84,13 @@ 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)
std::vector<std::string> explore_detail_sig; ///< parallel: `module\tsignal` per row (empty = no action)
int explore_detail_idx;
// Second detail sub-panel — populated only when the children pane is on
// the signals tab (top = local pins, bottom = net members).
std::vector<std::string> explore_detail2;
std::vector<std::string> explore_detail2_sig;
int explore_detail2_idx = 0;
std::string explore_header;
int explore_focus_idx;
@@ -103,6 +108,45 @@ class Tui {
// ---- Dashboard scroll state (0 = top; grows as the user scrolls down) ----
int dashboard_scroll_offset = 0;
// ---- Generic error-notification dialog (modal) ----
bool error_open = false;
std::string error_message;
// ---- Generic file-picker dialog (modal) ----
// Reused for any "pick a path" interaction (export, save, restore, …).
// `persist_key` ties the dir + filename to a key persisted under the
// user-data directory so the next invocation under the same key
// re-opens on the same location. `on_confirm` runs the actual action
// when the user accepts a path.
//
// Optional `filters`: a horizontal selector (Toggle) at the top of
// the dialog with (label, extension) pairs. Picking a filter rewrites
// the filename's extension to match. The action then dispatches on
// extension (the dialog stays format-agnostic).
public:
struct FilenameFilter { std::string label; std::string extension; };
private:
struct FileDialogState {
bool open = false;
std::string title;
std::string persist_key;
std::string dir;
std::string filename;
std::vector<std::string> entries; ///< rebuilt every frame
std::vector<bool> entries_is_dir;
int entry_idx = 0;
// Focus map (variable: 0=filters [if any], else +1 each next slot):
// filters present → 0=filters 1=entries 2=filename 3=button (4 slots)
// no filters → 0=entries 1=filename 2=button (3 slots)
int focus_idx = 0;
std::string status;
std::vector<FilenameFilter> filters;
std::vector<std::string> filter_labels; ///< parallel to `filters`
int filter_idx = 0;
std::function<void(const std::string &)> on_confirm;
};
FileDialogState file_dialog;
// ---- Help screen state ----
int help_topic_idx = 0;
std::vector<std::string> help_topic_names; ///< populated by BuildHelpScreen
@@ -149,6 +193,11 @@ public:
void Run();
void DumpCommandsMd(std::ostream &out) const;
// Boot-time hook: dispatch a single command exactly as if the user
// typed it (e.g. `restore foo.essim` or `source bring-up.essim`).
// Call before `Run()` to seed the system before the event loop starts.
void BootDispatch(const std::string &raw);
private:
// Lifecycle (commands.cpp)
void RegisterCommands();
@@ -197,6 +246,22 @@ private:
ftxui::Component BuildDashboardScreen();
ftxui::Component BuildAnalyzeScreen();
ftxui::Component BuildHelpScreen();
ftxui::Component BuildFileDialog();
ftxui::Component BuildErrorModal();
// Pop a centred modal with `msg` and an OK button. Esc / Enter close
// it. Use for actionable failures the user must see (write errors,
// bad inputs, etc.) — for normal feedback keep `Print()`.
void ShowError(const std::string &msg);
// Open the picker modal. `persist_key` controls where the last-used
// dir + filename are stored (one tiny file per key under the user
// data directory). `on_confirm` runs when the user presses Enter on
// the action button — it receives the absolute path the user picked.
void OpenFileDialog(std::string title,
std::string persist_key,
std::string default_filename,
std::vector<FilenameFilter> filters,
std::function<void(const std::string &)> on_confirm);
void ConfirmFileDialog();
ftxui::Component BuildSignalTypeModal();
ftxui::Component BuildPaletteModal();
// Open palette (resets query/index, builds initial list).

View File

@@ -21,11 +21,18 @@ Element RenderHelpPanel(const std::string &title,
// borderRounded gives the panel a distinct visual boundary, so the
// user can find it without ambiguity even when the main content is
// dense (e.g. the analyze screen).
//
// Width: target ~32 cols but allow shrinking down to ~20 on narrow
// terminals. Without the LESS_THAN/GREATER_THAN bounds, a strict
// EQUAL would cause the panel to be clipped to nothing when the
// viewport is narrower than 32 + main-content-min.
return vbox({
text(" " + title + " ") | bold | center,
separator(),
vbox(std::move(rows)),
}) | borderRounded | size(WIDTH, EQUAL, 32);
}) | borderRounded
| size(WIDTH, LESS_THAN, 36)
| size(WIDTH, GREATER_THAN, 20);
}