diff --git a/CLAUDE.md b/CLAUDE.md index ab5c744..b82e73b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ cmake --build build -j - CMake **3.14+** required (uses `FetchContent_MakeAvailable`). - FTXUI is fetched at configure time from GitHub (`v6.1.9`, shallow clone). First configure pays ~20 s for the clone; subsequent ones are cached in `build/_deps/`. +- **System dependencies** (resolved via `find_package`): `libzip` (target `libzip::zip`) and `pugixml` (target `pugixml::pugixml`). Used by the ODS importer. Available on Arch via `pacman -S libzip pugixml`. - Sources are collected with `file(GLOB_RECURSE ALL_SOURCES "src/*.cpp")`. **After adding a new `.cpp`, re-run `cmake -S . -B build`** — CMake does not re-glob automatically and link will fail with "undefined reference". ## Layout @@ -31,8 +32,17 @@ src/ imports/ -- adapters that populate the domain import_base.hpp ImportBase interface import_mentor.{hpp,cpp} Mentor Graphics netlist parser (done) - tui/ -- FTXUI shell - tui.{hpp,cpp} Tui: visualisation area on top, input + history at the bottom + 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 + shell.cpp Print, Submit, Dispatch, Finalize, Tokenize, history persistence + completion.cpp CompleteCommand, CompletePath, CompleteInline + commands.cpp RegisterCommands (all built-in commands declared here) + screen_main.cpp BuildMainScreen (visualisation area + bottom input) + screen_search.cpp BuildSearchScreen + screen_connect.cpp BuildConnectScreen + shared RefreshFilteredPartList helper + screen_settype.cpp BuildSettypeScreen + screen_explore.cpp BuildExploreScreen (browse modules → parts/signals/connections → details; not scriptable) doc/classes.puml -- PlantUML class diagram ``` @@ -54,12 +64,36 @@ doc/classes.puml -- PlantUML class diagram - `CatchEvent` is wrapped **outside** the `Renderer` (not between `Renderer` and `Input`). Pattern: `Renderer(input_component, lambda)` then `CatchEvent(renderer, handler)`. Wrapping `CatchEvent(input, …)` inside a `Container::Vertical({…})` and then `Renderer(container, …)` was found to break the `Input`'s content rendering on at least one terminal — typed characters didn't show even though `on_enter` fired correctly. - `InputOption::transform` is overridden to avoid hard-coded colors (default sets `White on Black` when focused, which is unreadable on light terminal themes). The custom transform only applies `dim` to the placeholder; everything else inherits terminal default fg/bg, so the UI adapts to any theme. - `InputOption::multiline` **must** be set to `false` for the command prompt. Default is `true`, and FTXUI's `HandleReturn()` then both inserts `\n` into the content **and** fires `on_enter` — so `Submit()` would receive `"help\n"` instead of `"help"` and command lookups would all fall through to "unknown command". -- Commands live in a `std::map` registry built in `RegisterCommands()`. Each `CommandSpec` declares a list of `Param{name, path_completion}` and an `action(args)` lambda. `Dispatch(raw)` tokenises the input (whitespace split with `"…"` quoting), takes inline args first, and pushes a `Prompt` onto the queue for each missing param. The last prompt's callback calls `Finalize()`. +- Commands live in a `std::map` registry built in `RegisterCommands()`. Each `CommandSpec` carries `params` (list of `Param{name, completion}` where `completion` is the `Tui::Completion` enum: `None | Path | Command`), an `action(args)` lambda, a `prompt_for_missing` bool (default `true`), a one-line `description` string, and a `scriptable` bool (default `true`). Setting `scriptable = false` (e.g. `explore`) excludes the command from the script-save buffer; if a non-scriptable command is invoked from a sourced file the `Source` loop still aborts via the `screen_idx != 0` check, since these commands are typically screen-openers. `Dispatch(raw)` tokenises the input (whitespace split with `"…"` quoting), takes inline args first, and (only when `prompt_for_missing`) pushes a `Prompt` onto the queue for each missing param. The last prompt's callback calls `Finalize()`. Set `prompt_for_missing = false` for commands that accept either zero args or a full arg list (e.g. `search`: bare → interactive, full → inline) — the action gets called immediately with whatever it received and decides what to do. +- `Tab` completes by `Param::completion`: `Path` triggers `CompletePath()` (filesystem listing with `~/` expansion), `Command` triggers `CompleteCommand()` (matches against the registry), `None` does nothing. Both work inline (`CompleteInline()` figures out the arg position) and inside a multi-step prompt. +- `help` (no args) iterates the registry and prints `name — description` for every command. `help ` describes a single command, including each `Param`'s name. The argument has `Completion::Command`, so `help ` lists registered commands. - `Finalize()` rebuilds the **canonical inline form** (`name arg1 "arg with spaces" arg3`) and writes that to history — so a `load` command answered via interactive prompts still shows up in `history` (and on disk) as a single inline line, ready to be replayed via ↑. - Multi-step prompts work via a `std::deque` queue. `Submit()` pops them one by one before falling back to dispatch. Adding a new command = one entry in `RegisterCommands()`; the prompt-flow and inline-flow are both handled automatically. - Tab completion: at the top-level prompt (no `pending`), completes built-in command names. Inside a prompt with `path_completion = true` (e.g. the `filename` step of `load`), completes file paths via `std::filesystem::directory_iterator` (handles `~/`, dirs get a trailing `/`). Logic: 1 match → replace; multiple with progress on the longest common prefix → extend; multiple stuck at LCP → list candidates in the visualisation area. -Built-in commands: `new`, `load`, `search`, `clear`, `help`, `quit`/`exit`. `Esc` cancels an in-progress multi-step prompt. +Built-in commands: `new`, `load`, `save`, `restore`, `source`, `script-save`, `connect`, `set-type`, `search`, `explore`, `clear`, `help`, `quit`/`exit`. `Esc` cancels an in-progress multi-step prompt. + +`script-save ` writes a replay-ready script of every command issued since the last `new` (the `recorded` buffer is cleared inside the `new` action). The buffer is appended to inside `Finalize()` after the action runs, with a denylist of commands that aren't useful in a replay: `clear`, `help`, `quit`, `exit`, `source`, `script-save`. Note the `source` exclusion: when a script is sourced, the *individual lines inside* the script are recorded (because each goes through `Finalize`), so the saved script reproduces the same end state without the indirection. + +`source ` reads a script line by line and feeds each line through `Submit()`. While the script is running, `in_source = true` is set on the `Tui` and: +- `Dispatch` / `Finalize` skip writing to memory + on-disk history. +- After each `Submit`, if `screen_idx != 0` (a screen was opened by an "interactive" command like bare `connect`/`search`/`set-type`), the script is aborted with an error message and `screen_idx` is reset to 0 — interactive screen-opening commands are explicitly disallowed in scripts. + +Pending prompts (from incomplete inline commands) are NOT considered interactive and are filled by subsequent script lines, the way you'd expect. Lines starting with `#` and blank lines are skipped; leading/trailing whitespace is trimmed; `~/` is expanded. + +`save` / `restore` (`src/system/persist.{hpp,cpp}`): tab-delimited line format, no extra deps. Tags: `M` (module), `P` (part with `connector_type`), `N` (pin → signal name; empty = NC), `C` (connection header with endpoints + `transform_name`), `W` (wire pair within the current connection). `restore` replaces `Tui::sys` entirely (`unique_ptr::reset`). Names are stored as-is — must not contain TAB or newline (true for the EE netlists we ingest). Format is versioned by the `# essim system snapshot v1` header for future compatibility. + +**Connector types & transforms**: every `Part` carries a `connector_type` string (default `""`, set via the `set-type` command — inline `set-type m p kind` or bare which opens a TUI screen with module menu, part filter+menu, type input, list of types already in use, and an Apply button). When `connect` validates a pair, it consults `TransformRegistry::lookup(p1->connector_type, p2->connector_type)` (defined in `src/system/transform.{hpp,cpp}`) — both directions of the pair are tried. If neither is registered, an `IdentityTransform` fallback wires each pin of A to the same-name pin of B (when present). The resulting `(Pin*, Pin*)` list and the transform's name are stored on the `Connection` (`pin_map`, `transform_name`). To register a real transform: define a `Transform` subclass in `transform.cpp` and call `TransformRegistry::get().add("kindA", "kindB", new MyTransform())` at init — there's no startup hook for this yet, so a small `RegisterBuiltinTransforms()` helper is the natural place to add when more types appear. + +`screen_idx` mapping: 0 = main TUI, 1 = search, 2 = connect, 3 = set-type. Adding a new screen mode is the same recipe each time: state members, `Container::Vertical` of focusable components, a `Renderer` lambda that recomputes derived state per frame (e.g. filtered part lists), an entry in `Container::Tab`, and Tab/Esc handling in the outer `CatchEvent`. + +`connect` is dual-mode (`prompt_for_missing = false`): +- Inline: `connect m1 p1 m2 p2`. Each part argument is resolved as exact name first, then case-insensitive substring; ambiguous → lists candidates and aborts. +- Bare: opens a dedicated full-screen layout (`screen_idx = 2`) with two columns (`endpoint 1` / `endpoint 2`). Each column has a module `Menu`, a part filter `Input`, and a filtered part `Menu`. A `Button(" Connect ")` at the bottom commits. `Tab` cycles focus across the 7 components, `Esc` leaves. + +The connect screen rebuilds the filtered part lists inside the `Renderer` lambda each frame (cheap; lets the part menus react live to module changes and filter typing). Selection indices are clamped if a list shrinks below the previous index. + +The Connection record stores `Module*`/`Part*` for both endpoints (added to `Connection` for this — minimal struct fields, no behaviour change). `search` switches to a second full-screen layout (handled by `Container::Tab({main, search}, &screen_idx)`). Layout: - Left column: `Menu` for the module list and `Menu` for the type (`parts` / `signals`). @@ -67,7 +101,7 @@ Built-in commands: `new`, `load`, `search`, `clear`, `help`, `quit`/`exit`. `Esc - `Tab` cycles focus between the query input and the menus. **Implemented manually** in the outer `CatchEvent`: `Menu::OnEvent` consumes `Event::Tab` to cycle its own entries and returns `true`, which prevents `Container::Vertical` from ever seeing the event (Container only cycles between children when the active child returns `false`). So we short-circuit Tab/TabReverse upstream and mutate `search_focus_idx` directly. - `Esc` exits the search mode (flips `screen_idx` back to 0). The search state (selected module/type, query) is preserved across re-entries until `search` is run again. -Adding a new screen mode = add a child to `Container::Tab` and a `screen_idx` value; key handling already lives in the outer `CatchEvent`. +Adding a new screen mode = drop a `screen_.cpp` defining `Component Tui::BuildXxxScreen()`, add corresponding state members in `tui.hpp`, register a `BuildXxxScreen` declaration in the screen-builders block, then in `tui.cpp::Run()` add a child to `Container::Tab` and a case in the screen-mode `switch` of the outer `CatchEvent` (Tab cycling + Esc-leave). Commands lived in `commands.cpp` set `screen_idx` to enter the new mode. Command history is persisted on disk and loaded on startup. Path resolution is platform-aware: - Linux/macOS: `$XDG_DATA_HOME/essim/history`, falling back to `~/.local/share/essim/history`. @@ -77,7 +111,8 @@ Each successful submission appends a single line to the file (so a crash doesn't ## Gotchas -- `System::Load` for `IMPORT_ALTIUM` / `IMPORT_ODS`: the corresponding constructor lines are commented out, so `imp` stays uninitialised → UB on `imp->parse(...)`. Only `IMPORT_MENTOR` is safe today. Wrap calls in `try/catch` (the TUI does). +- `System::Load` for `IMPORT_ALTIUM`: the corresponding constructor line is still commented out, so `imp` stays uninitialised → UB on `imp->parse(...)`. `IMPORT_MENTOR` and `IMPORT_ODS` are wired. Wrap calls in `try/catch` (the TUI does). +- ODS importer: each spreadsheet sheet becomes a `Part` (sheet name = part name). Rows are pin/signal pairs; the **first non-empty row of each sheet is dropped as a header** (no validation of header content). Empty cells skip the row; `"NC"` keeps the pin in the part but doesn't connect it to a signal. Pins or parts whose name collides (rare in well-formed sheets) are silently dropped. - `System::Load` throws `std::runtime_error("Unknown import type")` for any value outside the three enum cases. - `Modules`/`Parts`/etc. have no const-correct iteration on the `*` accessor; iterators on `SystemElementContainer` are available but `begin()`/`end()` are non-const-safe in some places. diff --git a/CMakeLists.txt b/CMakeLists.txt index a0a4819..e839dba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,9 @@ FetchContent_Declare(ftxui ) FetchContent_MakeAvailable(ftxui) +find_package(libzip REQUIRED) +find_package(pugixml REQUIRED) + file(GLOB_RECURSE ALL_SOURCES "src/*.cpp") add_executable(essim ${ALL_SOURCES}) @@ -34,4 +37,6 @@ target_link_libraries(essim ftxui::screen ftxui::dom ftxui::component + libzip::zip + pugixml::pugixml ) diff --git a/src/imports/import_ods.cpp b/src/imports/import_ods.cpp new file mode 100644 index 0000000..f291036 --- /dev/null +++ b/src/imports/import_ods.cpp @@ -0,0 +1,121 @@ +#include "import_ods.hpp" + +#include "system/parts.hpp" +#include "system/pins.hpp" +#include "system/signals.hpp" + +#include +#include + +#include +#include +#include + +ImportOds::ImportOds(std::string filename) + : ImportBase(filename), filename_(std::move(filename)) {} + +static std::string read_content_xml(const std::string &filename) +{ + int err = 0; + zip_t *zip = zip_open(filename.c_str(), ZIP_RDONLY, &err); + if (!zip) throw std::runtime_error("ods: cannot open " + filename); + + zip_stat_t st; + if (zip_stat(zip, "content.xml", 0, &st) != 0) { + zip_close(zip); + throw std::runtime_error("ods: missing content.xml in " + filename); + } + + zip_file_t *f = zip_fopen(zip, "content.xml", 0); + if (!f) { + zip_close(zip); + throw std::runtime_error("ods: cannot read content.xml"); + } + + std::string out; + out.resize(static_cast(st.size)); + zip_int64_t n = zip_fread(f, out.data(), st.size); + zip_fclose(f); + zip_close(zip); + if (n < 0) throw std::runtime_error("ods: read failed"); + out.resize(static_cast(n)); + return out; +} + +static std::string cell_text(pugi::xml_node cell) +{ + std::string out; + for (auto p : cell.children("text:p")) { + if (!out.empty()) out += " "; + for (auto n : p.children()) { + if (n.type() == pugi::node_pcdata) out += n.value(); + } + } + return out; +} + +static std::vector expand_row(pugi::xml_node row) +{ + std::vector cells; + for (auto cell : row.children()) { + std::string n = cell.name(); + if (n != "table:table-cell" && n != "table:covered-table-cell") continue; + int rep = cell.attribute("table:number-columns-repeated").as_int(1); + std::string v = cell_text(cell); + for (int i = 0; i < rep; ++i) cells.push_back(v); + } + while (!cells.empty() && cells.back().empty()) cells.pop_back(); + return cells; +} + +void ImportOds::parse(Signals *signals) +{ + std::string xml = read_content_xml(filename_); + + pugi::xml_document doc; + auto res = doc.load_string(xml.c_str()); + if (!res) { + throw std::runtime_error(std::string("ods: xml parse: ") + res.description()); + } + + auto spreadsheet = doc.child("office:document-content") + .child("office:body") + .child("office:spreadsheet"); + if (!spreadsheet) { + throw std::runtime_error("ods: no spreadsheet found"); + } + + for (auto t : spreadsheet.children("table:table")) { + std::string sheet = t.attribute("table:name").value(); + if (sheet.empty()) continue; + + Part *prt = new Part(sheet); + bool first_data = true; + + for (auto row : t.children("table:table-row")) { + auto cells = expand_row(row); + if (cells.size() < 2) continue; + + // Skip the first non-empty row (header). + if (first_data) { first_data = false; continue; } + + const std::string &pin_name = cells[0]; + const std::string &sig_name = cells[1]; + if (pin_name.empty() || sig_name.empty()) continue; + + Pin *pin = new Pin(pin_name); + try { prt->add(pin); } + catch (const std::exception &) { delete pin; continue; } + + // "NC" = Not Connected: keep the pin in the part, no signal hookup. + if (sig_name == "NC") continue; + + Signal *s = signals->merge(sig_name); + s->add(pin); + pin->connect(s); + } + + try { prts->add(prt); } + catch (const std::exception &) { delete prt; } + } +} diff --git a/src/imports/import_ods.hpp b/src/imports/import_ods.hpp new file mode 100644 index 0000000..e793297 --- /dev/null +++ b/src/imports/import_ods.hpp @@ -0,0 +1,15 @@ +#ifndef _IMPORT_ODS_HPP_ +#define _IMPORT_ODS_HPP_ + +#include "import_base.hpp" + +class ImportOds : public ImportBase +{ + std::string filename_; + +public: + ImportOds(std::string filename); + void parse(Signals *signals) override; +}; + +#endif // _IMPORT_ODS_HPP_ diff --git a/src/system/connect.cpp b/src/system/connect.cpp index d089558..ba095fc 100644 --- a/src/system/connect.cpp +++ b/src/system/connect.cpp @@ -1,7 +1,11 @@ #include "connect.hpp" -Connection::Connection(std::string name) : SystemElement(name) {}; +Connection::Connection(std::string name) + : SystemElement(name), m1(nullptr), p1(nullptr), m2(nullptr), p2(nullptr) {} + +Connection::Connection(std::string name, Module *m1, Part *p1, Module *m2, Part *p2) + : SystemElement(name), m1(m1), p1(p1), m2(m2), p2(p2) {} Connections::Connections(void): SystemElementContainer("connections") {} -Connections::Connections(std::vector conns): SystemElementContainer("connections", conns) {} \ No newline at end of file +Connections::Connections(std::vector conns): SystemElementContainer("connections", conns) {} diff --git a/src/system/connect.hpp b/src/system/connect.hpp index 5b36e46..497fcfb 100644 --- a/src/system/connect.hpp +++ b/src/system/connect.hpp @@ -3,10 +3,25 @@ #include "syselmts.hpp" +#include +#include + +class Module; +class Part; +class Pin; + class Connection : public SystemElement { public: + Module *m1; + Part *p1; + Module *m2; + Part *p2; + std::string transform_name; + std::vector> pin_map; + Connection(std::string name); + Connection(std::string name, Module *m1, Part *p1, Module *m2, Part *p2); }; class Connections : public SystemElementContainer @@ -16,4 +31,4 @@ public: Connections(std::vector connections); }; -#endif // _CONNECT_HPP_ \ No newline at end of file +#endif // _CONNECT_HPP_ diff --git a/src/system/modules.cpp b/src/system/modules.cpp index f2a3390..fc2788e 100644 --- a/src/system/modules.cpp +++ b/src/system/modules.cpp @@ -13,6 +13,11 @@ Module::~Module() { delete signals; } +void Module::add(Part *part) { + SystemElementContainer::add(part); + part->prnt = this; +} + Modules::Modules(void): SystemElementContainer("modules") {} Modules::Modules(std::vector modules): SystemElementContainer("modules", modules) {} diff --git a/src/system/modules.hpp b/src/system/modules.hpp index 4687a2f..4910b26 100644 --- a/src/system/modules.hpp +++ b/src/system/modules.hpp @@ -16,6 +16,8 @@ public: Modules *prnt; Module(std::string name); ~Module(); + using SystemElementContainer::add; + void add(Part *part) override; }; class Modules : public SystemElementContainer @@ -27,4 +29,5 @@ public: ~Modules(); }; + #endif // _MODULES_HPP_ \ No newline at end of file diff --git a/src/system/parts.cpp b/src/system/parts.cpp index 41ac50f..dad7c51 100644 --- a/src/system/parts.cpp +++ b/src/system/parts.cpp @@ -1,6 +1,6 @@ #include "parts.hpp" -Part::Part(std::string name) : SystemElementContainer(name), prnt(nullptr) {}; +Part::Part(std::string name) : SystemElementContainer(name), prnt(nullptr), connector_type() {}; void Part::add(Pin *pin) { diff --git a/src/system/parts.hpp b/src/system/parts.hpp index ba3deae..5ece419 100644 --- a/src/system/parts.hpp +++ b/src/system/parts.hpp @@ -13,6 +13,7 @@ public: Part(std::string name); ~Part(); Module *prnt; ///< Pointer to the parent module. + std::string connector_type; ///< Tag used by the transform registry; empty = untyped. void add(Pin *pin) override; }; diff --git a/src/system/persist.cpp b/src/system/persist.cpp new file mode 100644 index 0000000..3c82b2b --- /dev/null +++ b/src/system/persist.cpp @@ -0,0 +1,153 @@ +#include "persist.hpp" + +#include "connect.hpp" +#include "modules.hpp" +#include "parts.hpp" +#include "pins.hpp" +#include "signals.hpp" +#include "system.hpp" + +#include +#include +#include +#include +#include + +namespace { + +// Tab-delimited tokeniser. Empty trailing fields are preserved. +std::vector split_tab(const std::string &line) { + std::vector out; + std::string cur; + for (char c : line) { + if (c == '\t') { out.push_back(std::move(cur)); cur.clear(); } + else cur.push_back(c); + } + out.push_back(std::move(cur)); + return out; +} + +} // namespace + +bool save_system(const System *sys, const std::string &filename, std::string &error) +{ + if (!sys) { error = "no system to save"; return false; } + std::ofstream f(filename); + if (!f) { error = "cannot open " + filename + " for writing"; return false; } + + f << "# essim system snapshot v1\n"; + + for (auto &mkv : *sys->modules()) { + Module *mod = mkv.second; + f << "M\t" << mod->name << "\n"; + for (auto &pkv : *mod) { + Part *p = pkv.second; + f << "P\t" << p->name << "\t" << p->connector_type << "\n"; + for (auto &nkv : *p) { + Pin *pin = nkv.second; + Signal *s = pin->signal(); + f << "N\t" << pin->name << "\t" << (s ? s->name : "") << "\n"; + } + } + } + + for (auto &ckv : *sys->connections()) { + Connection *c = ckv.second; + f << "C\t" << c->name + << "\t" << (c->m1 ? c->m1->name : "") + << "\t" << (c->p1 ? c->p1->name : "") + << "\t" << (c->m2 ? c->m2->name : "") + << "\t" << (c->p2 ? c->p2->name : "") + << "\t" << c->transform_name << "\n"; + for (auto &wp : c->pin_map) { + Pin *a = wp.first; + Pin *b = wp.second; + f << "W" + << "\t" << (a && a->prnt && a->prnt->prnt ? a->prnt->prnt->name : "") + << "\t" << (a && a->prnt ? a->prnt->name : "") + << "\t" << (a ? a->name : "") + << "\t" << (b && b->prnt && b->prnt->prnt ? b->prnt->prnt->name : "") + << "\t" << (b && b->prnt ? b->prnt->name : "") + << "\t" << (b ? b->name : "") + << "\n"; + } + } + return f.good(); +} + +System *restore_system(const std::string &filename, std::string &error) +{ + std::ifstream f(filename); + if (!f) { error = "cannot open " + filename; return nullptr; } + + System *sys = new System(); + Module *cur_mod = nullptr; + Part *cur_part = nullptr; + Connection *cur_conn = nullptr; + + std::string line; + int lineno = 0; + + auto fail = [&](const std::string &msg) { + error = "line " + std::to_string(lineno) + ": " + msg; + delete sys; + return nullptr; + }; + + while (std::getline(f, line)) { + ++lineno; + if (line.empty() || line[0] == '#') continue; + auto fs = split_tab(line); + if (fs.empty()) continue; + const std::string &tag = fs[0]; + + try { + if (tag == "M") { + if (fs.size() < 2) return fail("M needs "); + cur_mod = sys->modules()->merge(fs[1]); + cur_part = nullptr; + } else if (tag == "P") { + if (!cur_mod) return fail("P outside module"); + if (fs.size() < 2) return fail("P needs "); + cur_part = new Part(fs[1]); + if (fs.size() >= 3) cur_part->connector_type = fs[2]; + cur_mod->add(cur_part); + } else if (tag == "N") { + if (!cur_part || !cur_mod) return fail("N outside part"); + if (fs.size() < 3) return fail("N needs "); + Pin *pin = new Pin(fs[1]); + cur_part->add(pin); + if (!fs[2].empty()) { + Signal *s = cur_mod->signals->merge(fs[2]); + s->add(pin); + pin->connect(s); + } + } else if (tag == "C") { + if (fs.size() < 7) return fail("C needs "); + Module *m1 = sys->modules()->get(fs[2]); + Module *m2 = sys->modules()->get(fs[4]); + Part *p1 = m1->get(fs[3]); + Part *p2 = m2->get(fs[5]); + cur_conn = new Connection(fs[1], m1, p1, m2, p2); + cur_conn->transform_name = fs[6]; + sys->connections()->add(cur_conn); + } else if (tag == "W") { + if (!cur_conn) return fail("W outside connection"); + if (fs.size() < 7) return fail("W needs "); + Module *m1 = sys->modules()->get(fs[1]); + Module *m2 = sys->modules()->get(fs[4]); + Part *p1 = m1->get(fs[2]); + Part *p2 = m2->get(fs[5]); + Pin *pin1 = p1->get(fs[3]); + Pin *pin2 = p2->get(fs[6]); + cur_conn->pin_map.emplace_back(pin1, pin2); + } else { + return fail("unknown tag '" + tag + "'"); + } + } catch (const std::exception &e) { + return fail(std::string(tag) + ": " + e.what()); + } + } + + return sys; +} diff --git a/src/system/persist.hpp b/src/system/persist.hpp new file mode 100644 index 0000000..aecefe9 --- /dev/null +++ b/src/system/persist.hpp @@ -0,0 +1,16 @@ +#ifndef _PERSIST_HPP_ +#define _PERSIST_HPP_ + +#include + +class System; + +// Writes the system to `filename` using a tab-delimited line format. +// Returns true on success; on failure, sets `error` and returns false. +bool save_system(const System *sys, const std::string &filename, std::string &error); + +// Loads a system snapshot from `filename`. On success, returns a freshly allocated +// System (caller takes ownership). On failure, returns nullptr and sets `error`. +System *restore_system(const std::string &filename, std::string &error); + +#endif // _PERSIST_HPP_ diff --git a/src/system/pins.hpp b/src/system/pins.hpp index fe51d76..437b475 100644 --- a/src/system/pins.hpp +++ b/src/system/pins.hpp @@ -16,6 +16,7 @@ public: Pin(std::string name); Part *prnt; ///< Pointer to the parent part. bool connected(); + Signal *signal() const { return sig; } void connect(Signal *signal); }; diff --git a/src/system/system.cpp b/src/system/system.cpp index 242750c..4183ae1 100644 --- a/src/system/system.cpp +++ b/src/system/system.cpp @@ -4,6 +4,7 @@ #include "connect.hpp" #include "modules.hpp" #include "imports/import_mentor.hpp" +#include "imports/import_ods.hpp" System::System() : mods(nullptr), conns(nullptr) { @@ -35,7 +36,7 @@ void System::Load(std::string module_name, std::string file_name, ImportType typ // imp = new ImportAltium(file_name); } else if (type == ImportType::IMPORT_ODS) { - // imp = new ImportOds(file_name); + imp = new ImportOds(file_name); } else { diff --git a/src/system/system.hpp b/src/system/system.hpp index 6846893..6d8f955 100644 --- a/src/system/system.hpp +++ b/src/system/system.hpp @@ -23,6 +23,7 @@ public: System(); void Load(std::string module_name, std::string filename, ImportType type); Modules *modules() const { return mods; } + Connections *connections() const { return conns; } ~System(); }; diff --git a/src/system/transform.cpp b/src/system/transform.cpp index e69de29..e48840a 100644 --- a/src/system/transform.cpp +++ b/src/system/transform.cpp @@ -0,0 +1,90 @@ +#include "transform.hpp" + +#include "parts.hpp" +#include "pins.hpp" +#include "transform_vpx.hpp" + +#include +#include + +#include +#include + +Transform::Transform(std::string name) : name(std::move(name)) {} + +std::string CheckIdentityCompatible(const Part *a, const Part *b) +{ + if (!a || !b) return "missing part"; + std::set a_pins, b_pins; + for (auto &kv : *a) a_pins.insert(kv.first); + for (auto &kv : *b) b_pins.insert(kv.first); + if (a_pins == b_pins) return ""; + + std::vector only_a, only_b; + for (const auto &n : a_pins) if (!b_pins.count(n)) only_a.push_back(n); + for (const auto &n : b_pins) if (!a_pins.count(n)) only_b.push_back(n); + + std::string msg = "identity wiring requires same pin names on both sides"; + if (!only_a.empty()) + msg += "; only on '" + a->name + "': " + + std::to_string(only_a.size()) + " (e.g. " + only_a.front() + ")"; + if (!only_b.empty()) + msg += "; only on '" + b->name + "': " + + std::to_string(only_b.size()) + " (e.g. " + only_b.front() + ")"; + return msg; +} + +IdentityTransform::IdentityTransform() : Transform("identity") {} + +std::vector> IdentityTransform::apply(Part *a, Part *b) const +{ + std::vector> out; + for (auto &kv : *a) { + try { + Pin *pb = b->get(kv.first); + out.emplace_back(kv.second, pb); + } catch (const std::exception &) { + // No same-name pin on the other side — skip. + } + } + return out; +} + +TransformRegistry::TransformRegistry() : identity_(new IdentityTransform()) { + RegisterVpxTransforms(*this); +} + +TransformRegistry::~TransformRegistry() +{ + for (auto &kv : entries) delete kv.second; + delete identity_; +} + +TransformRegistry &TransformRegistry::get() +{ + static TransformRegistry instance; + return instance; +} + +void TransformRegistry::add(const std::string &kA, const std::string &kB, Transform *t) +{ + auto key = std::make_pair(kA, kB); + auto it = entries.find(key); + if (it != entries.end()) { + delete it->second; + it->second = t; + } else { + entries.emplace(key, t); + } +} + +Transform *TransformRegistry::lookup(const std::string &kA, const std::string &kB) const +{ + auto it = entries.find({kA, kB}); + if (it != entries.end()) return it->second; + auto it2 = entries.find({kB, kA}); + if (it2 != entries.end()) return it2->second; + return identity_; +} + +Transform *TransformRegistry::identity() const { return identity_; } diff --git a/src/system/transform.hpp b/src/system/transform.hpp index e69de29..a9a9a28 100644 --- a/src/system/transform.hpp +++ b/src/system/transform.hpp @@ -0,0 +1,56 @@ +#ifndef _TRANSFORM_HPP_ +#define _TRANSFORM_HPP_ + +#include +#include +#include +#include + +class Part; +class Pin; + +// A Transform describes how a connector pair maps pins between two Parts. +// Returning the list of (pin on side A, pin on side B) wired by this connection +// is enough to record the wiring on the Connection. +// Returns "" if a and b have identical pin name sets (so the identity +// fallback would wire every pin), otherwise a description of the mismatch. +std::string CheckIdentityCompatible(const Part *a, const Part *b); + +class Transform +{ +public: + std::string name; + explicit Transform(std::string name); + virtual ~Transform() = default; + virtual std::vector> apply(Part *a, Part *b) const = 0; +}; + +// Identity: each pin on A is wired to the same-named pin on B, when present. +// Used as the fallback when no specific transform is registered for the pair. +class IdentityTransform : public Transform +{ +public: + IdentityTransform(); + std::vector> apply(Part *a, Part *b) const override; +}; + +class TransformRegistry +{ + std::map, Transform *> entries; + Transform *identity_; + TransformRegistry(); + ~TransformRegistry(); + +public: + static TransformRegistry &get(); + void add(const std::string &kindA, const std::string &kindB, Transform *t); + // Returns the transform registered for (kindA, kindB). If neither (kindA, kindB) + // nor (kindB, kindA) is registered, returns the identity transform. + Transform *lookup(const std::string &kindA, const std::string &kindB) const; + Transform *identity() const; + + TransformRegistry(const TransformRegistry &) = delete; + TransformRegistry &operator=(const TransformRegistry &) = delete; +}; + +#endif // _TRANSFORM_HPP_ diff --git a/src/system/transform_vpx.cpp b/src/system/transform_vpx.cpp new file mode 100644 index 0000000..198149d --- /dev/null +++ b/src/system/transform_vpx.cpp @@ -0,0 +1,204 @@ +#include "transform_vpx.hpp" + +#include "parts.hpp" +#include "pins.hpp" + +#include +#include +#include +#include +#include + +VpxTransform::VpxTransform(std::string name, + std::string bkp_kind, std::string payload_kind, + std::vector bkp_to_payload, + std::vector payload_to_bkp) + : Transform(std::move(name)), + bkp_kind_(std::move(bkp_kind)), + payload_kind_(std::move(payload_kind)), + bkp_to_payload_(std::move(bkp_to_payload)), + payload_to_bkp_(std::move(payload_to_bkp)) {} + +namespace { + +struct ParsedPin { + char col; // single-letter column + int row; // numeric row (1-based) + std::string digits; // original digit string (preserves leading zeros) + bool ok = false; +}; + +ParsedPin parse_pin(const std::string &name) { + ParsedPin out; + size_t i = 0; + while (i < name.size() && std::isalpha((unsigned char)name[i])) ++i; + if (i != 1) return out; // single letter only (VPX 3U convention) + if (i == name.size()) return out; + for (size_t k = i; k < name.size(); ++k) + if (!std::isdigit((unsigned char)name[k])) return out; + out.col = (char)std::toupper((unsigned char)name[0]); + out.digits = name.substr(i); + try { out.row = std::stoi(out.digits); } + catch (const std::exception &) { return out; } + if (out.row <= 0) return out; + out.ok = true; + return out; +} + +// Try to find a pin in `to` whose name matches the new column + the source +// row, allowing for either the source's exact digit-string or the canonical +// integer form (so "C01" finds either "C01" or "C1"). +Pin *find_target_pin(Part *to, char col, int row, const std::string &digits) { + std::string s1 = std::string(1, col) + digits; + try { return to->get(s1); } catch (const std::exception &) {} + std::string s2 = std::string(1, col) + std::to_string(row); + if (s2 != s1) { + try { return to->get(s2); } catch (const std::exception &) {} + } + return nullptr; +} + +} // namespace + +std::vector> VpxTransform::apply(Part *a, Part *b) const +{ + std::vector> out; + if (!a || !b) return out; + + bool a_is_bkp; + if (a->connector_type == bkp_kind_ && b->connector_type == payload_kind_) + a_is_bkp = true; + else if (a->connector_type == payload_kind_ && b->connector_type == bkp_kind_) + a_is_bkp = false; + else + return out; // not our pair + + const std::vector &table = a_is_bkp ? bkp_to_payload_ : payload_to_bkp_; + if (table.empty()) return out; + + for (auto &kv : *a) { + Pin *a_pin = kv.second; + ParsedPin p = parse_pin(kv.first); + if (!p.ok) continue; + + size_t pat_idx = (size_t)((p.row - 1) % (int)table.size()); + const ColTable &t = table[pat_idx]; + auto it = t.find(p.col); + if (it == t.end()) continue; + char new_col = it->second; + + Pin *b_pin = find_target_pin(b, new_col, p.row, p.digits); + if (!b_pin) continue; + + out.emplace_back(a_pin, b_pin); + } + return out; +} + +// ---- Built-in tables (translated verbatim from the user's Python reference) ---- + +namespace { + +using Tbl = VpxTransform::ColTable; +using Pat = std::vector; + +// Connector 0: 8 row-patterns each side. +const Pat bkp_to_payload_0 = { + {{'A','A'},{'B','C'},{'C','C'},{'D','C'},{'E','D'},{'F','E'},{'G','E'},{'H','F'},{'I','G'}}, + {{'A','A'},{'B','C'},{'C','C'},{'D','C'},{'E','D'},{'F','E'},{'G','E'},{'H','F'},{'I','G'}}, + {{'A','A'},{'B','C'},{'C','C'},{'D','C'},{'E','D'},{'F','E'},{'G','E'},{'H','F'},{'I','G'}}, + {{'A','C'},{'B','A'},{'C','B'},{'D','C'},{'E','D'},{'F','E'},{'G','F'},{'H','G'},{'I','E'}}, + {{'A','C'},{'B','A'},{'C','B'},{'D','C'},{'E','D'},{'F','E'},{'G','F'},{'H','G'},{'I','E'}}, + {{'A','C'},{'B','A'},{'C','B'},{'D','C'},{'E','D'},{'F','E'},{'G','F'},{'H','G'},{'I','E'}}, + {{'A','A'},{'B','B'},{'C','C'},{'D','C'},{'E','D'},{'F','E'},{'G','F'},{'H','F'},{'I','G'}}, + {{'A','A'},{'B','A'},{'C','B'},{'D','C'},{'E','D'},{'F','D'},{'G','E'},{'H','F'},{'I','G'}}, +}; +const Pat payload_to_bkp_0 = { + {{'A','A'},{'B','B'},{'C','C'},{'D','E'},{'E','G'},{'F','H'},{'G','I'}}, + {{'A','A'},{'B','B'},{'C','C'},{'D','E'},{'E','G'},{'F','H'},{'G','I'}}, + {{'A','A'},{'B','B'},{'C','C'},{'D','E'},{'E','G'},{'F','H'},{'G','I'}}, + {{'A','B'},{'B','C'},{'C','D'},{'D','E'},{'E','F'},{'F','G'},{'G','H'}}, + {{'A','B'},{'B','C'},{'C','D'},{'D','E'},{'E','F'},{'F','G'},{'G','H'}}, + {{'A','B'},{'B','C'},{'C','D'},{'D','E'},{'E','F'},{'F','G'},{'G','H'}}, + {{'A','A'},{'B','B'},{'C','C'},{'D','E'},{'E','F'},{'F','G'},{'G','I'}}, + {{'A','B'},{'B','C'},{'C','D'},{'D','E'},{'E','G'},{'F','H'},{'G','I'}}, +}; + +// Connectors 1 and 2: 2 row-patterns each side. +const Pat bkp_to_payload_aux = { + {{'A','A'},{'B','B'},{'C','C'},{'D','C'},{'E','D'},{'F','E'},{'G','F'},{'H','F'},{'I','G'}}, + {{'A','A'},{'B','A'},{'C','B'},{'D','C'},{'E','D'},{'F','D'},{'G','E'},{'H','F'},{'I','G'}}, +}; +const Pat payload_to_bkp_aux = { + {{'A','A'},{'B','B'},{'C','C'},{'D','E'},{'E','F'},{'F','G'},{'G','I'}}, + {{'A','A'},{'B','C'},{'C','D'},{'D','E'},{'E','G'},{'F','H'},{'G','I'}}, +}; + +} // namespace + +namespace { + +// Returns the set of acceptable column letters for a known VPX kind, or +// an empty set when `kind` is unknown (no built-in layout to enforce). +std::set expected_cols(const std::string &kind) { + if (kind == "vpx-3u-bkp-p0" + || kind == "vpx-3u-bkp-p1" + || kind == "vpx-3u-bkp-p2") { + return {'A','B','C','D','E','F','G','H','I'}; + } + if (kind == "vpx-3u-payload-p0" + || kind == "vpx-3u-payload-p1" + || kind == "vpx-3u-payload-p2") { + return {'A','B','C','D','E','F','G'}; + } + return {}; +} + +} // namespace + +std::string ValidatePartForKind(const Part *p, const std::string &kind) +{ + if (!p) return "no part"; + auto cols = expected_cols(kind); + if (cols.empty()) return ""; // unknown kind → caller handles freely + + int bad_count = 0; + std::string sample; + std::set seen; + for (auto &kv : *const_cast(p)) { + ParsedPin pp = parse_pin(kv.first); + if (!pp.ok || !cols.count(pp.col)) { + ++bad_count; + if (sample.empty()) sample = kv.first; + continue; + } + seen.insert(pp.col); + } + if (bad_count > 0) { + return std::to_string(bad_count) + " pin(s) don't fit the '" + kind + + "' layout (e.g. '" + sample + "'); expected single-letter columns " + + "{" + std::string(cols.begin(), cols.end()) + "}."; + } + if (seen != cols) { + std::string missing; + for (char c : cols) if (!seen.count(c)) missing += c; + return "missing pin column(s) for '" + kind + "': " + missing; + } + return ""; +} + +void RegisterVpxTransforms(TransformRegistry ®) +{ + reg.add("vpx-3u-bkp-p0", "vpx-3u-payload-p0", + new VpxTransform("vpx-3u-p0", + "vpx-3u-bkp-p0", "vpx-3u-payload-p0", + bkp_to_payload_0, payload_to_bkp_0)); + reg.add("vpx-3u-bkp-p1", "vpx-3u-payload-p1", + new VpxTransform("vpx-3u-p1", + "vpx-3u-bkp-p1", "vpx-3u-payload-p1", + bkp_to_payload_aux, payload_to_bkp_aux)); + reg.add("vpx-3u-bkp-p2", "vpx-3u-payload-p2", + new VpxTransform("vpx-3u-p2", + "vpx-3u-bkp-p2", "vpx-3u-payload-p2", + bkp_to_payload_aux, payload_to_bkp_aux)); +} diff --git a/src/system/transform_vpx.hpp b/src/system/transform_vpx.hpp new file mode 100644 index 0000000..be7b91b --- /dev/null +++ b/src/system/transform_vpx.hpp @@ -0,0 +1,52 @@ +#ifndef _TRANSFORM_VPX_HPP_ +#define _TRANSFORM_VPX_HPP_ + +#include "transform.hpp" + +#include +#include +#include + +// 3U-VPX connector pin-name transform. +// +// Each VPX 3U slot has 3 connectors per side: backplane (P0/P1/P2) and +// payload (J0/J1/J2). The pin-name correspondence between the two sides +// is *not* identity (differential-pair routing makes 9 backplane columns +// map onto 7 payload columns, and the mapping cycles on a row-pattern +// basis that depends on which connector index we're on). +// +// One VpxTransform handles a single connector index (0, 1 or 2). Three +// instances are registered at startup, under the type pairs +// (vpx-3u-bkp-N, vpx-3u-payload-N) +// for N in {0, 1, 2}. Tag each Part with the correct connector_type via +// `set-type`, then `connect` will pick the right transform automatically. +class VpxTransform : public Transform +{ +public: + using ColTable = std::map; + + VpxTransform(std::string name, + std::string bkp_kind, std::string payload_kind, + std::vector bkp_to_payload, + std::vector payload_to_bkp); + + std::vector> apply(Part *a, Part *b) const override; + +private: + std::string bkp_kind_; + std::string payload_kind_; + std::vector bkp_to_payload_; // pattern row → col→col map (cycling by row index) + std::vector payload_to_bkp_; +}; + +class TransformRegistry; +void RegisterVpxTransforms(TransformRegistry ®); + +class Part; + +// Returns "" if the part's pin layout is consistent with `kind`, otherwise an +// error message describing the mismatch. Returns "" for unknown `kind`s +// (no built-in layout to check against → caller-defined types). +std::string ValidatePartForKind(const Part *p, const std::string &kind); + +#endif // _TRANSFORM_VPX_HPP_ diff --git a/src/tui/commands.cpp b/src/tui/commands.cpp new file mode 100644 index 0000000..48f40b9 --- /dev/null +++ b/src/tui/commands.cpp @@ -0,0 +1,417 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include "system/connect.hpp" +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/persist.hpp" +#include "system/signals.hpp" +#include "system/system.hpp" +#include "system/transform.hpp" +#include "system/transform_vpx.hpp" + +#include +#include +#include +#include +#include +#include + +void Tui::RegisterCommands() { + commands["help"] = { + {{"command name (optional)", Completion::Command}}, + [this](const std::vector &args) { + if (args.empty()) { + Print("Commands — type `help ` for details."); + size_t maxw = 0; + for (const auto &kv : commands) maxw = std::max(maxw, kv.first.size()); + for (const auto &kv : commands) { + Print(" " + kv.first + + std::string(maxw - kv.first.size() + 2, ' ') + + kv.second.description); + } + Print("Keys: Esc cancels a multi-step prompt; Tab completes commands or paths."); + return; + } + const std::string &name = args[0]; + auto it = commands.find(name); + if (it == commands.end()) { Print("unknown command: " + name); return; } + const auto &spec = it->second; + Print(name + " — " + spec.description); + if (spec.params.empty()) { + Print(" no arguments."); + } else { + for (size_t i = 0; i < spec.params.size(); ++i) { + Print(" arg " + std::to_string(i + 1) + ": " + spec.params[i].name); + } + } + if (!spec.prompt_for_missing) { + Print(" run with no args for the interactive form."); + } else if (!spec.params.empty()) { + Print(" missing args trigger a prompt for each one."); + } + }, + /*prompt_for_missing=*/ false, + "show command help (optionally for a specific command)", + }; + commands["clear"] = { {}, [this](auto &) { output.clear(); }, true, + "clear the visualization area" }; + commands["quit"] = { {}, [this](auto &) { quit = true; }, true, + "leave essim" }; + commands["exit"] = { {}, [this](auto &) { quit = true; }, true, + "leave essim (alias of quit)" }; + + commands["new"] = { {}, [this](auto &) { + sys = std::make_unique(); + recorded.clear(); + Print("system created."); + }, true, "create a new (empty) system; resets the script-save buffer" }; + + commands["load"] = { + {{"module name", Completion::None}, + {"filename", Completion::Path}, + {"import type [mentor|altium|ods]", Completion::None}}, + [this](const std::vector &args) { + if (!sys) { Print("no system: run 'new' first."); return; } + std::string ls = ToLower(args[2]); + ImportType t; + if (ls == "mentor") t = ImportType::IMPORT_MENTOR; + else if (ls == "altium") t = ImportType::IMPORT_ALTIUM; + else if (ls == "ods") t = ImportType::IMPORT_ODS; + else { Print("unknown import type: " + args[2]); return; } + try { + sys->Load(args[0], args[1], t); + Module *mod = sys->modules()->get(args[0]); + Print("loaded '" + args[0] + "' from " + args[1]); + Print(" parts: " + std::to_string(mod->size())); + Print(" signals: " + std::to_string(mod->signals->size())); + } catch (const std::exception &e) { + Print(std::string("load failed: ") + e.what()); + } + }, + /*prompt_for_missing=*/ true, + "load a module from a netlist / pinout file (mentor, altium, ods)", + }; + + commands["source"] = { + {{"filename", Completion::Path}}, + [this](const std::vector &args) { + Source(args[0]); + }, + /*prompt_for_missing=*/ true, + "execute a file of commands line by line (interactive cmds rejected)", + }; + + commands["script-save"] = { + {{"filename", Completion::Path}}, + [this](const std::vector &args) { + std::string expanded = args[0]; + if (!expanded.empty() && expanded[0] == '~') { + if (const char *home = std::getenv("HOME")) + expanded = std::string(home) + expanded.substr(1); + } + std::ofstream f(expanded); + if (!f) { Print("script-save: cannot open " + args[0]); return; } + for (const auto &cmd : recorded) f << cmd << '\n'; + Print("script-save: " + std::to_string(recorded.size()) + + " line(s) → " + args[0]); + }, + /*prompt_for_missing=*/ true, + "write commands run since last 'new' as a replay-ready script", + }; + + commands["save"] = { + {{"filename", Completion::Path}}, + [this](const std::vector &args) { + if (!sys) { Print("no system: run 'new' first."); return; } + std::string err; + if (save_system(sys.get(), args[0], err)) { + Print("saved to " + args[0]); + } else { + Print("save failed: " + err); + } + }, + /*prompt_for_missing=*/ true, + "write the current system snapshot to a file", + }; + + commands["restore"] = { + {{"filename", Completion::Path}}, + [this](const std::vector &args) { + std::string err; + System *fresh = restore_system(args[0], err); + if (!fresh) { Print("restore failed: " + err); return; } + sys.reset(fresh); + int mods = (int)sys->modules()->size(); + int conns = (int)sys->connections()->size(); + Print("restored from " + args[0] + + " (" + std::to_string(mods) + " module(s), " + + std::to_string(conns) + " connection(s))"); + }, + /*prompt_for_missing=*/ true, + "replace the current system with a saved snapshot", + }; + + commands["set-type"] = { + {{"module", Completion::None}, + {"part (name or pattern)", Completion::None}, + {"connector type (free string, e.g. vpx-bp, vpx-payload)", Completion::None}}, + [this](const std::vector &args) { + if (!sys) { Print("no system: run 'new' first."); return; } + + if (args.empty()) { + settype_modules.clear(); + for (auto &m : *sys->modules()) settype_modules.push_back(m.first); + std::sort(settype_modules.begin(), settype_modules.end(), NaturalLess); + if (settype_modules.empty()) { Print("no modules loaded."); return; } + settype_m_idx = 0; + settype_p_filter.clear(); + settype_p_idx = 0; + settype_type.clear(); + settype_status.clear(); + settype_focus_idx = 0; + screen_idx = 3; + return; + } + + if (args.size() != 3) { + Print("usage: set-type (or no args for interactive)"); + return; + } + + Module *mod; + try { mod = sys->modules()->get(args[0]); } + catch (const std::exception &) { + Print("unknown module: " + args[0]); return; + } + Part *prt = nullptr; + try { prt = mod->get(args[1]); } + catch (const std::exception &) { + std::string needle = ToLower(args[1]); + std::vector matches; + for (auto &p : *mod) + if (ToLower(p.first).find(needle) != std::string::npos) + matches.push_back(p.second); + if (matches.size() == 1) prt = matches[0]; + else { + Print(std::to_string(matches.size()) + + " match(es) for part '" + args[1] + "' in " + mod->name); + return; + } + } + std::string err = ValidatePartForKind(prt, args[2]); + if (!err.empty()) { + Print("set-type refused: " + err); + return; + } + prt->connector_type = args[2]; + Print(mod->name + "/" + prt->name + ": connector_type = " + + (args[2].empty() ? "(none)" : args[2])); + }, + /*prompt_for_missing=*/ false, + "tag a part's connector type for transform lookup", + }; + + commands["connect"] = { + {{"module1", Completion::None}, + {"part1 (name or pattern)", Completion::None}, + {"module2", Completion::None}, + {"part2 (name or pattern)", Completion::None}}, + [this](const std::vector &args) { + if (!sys) { Print("no system: run 'new' first."); return; } + + if (args.empty()) { + connect_modules.clear(); + for (auto &m : *sys->modules()) connect_modules.push_back(m.first); + std::sort(connect_modules.begin(), connect_modules.end(), NaturalLess); + if (connect_modules.empty()) { Print("no modules loaded."); return; } + connect_m1_idx = 0; + connect_m2_idx = std::min(1, (int)connect_modules.size() - 1); + connect_p1_filter.clear(); + connect_p2_filter.clear(); + connect_p1_idx = 0; + connect_p2_idx = 0; + connect_focus_idx = 0; + screen_idx = 2; + return; + } + + if (args.size() != 4) { + Print("usage: connect (or no args for interactive)"); + return; + } + + auto resolve_module = [this](const std::string &name) + -> std::pair> { + try { return {sys->modules()->get(name), {}}; } + catch (const std::exception &) {} + std::string needle = ToLower(name); + std::vector matches; + std::vector names; + for (auto &m : *sys->modules()) { + if (ToLower(m.first).find(needle) != std::string::npos) { + matches.push_back(m.second); + names.push_back(m.first); + } + } + if (matches.size() == 1) return {matches[0], {}}; + return {nullptr, names}; + }; + + auto resolve_part = [](Module *mod, const std::string &name) + -> std::pair> { + try { return {mod->get(name), {}}; } + catch (const std::exception &) {} + std::string needle = ToLower(name); + std::vector matches; + std::vector names; + for (auto &p : *mod) { + if (ToLower(p.first).find(needle) != std::string::npos) { + matches.push_back(p.second); + names.push_back(p.first); + } + } + if (matches.size() == 1) return {matches[0], {}}; + return {nullptr, names}; + }; + + auto report_ambiguous = [this](const std::string &what, + const std::string &needle, + const std::vector &names) { + if (names.empty()) { + Print(what + " not found: " + needle); + } else { + Print(what + " ambiguous for '" + needle + "': " + + std::to_string(names.size()) + " match(es)"); + int shown = 0; + for (const auto &n : names) { + if (shown++ >= 8) { Print(" …"); break; } + Print(" " + n); + } + } + }; + + auto [m1, m1_alts] = resolve_module(args[0]); + if (!m1) { report_ambiguous("module", args[0], m1_alts); return; } + auto [p1, p1_alts] = resolve_part(m1, args[1]); + if (!p1) { report_ambiguous("part in " + m1->name, args[1], p1_alts); return; } + auto [m2, m2_alts] = resolve_module(args[2]); + if (!m2) { report_ambiguous("module", args[2], m2_alts); return; } + auto [p2, p2_alts] = resolve_part(m2, args[3]); + if (!p2) { report_ambiguous("part in " + m2->name, args[3], p2_alts); return; } + + auto ® = TransformRegistry::get(); + Transform *t = reg.lookup(p1->connector_type, p2->connector_type); + bool both_empty = p1->connector_type.empty() && p2->connector_type.empty(); + if (t == reg.identity()) { + if (!both_empty) { + Print("connect refused: no transform for types '" + + (p1->connector_type.empty() ? "(none)" : p1->connector_type) + + "' ↔ '" + + (p2->connector_type.empty() ? "(none)" : p2->connector_type) + + "'. Set matching types via 'set-type' first."); + return; + } + std::string err = CheckIdentityCompatible(p1, p2); + if (!err.empty()) { + Print("connect refused: " + err); + return; + } + } + auto pin_map = t->apply(p1, p2); + + std::string conn_name = m1->name + "/" + p1->name + + " <-> " + m2->name + "/" + p2->name; + try { + Connection *c = new Connection(conn_name, m1, p1, m2, p2); + c->transform_name = t->name; + c->pin_map = std::move(pin_map); + sys->connections()->add(c); + Print("connected: " + conn_name + + " via " + t->name + + " (" + std::to_string(c->pin_map.size()) + " wires)"); + } catch (const std::exception &e) { + Print(std::string("connect failed: ") + e.what()); + } + }, + /*prompt_for_missing=*/ false, + "connect a part across two modules (interactive screen if no args)", + }; + + commands["explore"] = { {}, [this](auto &) { + if (!sys) { Print("no system: run 'new' first."); return; } + explore_modules.clear(); + for (auto &m : *sys->modules()) explore_modules.push_back(m.first); + std::sort(explore_modules.begin(), explore_modules.end(), NaturalLess); + if (explore_modules.empty()) { Print("no modules loaded."); return; } + explore_module_idx = 0; + explore_type_idx = 0; + explore_child_idx = 0; + explore_detail_idx = 0; + explore_child_filter.clear(); + explore_detail_filter.clear(); + explore_focus_idx = 0; + screen_idx = 4; + }, true, "browse modules → parts/signals/connections → details (interactive)", + /*scriptable=*/ false }; + + commands["search"] = { + {{"module", Completion::None}, + {"kind [parts|signals]", Completion::None}, + {"pattern", Completion::None}}, + [this](const std::vector &args) { + if (!sys) { Print("no system: run 'new' first."); return; } + + if (args.empty()) { + search_modules.clear(); + for (auto &m : *sys->modules()) search_modules.push_back(m.first); + std::sort(search_modules.begin(), search_modules.end(), NaturalLess); + if (search_modules.empty()) { Print("no modules loaded."); return; } + search_module_idx = 0; + search_type_idx = 0; + search_query.clear(); + search_focus_idx = 0; + screen_idx = 1; + return; + } + + if (args.size() != 3) { + Print("usage: search "); + Print(" search (interactive)"); + return; + } + + Module *mod; + try { mod = sys->modules()->get(args[0]); } + catch (const std::exception &) { + Print("unknown module: " + args[0]); return; + } + + std::string kind = ToLower(args[1]); + std::string needle = ToLower(args[2]); + + std::vector> hits; + if (kind == "parts" || kind == "part") { + for (auto &pkv : *mod) + if (ToLower(pkv.first).find(needle) != std::string::npos) + hits.emplace_back(pkv.first, pkv.second->size()); + } else if (kind == "signals" || kind == "signal") { + for (auto &skv : *mod->signals) + if (ToLower(skv.first).find(needle) != std::string::npos) + hits.emplace_back(skv.first, skv.second->size()); + } else { + Print("kind must be 'parts' or 'signals' (got: " + args[1] + ")"); + return; + } + std::sort(hits.begin(), hits.end(), + [](const auto &a, const auto &b) { return NaturalLess(a.first, b.first); }); + for (const auto &h : hits) { + Print(" " + args[0] + "/" + h.first + + " (" + std::to_string(h.second) + " pins)"); + } + Print(std::to_string(hits.size()) + " match(es)."); + }, + /*prompt_for_missing=*/ false, + "list parts/signals matching a pattern (interactive screen if no args)", + }; +} diff --git a/src/tui/completion.cpp b/src/tui/completion.cpp new file mode 100644 index 0000000..e2bd547 --- /dev/null +++ b/src/tui/completion.cpp @@ -0,0 +1,111 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include +#include +#include + +void Tui::CompleteCommand(size_t start) { + std::string current = input.substr(start); + std::vector matches; + for (const auto &kv : commands) + if (kv.first.rfind(current, 0) == 0) matches.push_back(kv.first); + + if (matches.empty()) return; + + auto replace_with = [&](const std::string &replacement) { + input.replace(start, std::string::npos, replacement); + cursor_pos = (int)input.size(); + }; + + if (matches.size() == 1) { replace_with(matches[0]); return; } + + std::string lcp = LongestCommonPrefix(matches); + if (lcp.size() > current.size()) { replace_with(lcp); return; } + + std::string line = " "; + for (const auto &m : matches) line += " " + m; + Print(line); +} + +void Tui::CompletePath(size_t start) { + namespace fs = std::filesystem; + + std::string current = input.substr(start); + + auto pos = current.rfind('/'); + std::string disp, prefix; + if (pos == std::string::npos) { disp = ""; prefix = current; } + else { disp = current.substr(0, pos + 1); prefix = current.substr(pos + 1); } + + std::string resolved = disp.empty() ? "." : disp; + if (!resolved.empty() && resolved[0] == '~') { + if (const char *home = std::getenv("HOME")) + resolved = std::string(home) + resolved.substr(1); + } + + std::vector names; + std::vector is_dir; + try { + for (const auto &e : fs::directory_iterator(resolved)) { + std::string n = e.path().filename().string(); + if (n.rfind(prefix, 0) == 0) { + names.push_back(n); + is_dir.push_back(e.is_directory()); + } + } + } catch (const std::exception &) { + return; + } + if (names.empty()) return; + + auto replace_with = [&](const std::string &replacement) { + input.replace(start, std::string::npos, replacement); + cursor_pos = (int)input.size(); + }; + + if (names.size() == 1) { + replace_with(disp + names[0] + (is_dir[0] ? "/" : "")); + return; + } + + std::string lcp = LongestCommonPrefix(names); + if (lcp.size() > prefix.size()) { replace_with(disp + lcp); return; } + + std::string line = " "; + for (size_t i = 0; i < names.size(); ++i) + line += " " + names[i] + (is_dir[i] ? "/" : ""); + Print(line); +} + +void Tui::CompleteInline() { + bool ends_with_ws = !input.empty() + && std::isspace((unsigned char)input.back()); + + size_t arg_start; + if (input.empty() || ends_with_ws) { + arg_start = input.size(); + } else { + size_t i = input.size(); + while (i > 0 && !std::isspace((unsigned char)input[i - 1])) --i; + arg_start = i; + } + + auto preceding = Tokenize(input.substr(0, arg_start)); + int arg_index = (int)preceding.size(); + + if (arg_index == 0) { CompleteCommand(); return; } + + if (preceding.empty()) return; + auto cmd_it = commands.find(preceding[0]); + if (cmd_it == commands.end()) return; + const auto &spec = cmd_it->second; + + int param_idx = arg_index - 1; + if (param_idx >= (int)spec.params.size()) return; + switch (spec.params[param_idx].completion) { + case Completion::Path: CompletePath(arg_start); break; + case Completion::Command: CompleteCommand(arg_start); break; + case Completion::None: break; + } +} diff --git a/src/tui/screen_connect.cpp b/src/tui/screen_connect.cpp new file mode 100644 index 0000000..6a30218 --- /dev/null +++ b/src/tui/screen_connect.cpp @@ -0,0 +1,146 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include "system/connect.hpp" +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/system.hpp" +#include "system/transform.hpp" + +#include +#include +#include + +#include +#include +#include + +using namespace ftxui; + +void Tui::RefreshFilteredPartList(const std::vector &modules, + int m_idx, + const std::string &filter, + std::vector &out, + int &sel_idx) { + out.clear(); + if (!sys || modules.empty()) { sel_idx = 0; return; } + try { + Module *mod = sys->modules()->get(modules[m_idx]); + std::string needle = ToLower(filter); + for (auto &pkv : *mod) { + if (needle.empty() + || ToLower(pkv.first).find(needle) != std::string::npos) { + out.push_back(pkv.first); + } + } + } catch (const std::exception &) {} + std::sort(out.begin(), out.end(), NaturalLess); + if (sel_idx >= (int)out.size()) sel_idx = std::max(0, (int)out.size() - 1); +} + +Component Tui::BuildConnectScreen() { + InputOption pf_opt; + pf_opt.multiline = false; + pf_opt.transform = [](InputState s) { + auto el = s.element; + if (s.is_placeholder) el |= dim; + return el; + }; + + auto m1_menu = Menu(&connect_modules, &connect_m1_idx); + auto p1_filter = Input(&connect_p1_filter, "filter…", pf_opt); + auto p1_menu = Menu(&connect_p1_list, &connect_p1_idx); + auto m2_menu = Menu(&connect_modules, &connect_m2_idx); + auto p2_filter = Input(&connect_p2_filter, "filter…", pf_opt); + auto p2_menu = Menu(&connect_p2_list, &connect_p2_idx); + + auto do_connect = [this] { + if (!sys || connect_modules.empty()) { screen_idx = 0; return; } + if (connect_p1_list.empty() || connect_p2_list.empty()) { + Print("connect: select a part on each side first."); + screen_idx = 0; + return; + } + try { + Module *m1 = sys->modules()->get(connect_modules[connect_m1_idx]); + Module *m2 = sys->modules()->get(connect_modules[connect_m2_idx]); + Part *p1 = m1->get(connect_p1_list[connect_p1_idx]); + Part *p2 = m2->get(connect_p2_list[connect_p2_idx]); + + auto ® = TransformRegistry::get(); + Transform *t = reg.lookup(p1->connector_type, p2->connector_type); + bool both_empty = p1->connector_type.empty() && p2->connector_type.empty(); + if (t == reg.identity()) { + if (!both_empty) { + Print("connect refused: no transform for types '" + + (p1->connector_type.empty() ? "(none)" : p1->connector_type) + + "' ↔ '" + + (p2->connector_type.empty() ? "(none)" : p2->connector_type) + + "'. Set matching types via 'set-type' first."); + screen_idx = 0; + return; + } + std::string err = CheckIdentityCompatible(p1, p2); + if (!err.empty()) { + Print("connect refused: " + err); + screen_idx = 0; + return; + } + } + auto pin_map = t->apply(p1, p2); + + std::string conn_name = m1->name + "/" + p1->name + + " <-> " + m2->name + "/" + p2->name; + Connection *c = new Connection(conn_name, m1, p1, m2, p2); + c->transform_name = t->name; + c->pin_map = std::move(pin_map); + sys->connections()->add(c); + Print("connected: " + conn_name + + " via " + t->name + + " (" + std::to_string(c->pin_map.size()) + " wires)"); + } catch (const std::exception &e) { + Print(std::string("connect failed: ") + e.what()); + } + screen_idx = 0; + }; + auto connect_button = Button(" Connect ", do_connect); + + auto components = Container::Vertical({ + m1_menu, p1_filter, p1_menu, + m2_menu, p2_filter, p2_menu, + connect_button, + }, &connect_focus_idx); + + return Renderer(components, + [this, m1_menu, p1_filter, p1_menu, + m2_menu, p2_filter, p2_menu, connect_button] { + RefreshFilteredPartList(connect_modules, connect_m1_idx, + connect_p1_filter, connect_p1_list, connect_p1_idx); + RefreshFilteredPartList(connect_modules, connect_m2_idx, + connect_p2_filter, connect_p2_list, connect_p2_idx); + + auto col = [&](const std::string &title, + Component mm, Component pf, Component pm) { + return vbox({ + text(title) | bold, + text("module") | dim, + mm->Render() | yframe | size(HEIGHT, LESS_THAN, 8), + separator(), + hbox({text(" filter: "), pf->Render() | flex}) | border, + text("part") | dim, + pm->Render() | yframe | flex, + }) | flex; + }; + + return vbox({ + hbox({ + col("endpoint 1", m1_menu, p1_filter, p1_menu), + separator(), + col("endpoint 2", m2_menu, p2_filter, p2_menu), + }) | flex, + separator(), + hbox({filler(), connect_button->Render(), filler()}), + text(" Tab: cycle focus | Enter on [Connect]: confirm | Esc: leave ") | dim, + }) | border; + }); +} diff --git a/src/tui/screen_explore.cpp b/src/tui/screen_explore.cpp new file mode 100644 index 0000000..e1d9d1a --- /dev/null +++ b/src/tui/screen_explore.cpp @@ -0,0 +1,207 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include "system/connect.hpp" +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/pins.hpp" +#include "system/signals.hpp" +#include "system/system.hpp" + +#include +#include +#include + +#include +#include + +using namespace ftxui; + +Component Tui::BuildExploreScreen() { + InputOption pf_opt; + pf_opt.multiline = false; + pf_opt.transform = [](InputState s) { + auto el = s.element; + if (s.is_placeholder) el |= dim; + return el; + }; + + auto module_menu = Menu(&explore_modules, &explore_module_idx); + auto type_menu = Menu(&explore_types, &explore_type_idx); + auto child_filter = Input(&explore_child_filter, "filter…", pf_opt); + auto children_menu = Menu(&explore_children, &explore_child_idx); + auto detail_filter = Input(&explore_detail_filter, "filter…", pf_opt); + auto detail_menu = Menu(&explore_detail, &explore_detail_idx); + + auto components = Container::Vertical( + {module_menu, type_menu, child_filter, children_menu, detail_filter, detail_menu}, + &explore_focus_idx); + + return Renderer(components, + [this, module_menu, type_menu, child_filter, children_menu, detail_filter, detail_menu] { + try { + // Clamp the module index defensively (covers fresh systems / restore). + if (explore_module_idx < 0 + || explore_module_idx >= (int)explore_modules.size()) { + explore_module_idx = 0; + } + + // Refresh the children list for the selected module + type, applying child filter. + explore_children.clear(); + Module *cur_mod = nullptr; + std::string c_needle = ToLower(explore_child_filter); + if (sys && !explore_modules.empty()) { + try { + cur_mod = sys->modules()->get(explore_modules[explore_module_idx]); + auto keep = [&](const std::string &name) { + return c_needle.empty() + || ToLower(name).find(c_needle) != std::string::npos; + }; + if (explore_type_idx == 0) { + for (auto &pkv : *cur_mod) + if (keep(pkv.first)) explore_children.push_back(pkv.first); + } else if (explore_type_idx == 1) { + for (auto &skv : *cur_mod->signals) + if (keep(skv.first)) explore_children.push_back(skv.first); + } else { + for (auto &ckv : *sys->connections()) { + Connection *c = ckv.second; + if ((c->m1 == cur_mod || c->m2 == cur_mod) && keep(ckv.first)) + explore_children.push_back(ckv.first); + } + } + } catch (const std::exception &) {} + } + std::sort(explore_children.begin(), explore_children.end(), NaturalLess); + + // Never let the Menu see an empty list — FTXUI Menu's clamp(v, 0, size()-1) + // is brittle when size() == 0; a placeholder also gives the user feedback. + if (explore_children.empty()) explore_children.push_back("(no matches)"); + + if (explore_child_idx < 0 + || explore_child_idx >= (int)explore_children.size()) { + explore_child_idx = 0; + } + + std::string d_needle = ToLower(explore_detail_filter); + auto keep_detail = [&](const std::string &line) { + return d_needle.empty() + || ToLower(line).find(d_needle) != std::string::npos; + }; + + // Build the detail pane (lines stored in explore_detail so the Menu + // bound to it can scroll with arrow keys when focused). + explore_header = "(no system)"; + explore_detail.clear(); + if (cur_mod && !explore_children.empty()) { + const std::string &cname = explore_children[explore_child_idx]; + try { + if (explore_type_idx == 0) { + Part *p = cur_mod->get(cname); + std::string type_str = p->connector_type.empty() + ? "(no type)" : p->connector_type; + explore_header = cur_mod->name + "/" + p->name + + " — " + std::to_string(p->size()) + + " pins • type: " + type_str; + std::vector> rows; + for (auto &pin_kv : *p) { + Signal *s = pin_kv.second->signal(); + rows.emplace_back(pin_kv.first, s ? s->name : std::string("—")); + } + std::sort(rows.begin(), rows.end(), + [](const auto &a, const auto &b) { return NaturalLess(a.first, b.first); }); + size_t maxw = 0; + for (const auto &r : rows) maxw = std::max(maxw, r.first.size()); + for (const auto &r : rows) { + std::string line = " " + r.first + + std::string(maxw - r.first.size() + 2, ' ') + + r.second; + if (keep_detail(line)) + explore_detail.push_back(line); + } + } else if (explore_type_idx == 1) { + Signal *s = cur_mod->signals->get(cname); + explore_header = cur_mod->name + "/" + s->name + + " — " + std::to_string(s->size()) + + " pins"; + std::vector rows; + for (auto &pin_kv : *s) { + Pin *pin = pin_kv.second; + std::string mname = (pin->prnt && pin->prnt->prnt) + ? pin->prnt->prnt->name : std::string("?"); + std::string pname = pin->prnt ? pin->prnt->name : std::string("?"); + rows.push_back(mname + "/" + pname + "/" + pin->name); + } + std::sort(rows.begin(), rows.end(), NaturalLess); + for (const auto &r : rows) + if (keep_detail(r)) + explore_detail.push_back(" " + r); + } else { + Connection *c = sys->connections()->get(cname); + std::string tname = c->transform_name.empty() + ? "(unknown)" : c->transform_name; + explore_header = c->name + + " — " + std::to_string(c->pin_map.size()) + + " wires • transform: " + tname; + std::vector rows; + for (auto &wp : c->pin_map) { + Pin *a = wp.first; + Pin *b = wp.second; + auto label = [](Pin *pin) { + if (!pin) return std::string("?"); + std::string m = (pin->prnt && pin->prnt->prnt) + ? pin->prnt->prnt->name : "?"; + std::string p = pin->prnt ? pin->prnt->name : "?"; + return m + "/" + p + "/" + pin->name; + }; + rows.push_back(label(a) + " ↔ " + label(b)); + } + std::sort(rows.begin(), rows.end(), NaturalLess); + for (const auto &r : rows) + if (keep_detail(r)) + explore_detail.push_back(" " + r); + } + } catch (const std::exception &) {} + } + + if (explore_detail.empty()) explore_detail.push_back("(empty)"); + if (explore_detail_idx < 0 + || explore_detail_idx >= (int)explore_detail.size()) { + explore_detail_idx = 0; + } + + auto col1 = vbox({ + text("module") | bold, + module_menu->Render() | yframe | flex, + }) | size(WIDTH, EQUAL, 24); + + auto col2 = vbox({ + text("type") | bold, + type_menu->Render() | flex, + }) | size(WIDTH, EQUAL, 12); + + auto col3 = vbox({ + text(explore_types[explore_type_idx]) | bold, + hbox({text(" filter: "), child_filter->Render() | flex}) | border, + children_menu->Render() | yframe | flex, + }) | size(WIDTH, EQUAL, 36); + + auto col4 = vbox({ + text(explore_header) | bold, + hbox({text(" filter: "), detail_filter->Render() | flex}) | border, + detail_menu->Render() | vscroll_indicator | yframe | flex, + }) | flex; + + return vbox({ + hbox({col1, separator(), col2, separator(), col3, separator(), col4}) | flex, + text(" Tab: cycle focus (incl. detail to scroll) | Esc: leave explore ") | dim, + }) | border; + } catch (const std::exception &e) { + return vbox({ + text("explore: render error") | bold, + text(std::string(" ") + e.what()) | dim, + text(" Esc: leave explore ") | dim, + }) | border; + } + }); +} diff --git a/src/tui/screen_main.cpp b/src/tui/screen_main.cpp new file mode 100644 index 0000000..9788122 --- /dev/null +++ b/src/tui/screen_main.cpp @@ -0,0 +1,42 @@ +#include "tui/tui.hpp" + +#include +#include +#include +#include + +using namespace ftxui; + +Component Tui::BuildMainScreen(ScreenInteractive &screen) { + InputOption opt; + opt.multiline = false; + opt.cursor_position = &cursor_pos; + opt.on_enter = [this] { Submit(); }; + opt.transform = [](InputState s) { + auto el = s.element; + if (s.is_placeholder) el |= dim; + return el; + }; + auto input_component = Input(&input, "type a command…", opt); + + return Renderer(input_component, [this, &screen, input_component] { + if (quit) screen.Exit(); + + Elements lines; + for (const auto &l : output) lines.push_back(text(l)); + auto view = vbox(std::move(lines)) + | focusPositionRelative(0, 1) + | yframe + | flex; + + std::string label = pending.empty() + ? "> " + : pending.front().question + "? "; + + return vbox({ + view, + separator(), + hbox({text(label), input_component->Render()}), + }) | border; + }); +} diff --git a/src/tui/screen_search.cpp b/src/tui/screen_search.cpp new file mode 100644 index 0000000..216f60c --- /dev/null +++ b/src/tui/screen_search.cpp @@ -0,0 +1,83 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/signals.hpp" +#include "system/system.hpp" + +#include +#include +#include + +#include +#include +#include + +using namespace ftxui; + +Component Tui::BuildSearchScreen() { + InputOption query_opt; + query_opt.multiline = false; + query_opt.transform = [](InputState s) { + auto el = s.element; + if (s.is_placeholder) el |= dim; + return el; + }; + + auto query_input = Input(&search_query, "filter…", query_opt); + auto module_menu = Menu(&search_modules, &search_module_idx); + auto type_menu = Menu(&search_types, &search_type_idx); + + auto components = Container::Vertical( + {query_input, module_menu, type_menu}, &search_focus_idx); + + return Renderer(components, + [this, query_input, module_menu, type_menu] { + std::vector> hits; + if (!search_modules.empty() && sys) { + const std::string &mname = search_modules[search_module_idx]; + try { + Module *mod = sys->modules()->get(mname); + std::string needle = ToLower(search_query); + if (search_type_idx == 0) { // parts + for (auto &pkv : *mod) + if (needle.empty() + || ToLower(pkv.first).find(needle) != std::string::npos) + hits.emplace_back(pkv.first, pkv.second->size()); + } else { // signals + for (auto &skv : *mod->signals) + if (needle.empty() + || ToLower(skv.first).find(needle) != std::string::npos) + hits.emplace_back(skv.first, skv.second->size()); + } + } catch (const std::exception &) {} + } + std::sort(hits.begin(), hits.end(), + [](const auto &a, const auto &b) { return NaturalLess(a.first, b.first); }); + + Elements result_lines; + for (const auto &h : hits) + result_lines.push_back( + text(" " + h.first + " (" + std::to_string(h.second) + " pins)")); + + auto left = vbox({ + text("module") | bold, + module_menu->Render() | yframe | flex, + separator(), + text("type") | bold, + type_menu->Render(), + }) | size(WIDTH, EQUAL, 28); + + auto right = vbox({ + hbox({text(" search: "), query_input->Render() | flex}) | border, + text(std::to_string(hits.size()) + " match(es)") | dim, + vbox(std::move(result_lines)) | yframe | flex, + }) | flex; + + return vbox({ + hbox({left, separator(), right}) | flex, + text(" Tab: cycle focus | Esc: leave search ") | dim, + }) | border; + }); +} diff --git a/src/tui/screen_settype.cpp b/src/tui/screen_settype.cpp new file mode 100644 index 0000000..ca69ebf --- /dev/null +++ b/src/tui/screen_settype.cpp @@ -0,0 +1,129 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/system.hpp" +#include "system/transform_vpx.hpp" + +#include +#include +#include + +#include +#include + +using namespace ftxui; + +Component Tui::BuildSettypeScreen() { + InputOption pf_opt; + pf_opt.multiline = false; + pf_opt.transform = [](InputState s) { + auto el = s.element; + if (s.is_placeholder) el |= dim; + return el; + }; + + auto module_menu = Menu(&settype_modules, &settype_m_idx); + auto part_filter = Input(&settype_p_filter, "filter…", pf_opt); + auto part_menu = Menu(&settype_p_list, &settype_p_idx); + + InputOption type_opt; + type_opt.multiline = false; + type_opt.transform = pf_opt.transform; + auto type_input = Input(&settype_type, "connector type (e.g. vpx-bp)", type_opt); + + auto do_settype = [this] { + if (!sys || settype_modules.empty()) { + settype_status = "no system / no modules"; + return; + } + if (settype_p_list.empty()) { + settype_status = "select a part first"; + return; + } + try { + Module *mod = sys->modules()->get(settype_modules[settype_m_idx]); + Part *prt = mod->get(settype_p_list[settype_p_idx]); + std::string err = ValidatePartForKind(prt, settype_type); + if (!err.empty()) { + settype_status = "refused: " + err; + return; + } + prt->connector_type = settype_type; + std::string msg = mod->name + "/" + prt->name + " = " + + (settype_type.empty() ? "(none)" : settype_type); + settype_status = "applied: " + msg; + Print("set-type " + msg); + } catch (const std::exception &e) { + settype_status = std::string("failed: ") + e.what(); + } + }; + auto button = Button(" Apply ", do_settype); + + auto components = Container::Vertical({ + module_menu, part_filter, part_menu, type_input, button, + }, &settype_focus_idx); + + return Renderer(components, + [this, module_menu, part_filter, part_menu, type_input, button] { + RefreshFilteredPartList(settype_modules, settype_m_idx, + settype_p_filter, settype_p_list, settype_p_idx); + + std::string current = "(none)"; + if (sys && !settype_modules.empty() && !settype_p_list.empty()) { + try { + Module *mod = sys->modules()->get(settype_modules[settype_m_idx]); + Part *prt = mod->get(settype_p_list[settype_p_idx]); + if (!prt->connector_type.empty()) current = prt->connector_type; + } catch (const std::exception &) {} + } + + std::vector known_types; + if (sys) { + for (auto &mkv : *sys->modules()) + for (auto &pkv : *mkv.second) + if (!pkv.second->connector_type.empty()) + known_types.push_back(pkv.second->connector_type); + std::sort(known_types.begin(), known_types.end(), NaturalLess); + known_types.erase(std::unique(known_types.begin(), known_types.end()), + known_types.end()); + } + std::string known_line = "in use:"; + if (known_types.empty()) known_line += " (none yet)"; + else for (const auto &k : known_types) known_line += " " + k; + + auto left = vbox({ + text("module") | bold, + module_menu->Render() | yframe | flex, + }) | size(WIDTH, EQUAL, 28); + + auto middle = vbox({ + hbox({text(" filter: "), part_filter->Render() | flex}) | border, + text("part") | dim, + part_menu->Render() | yframe | flex, + }) | flex; + + auto right = vbox({ + text("current type: ") | bold, + text(" " + current), + separator(), + text("new type:") | bold, + hbox({text(" "), type_input->Render() | flex}) | border, + text(known_line) | dim, + filler(), + hbox({filler(), button->Render(), filler()}), + }) | size(WIDTH, EQUAL, 40); + + Element status = settype_status.empty() + ? text("") | dim + : text(" " + settype_status) | bold; + + return vbox({ + hbox({left, separator(), middle, separator(), right}) | flex, + separator(), + status, + text(" Tab: cycle focus | Enter on [Apply]: apply (stay) | Esc: leave ") | dim, + }) | border; + }); +} diff --git a/src/tui/shell.cpp b/src/tui/shell.cpp new file mode 100644 index 0000000..df1b9bc --- /dev/null +++ b/src/tui/shell.cpp @@ -0,0 +1,236 @@ +#include "tui/tui.hpp" +#include "tui/tui_helpers.hpp" + +#include +#include +#include +#include +#include +#include + +void Tui::Print(const std::string &line) { + output.push_back(line); +} + +void Tui::HistoryUp() { + if (history.empty()) return; + if (history_idx == -1) history_idx = (int)history.size() - 1; + else if (history_idx > 0) history_idx--; + input = history[history_idx]; + cursor_pos = (int)input.size(); +} + +void Tui::HistoryDown() { + if (history_idx == -1) return; + history_idx++; + if (history_idx >= (int)history.size()) { + history_idx = -1; + input.clear(); + } else { + input = history[history_idx]; + } + cursor_pos = (int)input.size(); +} + +void Tui::CancelPending() { + if (pending.empty()) return; + pending.clear(); + input.clear(); + cursor_pos = 0; + history_idx = -1; + Print("(cancelled)"); +} + +std::vector Tui::Tokenize(const std::string &s) { + std::vector out; + std::string cur; + bool in_q = false; + for (char c : s) { + if (c == '"') { in_q = !in_q; continue; } + if (!in_q && std::isspace((unsigned char)c)) { + if (!cur.empty()) { out.push_back(std::move(cur)); cur.clear(); } + } else { + cur.push_back(c); + } + } + if (!cur.empty()) out.push_back(std::move(cur)); + return out; +} + +void Tui::Submit() { + if (!pending.empty()) { + if (input.empty()) { Print("(empty — Esc to cancel)"); return; } + Prompt p = std::move(pending.front()); + pending.pop_front(); + Print(" " + p.question + ": " + input); + std::string answer = std::move(input); + input.clear(); + cursor_pos = 0; + history_idx = -1; + p.on_answer(answer); + return; + } + + if (input.empty()) return; + history_idx = -1; + Print("> " + input); + std::string raw = std::move(input); + input.clear(); + cursor_pos = 0; + Dispatch(raw); +} + +void Tui::Dispatch(const std::string &raw) { + auto tokens = Tokenize(raw); + if (tokens.empty()) return; + + auto it = commands.find(tokens[0]); + if (it == commands.end()) { + Print("unknown command: " + tokens[0]); + if (!in_source) { history.push_back(raw); AppendHistory(raw); } + return; + } + + const std::string name = it->first; + const CommandSpec &spec = it->second; + + if (tokens.size() - 1 > spec.params.size()) { + Print("too many arguments for '" + name + "'"); + if (!in_source) { history.push_back(raw); AppendHistory(raw); } + return; + } + + auto args = std::make_shared>( + tokens.begin() + 1, tokens.end()); + + if (args->size() == spec.params.size() || !spec.prompt_for_missing) { + Finalize(name, spec, *args); + return; + } + + for (size_t i = args->size(); i < spec.params.size(); ++i) { + bool last = (i + 1 == spec.params.size()); + const auto ¶m = spec.params[i]; + pending.push_back({ + param.name, + [this, name, &spec, args, last](const std::string &s) { + args->push_back(s); + if (last) Finalize(name, spec, *args); + }, + param.completion, + }); + } +} + +void Tui::Finalize(const std::string &name, + const CommandSpec &spec, + const std::vector &args) { + std::string canonical = name; + for (const auto &a : args) { + if (a.find_first_of(" \t\"") != std::string::npos) + canonical += " \"" + a + "\""; + else + canonical += " " + a; + } + if (!in_source) { + history.push_back(canonical); + AppendHistory(canonical); + } + spec.action(args); + + static const std::set no_record = { + "clear", "help", "quit", "exit", "source", "script-save", + }; + if (spec.scriptable && !no_record.count(name)) recorded.push_back(canonical); +} + +namespace { + +std::filesystem::path HistoryPath() { + namespace fs = std::filesystem; +#ifdef _WIN32 + if (const char *p = std::getenv("LOCALAPPDATA"); p && *p) + return fs::path(p) / "essim" / "history"; + if (const char *p = std::getenv("APPDATA"); p && *p) + return fs::path(p) / "essim" / "history"; + if (const char *p = std::getenv("USERPROFILE"); p && *p) + return fs::path(p) / "AppData" / "Local" / "essim" / "history"; +#else + if (const char *p = std::getenv("XDG_DATA_HOME"); p && *p) + return fs::path(p) / "essim" / "history"; + if (const char *p = std::getenv("HOME"); p && *p) + return fs::path(p) / ".local" / "share" / "essim" / "history"; +#endif + return {}; +} + +} // namespace + +void Tui::LoadHistory() { + auto p = HistoryPath(); + if (p.empty()) return; + std::ifstream f(p); + std::string line; + while (std::getline(f, line)) + if (!line.empty()) history.push_back(line); +} + +void Tui::Source(const std::string &filename) { + std::string expanded = filename; + if (!expanded.empty() && expanded[0] == '~') { + if (const char *home = std::getenv("HOME")) + expanded = std::string(home) + expanded.substr(1); + } + std::ifstream f(expanded); + if (!f) { Print("source failed: cannot open " + filename); return; } + + bool prev = in_source; + in_source = true; + + int executed = 0; + int lineno = 0; + bool aborted = false; + std::string line; + while (std::getline(f, line)) { + ++lineno; + size_t start = line.find_first_not_of(" \t"); + if (start == std::string::npos) continue; + if (line[start] == '#') continue; + std::string trimmed = line.substr(start); + while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back())) + trimmed.pop_back(); + if (trimmed.empty()) continue; + + input = trimmed; + cursor_pos = (int)input.size(); + Submit(); + ++executed; + + if (screen_idx != 0) { + Print("source: line " + std::to_string(lineno) + + " is interactive (would open a screen) — aborting."); + screen_idx = 0; + aborted = true; + break; + } + } + + in_source = prev; + + if (!aborted) + Print("source: " + filename + " (" + std::to_string(executed) + " line(s))"); +} + +void Tui::AppendHistory(const std::string &cmd) { + auto p = HistoryPath(); + if (p.empty()) return; + std::string trimmed = cmd; + while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back())) + trimmed.pop_back(); + if (trimmed.empty()) return; + std::error_code ec; + std::filesystem::create_directories(p.parent_path(), ec); + if (ec) return; + std::ofstream f(p, std::ios::app); + if (f) f << trimmed << '\n'; +} diff --git a/src/tui/tui.cpp b/src/tui/tui.cpp index 9fde186..4e5f453 100644 --- a/src/tui/tui.cpp +++ b/src/tui/tui.cpp @@ -1,29 +1,26 @@ #include "tui/tui.hpp" -#include "system/modules.hpp" -#include "system/parts.hpp" -#include "system/signals.hpp" #include "system/system.hpp" #include +#include #include -#include - -#include -#include -#include -#include -#include -#include -#include using namespace ftxui; Tui::Tui() - : cursor_pos(0), history_idx(-1), quit(false), + : cursor_pos(0), history_idx(-1), quit(false), in_source(false), screen_idx(0), search_types{"parts", "signals"}, - search_module_idx(0), search_type_idx(0), search_focus_idx(0) + search_module_idx(0), search_type_idx(0), search_focus_idx(0), + connect_m1_idx(0), connect_m2_idx(0), + connect_p1_idx(0), connect_p2_idx(0), + connect_focus_idx(0), + explore_module_idx(0), + explore_types{"parts", "signals", "connections"}, + explore_type_idx(0), explore_child_idx(0), + explore_detail_idx(0), explore_focus_idx(0), + settype_m_idx(0), settype_p_idx(0), settype_focus_idx(0) { LoadHistory(); RegisterCommands(); @@ -32,450 +29,68 @@ Tui::Tui() Tui::~Tui() = default; -void Tui::Print(const std::string &line) { - output.push_back(line); -} - -void Tui::HistoryUp() { - if (history.empty()) return; - if (history_idx == -1) history_idx = (int)history.size() - 1; - else if (history_idx > 0) history_idx--; - input = history[history_idx]; - cursor_pos = (int)input.size(); -} - -void Tui::HistoryDown() { - if (history_idx == -1) return; - history_idx++; - if (history_idx >= (int)history.size()) { - history_idx = -1; - input.clear(); - } else { - input = history[history_idx]; - } - cursor_pos = (int)input.size(); -} - -void Tui::CancelPending() { - if (pending.empty()) return; - pending.clear(); - input.clear(); - cursor_pos = 0; - history_idx = -1; - Print("(cancelled)"); -} - -static std::string ToLower(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), - [](unsigned char c) { return std::tolower(c); }); - return s; -} - -std::vector Tui::Tokenize(const std::string &s) { - std::vector out; - std::string cur; - bool in_q = false; - for (char c : s) { - if (c == '"') { in_q = !in_q; continue; } - if (!in_q && std::isspace((unsigned char)c)) { - if (!cur.empty()) { out.push_back(std::move(cur)); cur.clear(); } - } else { - cur.push_back(c); - } - } - if (!cur.empty()) out.push_back(std::move(cur)); - return out; -} - -void Tui::RegisterCommands() { - commands["help"] = { {}, [this](auto &) { - Print("Commands (give params inline or fill them in via prompt):"); - Print(" new create a new (empty) system"); - Print(" load load a module into the system"); - Print(" search interactive search (parts/signals, live filter)"); - Print(" clear clear the visualization area"); - Print(" help show this message"); - Print(" quit / exit leave essim"); - Print(" Esc cancel a multi-step prompt"); - Print(" Tab complete command name / file path"); - }}; - commands["clear"] = { {}, [this](auto &) { output.clear(); }}; - commands["quit"] = { {}, [this](auto &) { quit = true; }}; - commands["exit"] = { {}, [this](auto &) { quit = true; }}; - commands["new"] = { {}, [this](auto &) { - sys = std::make_unique(); - Print("system created."); - }}; - commands["load"] = { - {{"module name", false}, - {"filename", true}, - {"import type [mentor|altium|ods]", false}}, - [this](const std::vector &args) { - if (!sys) { Print("no system: run 'new' first."); return; } - std::string ls = args[2]; - std::transform(ls.begin(), ls.end(), ls.begin(), - [](unsigned char c) { return std::tolower(c); }); - ImportType t; - if (ls == "mentor") t = ImportType::IMPORT_MENTOR; - else if (ls == "altium") t = ImportType::IMPORT_ALTIUM; - else if (ls == "ods") t = ImportType::IMPORT_ODS; - else { Print("unknown import type: " + args[2]); return; } - try { - sys->Load(args[0], args[1], t); - Module *mod = sys->modules()->get(args[0]); - Print("loaded '" + args[0] + "' from " + args[1]); - Print(" parts: " + std::to_string(mod->size())); - Print(" signals: " + std::to_string(mod->signals->size())); - } catch (const std::exception &e) { - Print(std::string("load failed: ") + e.what()); - } - } - }; - commands["search"] = { {}, [this](auto &) { - if (!sys) { Print("no system: run 'new' first."); return; } - search_modules.clear(); - for (auto &m : *sys->modules()) search_modules.push_back(m.first); - if (search_modules.empty()) { Print("no modules loaded."); return; } - search_module_idx = 0; - search_type_idx = 0; - search_query.clear(); - search_focus_idx = 0; // start with the query input focused - screen_idx = 1; - }}; -} - -void Tui::Submit() { - if (!pending.empty()) { - if (input.empty()) { Print("(empty — Esc to cancel)"); return; } - Prompt p = std::move(pending.front()); - pending.pop_front(); - Print(" " + p.question + ": " + input); - std::string answer = std::move(input); - input.clear(); - cursor_pos = 0; - history_idx = -1; - p.on_answer(answer); - return; - } - - if (input.empty()) return; - history_idx = -1; - Print("> " + input); - std::string raw = std::move(input); - input.clear(); - cursor_pos = 0; - Dispatch(raw); -} - -void Tui::Dispatch(const std::string &raw) { - auto tokens = Tokenize(raw); - if (tokens.empty()) return; - - auto it = commands.find(tokens[0]); - if (it == commands.end()) { - Print("unknown command: " + tokens[0]); - history.push_back(raw); - AppendHistory(raw); - return; - } - - const std::string name = it->first; - const CommandSpec &spec = it->second; - - if (tokens.size() - 1 > spec.params.size()) { - Print("too many arguments for '" + name + "'"); - history.push_back(raw); - AppendHistory(raw); - return; - } - - auto args = std::make_shared>( - tokens.begin() + 1, tokens.end()); - - if (args->size() == spec.params.size()) { - Finalize(name, spec, *args); - return; - } - - for (size_t i = args->size(); i < spec.params.size(); ++i) { - bool last = (i + 1 == spec.params.size()); - const auto ¶m = spec.params[i]; - pending.push_back({ - param.name, - [this, name, &spec, args, last](const std::string &s) { - args->push_back(s); - if (last) Finalize(name, spec, *args); - }, - param.path_completion, - }); - } -} - -void Tui::Finalize(const std::string &name, - const CommandSpec &spec, - const std::vector &args) { - std::string canonical = name; - for (const auto &a : args) { - if (a.find_first_of(" \t\"") != std::string::npos) - canonical += " \"" + a + "\""; - else - canonical += " " + a; - } - history.push_back(canonical); - AppendHistory(canonical); - spec.action(args); -} - -static std::filesystem::path HistoryPath() { - namespace fs = std::filesystem; -#ifdef _WIN32 - if (const char *p = std::getenv("LOCALAPPDATA"); p && *p) - return fs::path(p) / "essim" / "history"; - if (const char *p = std::getenv("APPDATA"); p && *p) - return fs::path(p) / "essim" / "history"; - if (const char *p = std::getenv("USERPROFILE"); p && *p) - return fs::path(p) / "AppData" / "Local" / "essim" / "history"; -#else - if (const char *p = std::getenv("XDG_DATA_HOME"); p && *p) - return fs::path(p) / "essim" / "history"; - if (const char *p = std::getenv("HOME"); p && *p) - return fs::path(p) / ".local" / "share" / "essim" / "history"; -#endif - return {}; -} - -void Tui::LoadHistory() { - auto p = HistoryPath(); - if (p.empty()) return; - std::ifstream f(p); - std::string line; - while (std::getline(f, line)) - if (!line.empty()) history.push_back(line); -} - -void Tui::AppendHistory(const std::string &cmd) { - auto p = HistoryPath(); - if (p.empty()) return; - std::string trimmed = cmd; - while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back())) - trimmed.pop_back(); - if (trimmed.empty()) return; - std::error_code ec; - std::filesystem::create_directories(p.parent_path(), ec); - if (ec) return; - std::ofstream f(p, std::ios::app); - if (f) f << trimmed << '\n'; -} - -static std::string LongestCommonPrefix(const std::vector &v) { - if (v.empty()) return ""; - std::string lcp = v[0]; - for (size_t i = 1; i < v.size(); ++i) { - size_t k = 0; - while (k < lcp.size() && k < v[i].size() && lcp[k] == v[i][k]) ++k; - lcp.resize(k); - } - return lcp; -} - -void Tui::CompleteCommand() { - std::vector matches; - for (const auto &kv : commands) - if (kv.first.rfind(input, 0) == 0) matches.push_back(kv.first); - - if (matches.empty()) return; - if (matches.size() == 1) { input = matches[0]; cursor_pos = (int)input.size(); return; } - - std::string lcp = LongestCommonPrefix(matches); - if (lcp.size() > input.size()) { input = lcp; cursor_pos = (int)input.size(); return; } - - std::string line = " "; - for (const auto &m : matches) line += " " + m; - Print(line); -} - -void Tui::CompletePath() { - namespace fs = std::filesystem; - - auto pos = input.rfind('/'); - std::string disp, prefix; - if (pos == std::string::npos) { disp = ""; prefix = input; } - else { disp = input.substr(0, pos + 1); prefix = input.substr(pos + 1); } - - std::string resolved = disp.empty() ? "." : disp; - if (!resolved.empty() && resolved[0] == '~') { - if (const char *home = std::getenv("HOME")) - resolved = std::string(home) + resolved.substr(1); - } - - std::vector names; - std::vector is_dir; - try { - for (const auto &e : fs::directory_iterator(resolved)) { - std::string n = e.path().filename().string(); - if (n.rfind(prefix, 0) == 0) { - names.push_back(n); - is_dir.push_back(e.is_directory()); - } - } - } catch (const std::exception &) { - return; - } - if (names.empty()) return; - - if (names.size() == 1) { - input = disp + names[0] + (is_dir[0] ? "/" : ""); - cursor_pos = (int)input.size(); - return; - } - - std::string lcp = LongestCommonPrefix(names); - if (lcp.size() > prefix.size()) { input = disp + lcp; cursor_pos = (int)input.size(); return; } - - std::string line = " "; - for (size_t i = 0; i < names.size(); ++i) - line += " " + names[i] + (is_dir[i] ? "/" : ""); - Print(line); -} - void Tui::Run() { auto screen = ScreenInteractive::Fullscreen(); - // ---- Main TUI ---- - InputOption opt; - opt.multiline = false; - opt.cursor_position = &cursor_pos; - opt.on_enter = [this] { Submit(); }; - opt.transform = [](InputState s) { - auto el = s.element; - if (s.is_placeholder) el |= dim; - return el; - }; - auto input_component = Input(&input, "type a command…", opt); + auto main_screen = BuildMainScreen(screen); + auto search_screen = BuildSearchScreen(); + auto connect_screen = BuildConnectScreen(); + auto settype_screen = BuildSettypeScreen(); + auto explore_screen = BuildExploreScreen(); - auto main_renderer = Renderer(input_component, [this, &screen, input_component] { - if (quit) screen.Exit(); - - Elements lines; - for (const auto &l : output) lines.push_back(text(l)); - auto view = vbox(std::move(lines)) - | focusPositionRelative(0, 1) - | yframe - | flex; - - std::string label = pending.empty() - ? "> " - : pending.front().question + "? "; - - return vbox({ - view, - separator(), - hbox({text(label), input_component->Render()}), - }) | border; - }); - - // ---- Search screen ---- - InputOption query_opt; - query_opt.multiline = false; - query_opt.transform = opt.transform; - auto query_input = Input(&search_query, "filter…", query_opt); - auto module_menu = Menu(&search_modules, &search_module_idx); - auto type_menu = Menu(&search_types, &search_type_idx); - - auto search_components = Container::Vertical( - {query_input, module_menu, type_menu}, &search_focus_idx); - - auto search_renderer = Renderer(search_components, - [this, query_input, module_menu, type_menu] { - // Compute filtered list. - Elements result_lines; - int total = 0; - if (!search_modules.empty() && sys) { - const std::string &mname = search_modules[search_module_idx]; - try { - Module *mod = sys->modules()->get(mname); - std::string needle = ToLower(search_query); - if (search_type_idx == 0) { // parts - for (auto &pkv : *mod) { - if (needle.empty() - || ToLower(pkv.first).find(needle) != std::string::npos) { - result_lines.push_back( - text(" " + pkv.first - + " (" + std::to_string(pkv.second->size()) + " pins)")); - ++total; - } - } - } else { // signals - for (auto &skv : *mod->signals) { - if (needle.empty() - || ToLower(skv.first).find(needle) != std::string::npos) { - result_lines.push_back( - text(" " + skv.first - + " (" + std::to_string(skv.second->size()) + " pins)")); - ++total; - } - } - } - } catch (const std::exception &) {} - } - - auto left = vbox({ - text("module") | bold, - module_menu->Render() | yframe | flex, - separator(), - text("type") | bold, - type_menu->Render(), - }) | size(WIDTH, EQUAL, 28); - - auto right = vbox({ - hbox({text(" search: "), query_input->Render() | flex}) | border, - text(std::to_string(total) + " match(es)") | dim, - vbox(std::move(result_lines)) | yframe | flex, - }) | flex; - - return vbox({ - hbox({left, separator(), right}) | flex, - text(" Tab: cycle focus | Esc: leave search ") | dim, - }) | border; - }); - - // ---- Screen tab + global key handling ---- - auto tab = Container::Tab({main_renderer, search_renderer}, &screen_idx); + auto tab = Container::Tab( + {main_screen, search_screen, connect_screen, settype_screen, explore_screen}, + &screen_idx); auto root = CatchEvent(tab, [this](Event e) { - if (screen_idx == 1) { - // Search mode + switch (screen_idx) { + case 4: // explore if (e == Event::Escape) { screen_idx = 0; return true; } - // Cycle focus query → modules → type → query (Menu eats Tab otherwise). - if (e == Event::Tab) { - search_focus_idx = (search_focus_idx + 1) % 3; - return true; - } - if (e == Event::TabReverse) { - search_focus_idx = (search_focus_idx + 2) % 3; - return true; - } - return false; // let menus / input handle the rest - } + 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; } + return false; - // Main mode - if (e == Event::Escape && !pending.empty()) { CancelPending(); return true; } - if (e == Event::ArrowUp || e == Event::ArrowDown) { - if (pending.empty()) { - if (e == Event::ArrowUp) HistoryUp(); - else HistoryDown(); + case 3: // set-type + if (e == Event::Escape) { screen_idx = 0; return true; } + if (e == Event::Tab) { settype_focus_idx = (settype_focus_idx + 1) % 5; return true; } + if (e == Event::TabReverse) { settype_focus_idx = (settype_focus_idx + 4) % 5; return true; } + return false; + + case 2: // connect + if (e == Event::Escape) { screen_idx = 0; return true; } + if (e == Event::Tab) { connect_focus_idx = (connect_focus_idx + 1) % 7; return true; } + if (e == Event::TabReverse) { connect_focus_idx = (connect_focus_idx + 6) % 7; return true; } + return false; + + case 1: // search + if (e == Event::Escape) { screen_idx = 0; return true; } + if (e == Event::Tab) { search_focus_idx = (search_focus_idx + 1) % 3; return true; } + if (e == Event::TabReverse) { search_focus_idx = (search_focus_idx + 2) % 3; return true; } + return false; + + default: // main + if (e == Event::Escape && !pending.empty()) { CancelPending(); return true; } + if (e == Event::ArrowUp || e == Event::ArrowDown) { + if (pending.empty()) { + if (e == Event::ArrowUp) HistoryUp(); + else HistoryDown(); + } + return true; } - return true; - } - if (e == Event::Tab) { - if (pending.empty()) { - if (input.find(' ') == std::string::npos) CompleteCommand(); - } else if (pending.front().path_completion) { - CompletePath(); + if (e == Event::Tab) { + if (pending.empty()) { + CompleteInline(); + } else { + switch (pending.front().completion) { + case Completion::Path: CompletePath(); break; + case Completion::Command: CompleteCommand(); break; + case Completion::None: break; + } + } + return true; } - return true; + return false; } - return false; }); screen.Loop(root); diff --git a/src/tui/tui.hpp b/src/tui/tui.hpp index 0af9a33..2fcfc9c 100644 --- a/src/tui/tui.hpp +++ b/src/tui/tui.hpp @@ -8,36 +8,50 @@ #include #include +#include +#include + class System; class Tui { + enum class Completion { None, Path, Command }; + struct Prompt { std::string question; std::function on_answer; - bool path_completion = false; + Completion completion = Completion::None; }; struct CommandSpec { struct Param { std::string name; - bool path_completion = false; + Completion completion = Completion::None; }; std::vector params; std::function &)> action; + bool prompt_for_missing = true; + std::string description; + bool scriptable = true; }; + // ---- Shell state ---- std::vector history; + std::vector recorded; // commands since the last 'new', for script-save std::vector output; std::string input; int cursor_pos; int history_idx; bool quit; + bool in_source; std::unique_ptr sys; std::deque pending; std::map commands; + // ---- Screen orchestration ---- int screen_idx; + + // ---- Search screen state ---- std::vector search_modules; std::vector search_types; int search_module_idx; @@ -45,31 +59,84 @@ class Tui { int search_focus_idx; std::string search_query; + // ---- Connect screen state ---- + std::vector connect_modules; + int connect_m1_idx; + int connect_m2_idx; + std::string connect_p1_filter; + std::string connect_p2_filter; + std::vector connect_p1_list; + std::vector connect_p2_list; + int connect_p1_idx; + int connect_p2_idx; + int connect_focus_idx; + + // ---- Explore screen state ---- + std::vector explore_modules; + int explore_module_idx; + std::vector explore_types; + int explore_type_idx; + std::vector explore_children; + int explore_child_idx; + std::string explore_child_filter; + std::string explore_detail_filter; + std::vector explore_detail; + int explore_detail_idx; + std::string explore_header; + int explore_focus_idx; + + // ---- Set-type screen state ---- + std::vector settype_modules; + int settype_m_idx; + std::string settype_p_filter; + std::vector settype_p_list; + int settype_p_idx; + std::string settype_type; + std::string settype_status; + int settype_focus_idx; + public: Tui(); ~Tui(); void Run(); private: + // Lifecycle (commands.cpp) void RegisterCommands(); + + // Shell (shell.cpp) + void Print(const std::string &line); void Submit(); void Dispatch(const std::string &raw); void Finalize(const std::string &name, const CommandSpec &spec, const std::vector &args); - void HistoryUp(); void HistoryDown(); void CancelPending(); - void Print(const std::string &line); - - void CompleteCommand(); - void CompletePath(); - void LoadHistory(); void AppendHistory(const std::string &cmd); - + void Source(const std::string &filename); static std::vector Tokenize(const std::string &s); + + // Completion (completion.cpp) + void CompleteCommand(size_t start = 0); + void CompletePath(size_t start = 0); + void CompleteInline(); + + // Filtered part list rebuild (used by connect & set-type screens) + void RefreshFilteredPartList(const std::vector &modules, + int m_idx, + const std::string &filter, + std::vector &out, + int &sel_idx); + + // Screen builders (screen_*.cpp) + ftxui::Component BuildMainScreen(ftxui::ScreenInteractive &screen); + ftxui::Component BuildSearchScreen(); + ftxui::Component BuildConnectScreen(); + ftxui::Component BuildSettypeScreen(); + ftxui::Component BuildExploreScreen(); }; #endif // _TUI_HPP_ diff --git a/src/tui/tui_helpers.cpp b/src/tui/tui_helpers.cpp new file mode 100644 index 0000000..292b6b7 --- /dev/null +++ b/src/tui/tui_helpers.cpp @@ -0,0 +1,54 @@ +#include "tui/tui_helpers.hpp" + +#include +#include + +std::string ToLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return std::tolower(c); }); + return s; +} + +bool NaturalLess(const std::string &a, const std::string &b) { + size_t i = 0, j = 0; + while (i < a.size() && j < b.size()) { + unsigned char ca = (unsigned char)a[i]; + unsigned char cb = (unsigned char)b[j]; + if (std::isdigit(ca) && std::isdigit(cb)) { + size_t za = 0, zb = 0; + while (i + za < a.size() && a[i + za] == '0') ++za; + while (j + zb < b.size() && b[j + zb] == '0') ++zb; + size_t ea = i + za; + while (ea < a.size() && std::isdigit((unsigned char)a[ea])) ++ea; + size_t eb = j + zb; + while (eb < b.size() && std::isdigit((unsigned char)b[eb])) ++eb; + size_t la = ea - (i + za); + size_t lb = eb - (j + zb); + if (la != lb) return la < lb; + int cmp = a.compare(i + za, la, b, j + zb, lb); + if (cmp != 0) return cmp < 0; + if (za != zb) return za > zb; + i = ea; + j = eb; + } else { + char la = (char)std::tolower(ca); + char lb = (char)std::tolower(cb); + if (la != lb) return la < lb; + ++i; ++j; + } + } + if (i < a.size()) return false; + if (j < b.size()) return true; + return false; +} + +std::string LongestCommonPrefix(const std::vector &v) { + if (v.empty()) return ""; + std::string lcp = v[0]; + for (size_t i = 1; i < v.size(); ++i) { + size_t k = 0; + while (k < lcp.size() && k < v[i].size() && lcp[k] == v[i][k]) ++k; + lcp.resize(k); + } + return lcp; +} diff --git a/src/tui/tui_helpers.hpp b/src/tui/tui_helpers.hpp new file mode 100644 index 0000000..869c3d9 --- /dev/null +++ b/src/tui/tui_helpers.hpp @@ -0,0 +1,17 @@ +#ifndef _TUI_HELPERS_HPP_ +#define _TUI_HELPERS_HPP_ + +#include +#include + +// Free helpers shared across the TUI translation units. + +std::string ToLower(std::string s); + +// Case-insensitive natural-order comparison: digit runs compared as integers, +// letters compared after std::tolower. +bool NaturalLess(const std::string &a, const std::string &b); + +std::string LongestCommonPrefix(const std::vector &v); + +#endif // _TUI_HELPERS_HPP_