diff --git a/DESIGN.md b/DESIGN.md index 8479b43..e45b849 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -49,7 +49,8 @@ src/ tui_helpers.{hpp,cpp} Free helpers: ToLower, NaturalLess, LongestCommonPrefix shell.cpp Print, Submit, Dispatch, Finalize, Tokenize, history persistence completion.cpp CompleteCommand, CompletePath, CompleteInline - commands.cpp RegisterCommands (all built-in commands declared here) + commands.cpp RegisterCommands (orchestrator + lifecycle / shell / topology commands) + commands_export.cpp RegisterExportCommands (export → CSV / ODS, file-dialog hook) screen_main.cpp BuildMainScreen (visualisation area + bottom input) screen_connect.cpp BuildConnectScreen + shared RefreshFilteredPartList helper screen_settype.cpp BuildSettypeScreen @@ -162,6 +163,8 @@ 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. +**Command group factorisation**: `RegisterCommands()` in `commands.cpp` owns most built-ins, but self-contained groups live in their own files (one `RegisterCommands()` member each). Today only `RegisterExportCommands()` in `commands_export.cpp` follows the pattern. Adding a new group is mechanical: declare a new member in `tui.hpp`'s `private:` section, define it in `commands_.cpp`, and call it from the orchestrator. Each group can have file-local helpers (e.g. `commands_export.cpp` has its own anonymous-namespace `csv_quote` and `pin_side`). + **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. diff --git a/src/tui/commands.cpp b/src/tui/commands.cpp index be0c095..f112823 100644 --- a/src/tui/commands.cpp +++ b/src/tui/commands.cpp @@ -1,7 +1,6 @@ #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" @@ -206,180 +205,6 @@ 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}}, @@ -843,4 +668,8 @@ void Tui::RegisterCommands() { /*prompt_for_missing=*/ true, "clone a module under a new name (parts, pins, signals; no connections)", }; + + // Per-group registrators living in their own files. Keeps each + // self-contained concern out of this orchestrator. + RegisterExportCommands(); } diff --git a/src/tui/commands_export.cpp b/src/tui/commands_export.cpp new file mode 100644 index 0000000..2fe7f20 --- /dev/null +++ b/src/tui/commands_export.cpp @@ -0,0 +1,191 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include "imports/ods_writer.hpp" +#include "system/connect.hpp" +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/pins.hpp" +#include "system/signal_type.hpp" +#include "system/signals.hpp" +#include "system/system.hpp" + +#include +#include +#include + +namespace { + +// Minimal CSV quoter — wraps in `"…"` and doubles internal quotes when +// the field contains a comma, quote, or newline. Local to this file. +std::string csv_quote(const std::string &s) { + 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; +} + +// Flatten one pin into the 6 string slots the export row uses. +void pin_side(Pin *p, std::string &mod, std::string &part, + std::string &pin, std::string &sig, + std::string &type, std::string &suspect) { + if (!p) { mod = part = pin = sig = type = suspect = ""; return; } + mod = (p->prnt && p->prnt->prnt) ? p->prnt->prnt->name : ""; + part = p->prnt ? p->prnt->name : ""; + pin = p->name; + Signal *s = p->signal(); + if (!s) { + sig = ""; type = "(NC)"; suspect = ""; + } else { + sig = s->name; + type = signal_type_name(s->type); + suspect = (infer_signal_type(s->name) == SignalType::Power + && s->type == SignalType::Other) ? "yes" : "no"; + } +} + +} // namespace + +void Tui::RegisterExportCommands() { + 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. Filters give a + // one-keystroke CSV/ODS toggle; 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]; + + if (kind == "connections") { + // Accepted extensions: `.csv` (flat file) and `.ods` + // (one sheet per connection). Anything else is an error. + 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: Excel rejects /\?*:[] characters + // (LibreOffice tolerates them but stays portable). + 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; + pin_side(wp.first, lm, lp, ln, ls, lt, lsus); + pin_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; + pin_side(wp.first, lm, lp, ln, ls, lt, lsus); + pin_side(wp.second, rm, rp, rn, rs, rt, rsus); + std::string mixed = "no"; + if (lt != "(NC)" && rt != "(NC)" && lt != rt) mixed = "yes"; + f << csv_quote(c->name) << ',' << csv_quote(c->transform_name) << ',' + << csv_quote(lm) << ',' << csv_quote(lp) << ',' << csv_quote(ln) << ',' + << csv_quote(ls) << ',' << csv_quote(lt) << ',' << csv_quote(lsus) << ',' + << csv_quote(rm) << ',' << csv_quote(rp) << ',' << csv_quote(rn) << ',' + << csv_quote(rs) << ',' << csv_quote(rt) << ',' << csv_quote(rsus) << ',' + << 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 / ODS (kinds: connections; " + "bare form opens the file-picker dialog)", + /*scriptable=*/ true, + /*interactive=*/ true, + }; +} diff --git a/src/tui/screen_sigtype_modal.cpp b/src/tui/screen_sigtype_modal.cpp index 7118b65..3319532 100644 --- a/src/tui/screen_sigtype_modal.cpp +++ b/src/tui/screen_sigtype_modal.cpp @@ -1,12 +1,71 @@ #include "tui/tui.hpp" +#include "system/modules.hpp" +#include "system/signals.hpp" +#include "system/system.hpp" + #include #include #include #include +#include +#include + using namespace ftxui; +// Lifecycle of the signal-type popup. The Open* and Apply* methods used +// to live in shell.cpp; moved here so the modal owns all of its logic. + +void Tui::OpenSignalTypeDialog(const std::string &mod_name, + const std::string &sig_name) { + if (!sys) return; + Signal *sig = nullptr; + try { + Module *m = sys->modules()->get(mod_name); + sig = m->signals->get(sig_name); + } catch (const std::exception &) { return; } + + sigtype_dialog_mod = mod_name; + sigtype_dialog_sig = sig_name; + switch (sig->type) { + case SignalType::Power: sigtype_dialog_choice = 0; break; + case SignalType::GndShield: sigtype_dialog_choice = 1; break; + default: sigtype_dialog_choice = 2; break; + } + sigtype_dialog_open = true; +} + +void Tui::ApplySignalTypeChoice() { + sigtype_dialog_open = false; + if (!sys) return; + SignalType t; + switch (sigtype_dialog_choice) { + case 0: t = SignalType::Power; break; + case 1: t = SignalType::GndShield; break; + default: t = SignalType::Other; break; + } + Signal *sig = nullptr; + try { + Module *m = sys->modules()->get(sigtype_dialog_mod); + sig = m->signals->get(sigtype_dialog_sig); + } catch (const std::exception &) { return; } + if (sig->type == t) return; // no-op, no record + sig->type = t; + if (in_source) return; + + // Dedup: if the immediately previous recorded line targets the same + // signal, replace it so a sequence of toggles collapses to one line. + std::string line = "set-signal-type " + sigtype_dialog_mod + " " + + sigtype_dialog_sig + " " + signal_type_name(t); + std::string prefix = "set-signal-type " + sigtype_dialog_mod + " " + + sigtype_dialog_sig + " "; + if (!recorded.empty() && recorded.back().rfind(prefix, 0) == 0) + recorded.back() = std::move(line); + else + recorded.push_back(std::move(line)); +} + Component Tui::BuildSignalTypeModal() { sigtype_dialog_entries = {"power", "gnd", "other"}; diff --git a/src/tui/shell.cpp b/src/tui/shell.cpp index 912aedf..6c52002 100644 --- a/src/tui/shell.cpp +++ b/src/tui/shell.cpp @@ -31,55 +31,6 @@ void Tui::BootDispatch(const std::string &raw) { screen_idx = 4; } -void Tui::OpenSignalTypeDialog(const std::string &mod_name, - const std::string &sig_name) { - if (!sys) return; - Signal *sig = nullptr; - try { - Module *m = sys->modules()->get(mod_name); - sig = m->signals->get(sig_name); - } catch (const std::exception &) { return; } - - sigtype_dialog_mod = mod_name; - sigtype_dialog_sig = sig_name; - switch (sig->type) { - case SignalType::Power: sigtype_dialog_choice = 0; break; - case SignalType::GndShield: sigtype_dialog_choice = 1; break; - default: sigtype_dialog_choice = 2; break; - } - sigtype_dialog_open = true; -} - -void Tui::ApplySignalTypeChoice() { - sigtype_dialog_open = false; - if (!sys) return; - SignalType t; - switch (sigtype_dialog_choice) { - case 0: t = SignalType::Power; break; - case 1: t = SignalType::GndShield; break; - default: t = SignalType::Other; break; - } - Signal *sig = nullptr; - try { - Module *m = sys->modules()->get(sigtype_dialog_mod); - sig = m->signals->get(sigtype_dialog_sig); - } catch (const std::exception &) { return; } - if (sig->type == t) return; // no-op, no record - sig->type = t; - if (in_source) return; - - // Dedup: if the immediately previous recorded line targets the same - // signal, replace it so a sequence of toggles collapses to one line. - std::string line = "set-signal-type " + sigtype_dialog_mod + " " - + sigtype_dialog_sig + " " + signal_type_name(t); - std::string prefix = "set-signal-type " + sigtype_dialog_mod + " " - + sigtype_dialog_sig + " "; - if (!recorded.empty() && recorded.back().rfind(prefix, 0) == 0) - recorded.back() = std::move(line); - else - recorded.push_back(std::move(line)); -} - void Tui::Print(const std::string &line) { output.push_back(line); scroll_offset = 0; // any new line snaps the view back to the tail diff --git a/src/tui/tui.hpp b/src/tui/tui.hpp index 411158e..71e9718 100644 --- a/src/tui/tui.hpp +++ b/src/tui/tui.hpp @@ -201,6 +201,9 @@ public: private: // Lifecycle (commands.cpp) void RegisterCommands(); + // Per-file command-group registrators. Each adds entries to the + // `commands` map. Called from RegisterCommands(). + void RegisterExportCommands(); // commands_export.cpp // Shell (shell.cpp) void Print(const std::string &line);