- ftxui v6.1.9 fetched at configure time; vendored .a + headers dropped. - TUI: visualisation area on top, input prompt at the bottom; ↑/↓ history, Tab completion (commands + file paths), Esc cancels prompts. History is persisted (XDG on Linux, %LOCALAPPDATA% on Windows when ported). - Command registry: `new`, `load`, `search`, `clear`, `help`, `quit`/`exit`. Inline params or interactive prompts; either way the canonical inline form is what gets stored in history. - `search`: second full-screen mode with module + parts/signals menus and a live-filtered list; Tab cycles focus, Esc returns to the main shell. - Domain: `System::modules()` accessor + `SystemElementContainer::size()` to support load summary + search. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
483 lines
16 KiB
C++
483 lines
16 KiB
C++
#include "tui/tui.hpp"
|
|
|
|
#include "system/modules.hpp"
|
|
#include "system/parts.hpp"
|
|
#include "system/signals.hpp"
|
|
#include "system/system.hpp"
|
|
|
|
#include <ftxui/component/component.hpp>
|
|
#include <ftxui/component/screen_interactive.hpp>
|
|
#include <ftxui/dom/elements.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdlib>
|
|
#include <exception>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <system_error>
|
|
|
|
using namespace ftxui;
|
|
|
|
Tui::Tui()
|
|
: cursor_pos(0), history_idx(-1), quit(false),
|
|
screen_idx(0),
|
|
search_types{"parts", "signals"},
|
|
search_module_idx(0), search_type_idx(0), search_focus_idx(0)
|
|
{
|
|
LoadHistory();
|
|
RegisterCommands();
|
|
Print("essim — type 'help' for commands, 'quit' to exit.");
|
|
}
|
|
|
|
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<std::string> Tui::Tokenize(const std::string &s) {
|
|
std::vector<std::string> 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 <module> <file> <mentor|altium|ods> 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<System>();
|
|
Print("system created.");
|
|
}};
|
|
commands["load"] = {
|
|
{{"module name", false},
|
|
{"filename", true},
|
|
{"import type [mentor|altium|ods]", false}},
|
|
[this](const std::vector<std::string> &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<std::vector<std::string>>(
|
|
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<std::string> &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<std::string> &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<std::string> 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<std::string> names;
|
|
std::vector<bool> 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_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 root = CatchEvent(tab, [this](Event e) {
|
|
if (screen_idx == 1) {
|
|
// Search mode
|
|
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
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
return true;
|
|
}
|
|
if (e == Event::Tab) {
|
|
if (pending.empty()) {
|
|
if (input.find(' ') == std::string::npos) CompleteCommand();
|
|
} else if (pending.front().path_completion) {
|
|
CompletePath();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
screen.Loop(root);
|
|
}
|