Files
essim/src/tui/tui.cpp
François 3395469810 TUI shell + ftxui via FetchContent.
- 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>
2026-05-08 10:17:03 +02:00

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 &param = 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);
}