From 7d307dad57ffbd4f4ea3d008248c1e0e3cbc9f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Sat, 16 May 2026 12:03:39 +0200 Subject: [PATCH] Export command (CSV + ODS), file dialog, error modal, path persistence. New user-facing features: - `export connections ` 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 --- DESIGN.md | 26 +++- doc/user/analysis.md | 18 +++ doc/user/commands.md | 88 +++++------ src/imports/ods_writer.cpp | 148 ++++++++++++++++++ src/imports/ods_writer.hpp | 53 +++++++ src/main.cpp | 43 +++++- src/tui/commands.cpp | 176 +++++++++++++++++++++ src/tui/screen_analyze.cpp | 8 +- src/tui/screen_dashboard.cpp | 1 + src/tui/screen_error.cpp | 35 +++++ src/tui/screen_explore.cpp | 119 ++++++++++----- src/tui/screen_filedialog.cpp | 279 ++++++++++++++++++++++++++++++++++ src/tui/screen_help.cpp | 20 ++- src/tui/screen_settype.cpp | 2 +- src/tui/shell.cpp | 75 ++++++++- src/tui/tui.cpp | 21 ++- src/tui/tui.hpp | 67 +++++++- src/tui/tui_helpers.cpp | 9 +- 18 files changed, 1085 insertions(+), 103 deletions(-) create mode 100644 src/imports/ods_writer.cpp create mode 100644 src/imports/ods_writer.hpp create mode 100644 src/tui/screen_error.cpp create mode 100644 src/tui/screen_filedialog.cpp diff --git a/DESIGN.md b/DESIGN.md index da18171..8479b43 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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() / .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 `` per sheet, each row a `` of string `` 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. diff --git a/doc/user/analysis.md b/doc/user/analysis.md index 4e799ea..cd643fd 100644 --- a/doc/user/analysis.md +++ b/doc/user/analysis.md @@ -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 ` — 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. diff --git a/doc/user/commands.md b/doc/user/commands.md index 346fcf5..5ed42ca 100644 --- a/doc/user/commands.md +++ b/doc/user/commands.md @@ -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 / 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 ` for one command's details) **Arguments** diff --git a/src/imports/ods_writer.cpp b/src/imports/ods_writer.cpp new file mode 100644 index 0000000..91faa3e --- /dev/null +++ b/src/imports/ods_writer.cpp @@ -0,0 +1,148 @@ +#include "ods_writer.hpp" + +#include +#include + +#include +#include + +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 &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 `` line; we want + // it. Re-emit explicitly to control encoding. + return std::string(R"()" "\n") + + oss.str(); +} + +// Minimum META-INF/manifest.xml that satisfies the spec. +std::string build_manifest_xml() { + return R"()" "\n" + R"()" "\n" + R"( )" "\n" + R"( )" "\n" + R"()" "\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; +} diff --git a/src/imports/ods_writer.hpp b/src/imports/ods_writer.hpp new file mode 100644 index 0000000..05623cf --- /dev/null +++ b/src/imports/ods_writer.hpp @@ -0,0 +1,53 @@ +#ifndef _IMPORTS_ODS_WRITER_HPP_ +#define _IMPORTS_ODS_WRITER_HPP_ + +#include +#include + +// 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> cells_; +}; + +class OdsWriter { +public: + OdsSheet *add_sheet(const std::string &name); + bool save(const std::string &path, std::string &error) const; + +private: + std::vector sheets_; +}; + +#endif // _IMPORTS_ODS_WRITER_HPP_ diff --git a/src/main.cpp b/src/main.cpp index 74eebf2..a0afa1c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 : 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; } diff --git a/src/tui/commands.cpp b/src/tui/commands.cpp index 51a69ae..be0c095 100644 --- a/src/tui/commands.cpp +++ b/src/tui/commands.cpp @@ -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 &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 (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 &args) { diff --git a/src/tui/screen_analyze.cpp b/src/tui/screen_analyze.cpp index 61d02bc..baf0b86 100644 --- a/src/tui/screen_analyze.cpp +++ b/src/tui/screen_analyze.cpp @@ -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({ diff --git a/src/tui/screen_dashboard.cpp b/src/tui/screen_dashboard.cpp index 88f0db8..a6217b8 100644 --- a/src/tui/screen_dashboard.cpp +++ b/src/tui/screen_dashboard.cpp @@ -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"}, diff --git a/src/tui/screen_error.cpp b/src/tui/screen_error.cpp new file mode 100644 index 0000000..2774d49 --- /dev/null +++ b/src/tui/screen_error.cpp @@ -0,0 +1,35 @@ +#include "tui/tui.hpp" + +#include +#include +#include + +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); + }); +} diff --git a/src/tui/screen_explore.cpp b/src/tui/screen_explore.cpp index caf0e0a..1552ac5 100644 --- a/src/tui/screen_explore.cpp +++ b/src/tui/screen_explore.cpp @@ -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 &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 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> 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, diff --git a/src/tui/screen_filedialog.cpp b/src/tui/screen_filedialog.cpp new file mode 100644 index 0000000..cbc92a6 --- /dev/null +++ b/src/tui/screen_filedialog.cpp @@ -0,0 +1,279 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +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 &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 filters, + std::function 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> 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); + }); +} diff --git a/src/tui/screen_help.cpp b/src/tui/screen_help.cpp index 521fc67..4042e24 100644 --- a/src/tui/screen_help.cpp +++ b/src/tui/screen_help.cpp @@ -159,6 +159,24 @@ const std::vector> &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 ` 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; diff --git a/src/tui/screen_settype.cpp b/src/tui/screen_settype.cpp index c7eae03..e2ee30d 100644 --- a/src/tui/screen_settype.cpp +++ b/src/tui/screen_settype.cpp @@ -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), diff --git a/src/tui/shell.cpp b/src/tui/shell.cpp index 40203ee..912aedf 100644 --- a/src/tui/shell.cpp +++ b/src/tui/shell.cpp @@ -16,6 +16,21 @@ #include #include +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"; diff --git a/src/tui/tui.cpp b/src/tui/tui.cpp index a981de2..4b81fdd 100644 --- a/src/tui/tui.cpp +++ b/src/tui/tui.cpp @@ -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 diff --git a/src/tui/tui.hpp b/src/tui/tui.hpp index e59a71d..411158e 100644 --- a/src/tui/tui.hpp +++ b/src/tui/tui.hpp @@ -84,8 +84,13 @@ class Tui { std::string explore_child_filter; std::string explore_detail_filter; std::vector explore_detail; - std::vector explore_detail_sig; ///< parallel: signal name per detail line (empty = no signal) + std::vector 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 explore_detail2; + std::vector 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 entries; ///< rebuilt every frame + std::vector 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 filters; + std::vector filter_labels; ///< parallel to `filters` + int filter_idx = 0; + std::function on_confirm; + }; + FileDialogState file_dialog; + // ---- Help screen state ---- int help_topic_idx = 0; std::vector 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 filters, + std::function on_confirm); + void ConfirmFileDialog(); ftxui::Component BuildSignalTypeModal(); ftxui::Component BuildPaletteModal(); // Open palette (resets query/index, builds initial list). diff --git a/src/tui/tui_helpers.cpp b/src/tui/tui_helpers.cpp index d9cde68..6fcd475 100644 --- a/src/tui/tui_helpers.cpp +++ b/src/tui/tui_helpers.cpp @@ -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); }