Add a wxWidgets GUI frontend (second frontend, proves the split).

A native wx GUI built entirely on essim_core via the Frontend interface — no
Tui reuse, no command shell. Demonstrates the core/frontends architecture by
adding a real second frontend:

  - WxFrontend : public Frontend — owns the System + a console buffer; handles
    boot headlessly (restore for --restore/--batch; honest note for source);
    Run() boots wx without a generated main (SetInstance + wxEntryStart/
    CallOnInit/OnRun) so the shared frontend_main stays in control.
  - EssimFrame (wxFrame) — menu-driven window: Load (app::load_module), Restore/
    Save (persist), Export (app::export_connections), Verify (app::verify),
    rendered into a model tree (modules → parts), an overview + verify-health
    panel, and a log. Each handler is a thin wrapper over a core/app op.
  - main.cpp: construct WxFrontend, call frontend_main — same 4 lines as tui.
  - CMakeLists.txt: find_package(wxWidgets) + essim_add_frontend(wx LIBS …);
    select with -DESSIM_FRONTEND=wx. ESSIM_FRONTEND gains wx in its STRINGS.

Set LC_CTYPE from the environment at GUI init so wxString decodes the UTF-8 in
narrow literals (em dash, ellipsis); LC_NUMERIC stays "C". .gitignore: build*/.

Needs libwxgtk3.2-dev. Verified: builds clean (wx 3.2.9 GTK3), window opens and
renders with no wx asserts; --commands-md/--restore/--batch behave headlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:01:02 +02:00
parent e561c0f960
commit 4803d7d01c
8 changed files with 493 additions and 2 deletions

2
.gitignore vendored
View File

@@ -1 +1 @@
build/ build*/

View File

@@ -48,7 +48,7 @@ target_link_libraries(essim_core
# src/frontends/gui/ and configure with -DESSIM_FRONTEND=gui. # src/frontends/gui/ and configure with -DESSIM_FRONTEND=gui.
set(ESSIM_FRONTEND "tui" CACHE STRING set(ESSIM_FRONTEND "tui" CACHE STRING
"Frontend to build: a directory name under src/frontends/, or 'none'") "Frontend to build: a directory name under src/frontends/, or 'none'")
set_property(CACHE ESSIM_FRONTEND PROPERTY STRINGS tui none) set_property(CACHE ESSIM_FRONTEND PROPERTY STRINGS tui wx none)
if(ESSIM_FRONTEND STREQUAL "none") if(ESSIM_FRONTEND STREQUAL "none")
message(STATUS "essim: ESSIM_FRONTEND=none — core + tests only (no frontend, no GUI toolkit)") message(STATUS "essim: ESSIM_FRONTEND=none — core + tests only (no frontend, no GUI toolkit)")

View File

@@ -0,0 +1,16 @@
# wxWidgets GUI frontend. Builds the `essim` executable against essim_core.
#
# Self-contained like every frontend: it pulls its own GUI toolkit (here a
# system wxWidgets via find_package), then defers the target wiring to the
# shared essim_add_frontend() helper. Select it with -DESSIM_FRONTEND=wx.
#
# Needs the wxWidgets development package, e.g. on Debian/Ubuntu:
# sudo apt install libwxgtk3.2-dev
find_package(wxWidgets REQUIRED COMPONENTS core base)
# UsewxWidgets sets the include dirs and compile definitions for targets defined
# afterwards in this directory — so it must come before essim_add_frontend().
include(${wxWidgets_USE_FILE})
essim_add_frontend(wx LIBS ${wxWidgets_LIBRARIES})

10
src/frontends/wx/main.cpp Normal file
View File

@@ -0,0 +1,10 @@
#include "frontends/frontend_main.hpp"
#include "frontends/wx/wx_frontend.hpp"
// The wx frontend's entry point: construct the concrete Frontend (WxFrontend)
// and hand off to the shared, frontend-agnostic launcher. Identical in shape to
// the tui frontend's main — only the Frontend type differs.
int main(int argc, char **argv) {
WxFrontend fe;
return frontend_main(argc, argv, fe);
}

View File

@@ -0,0 +1,285 @@
#include "frontends/wx/wx_frame.hpp"
#include "frontends/wx/wx_frontend.hpp"
#include "core/app/export.hpp"
#include "core/app/load.hpp"
#include "core/app/verify.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/signal_type.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include <wx/wx.h>
#include <wx/choicdlg.h>
#include <wx/filedlg.h>
#include <wx/filename.h>
#include <wx/textdlg.h>
#include <wx/treectrl.h>
#include <algorithm>
#include <string>
#include <vector>
namespace {
enum {
ID_LOAD = wxID_HIGHEST + 1,
ID_RESTORE,
ID_SAVE,
ID_EXPORT,
ID_VERIFY,
ID_QUIT,
ID_ABOUT,
};
// Core (UTF-8 std::string) -> wxString, and back for paths.
inline wxString wx(const std::string &s) { return wxString::FromUTF8(s.c_str()); }
} // namespace
EssimFrame::EssimFrame(WxFrontend &fe)
: wxFrame(nullptr, wxID_ANY, "essim — system digital twin",
wxDefaultPosition, wxSize(960, 640)),
fe_(fe) {
auto *file = new wxMenu;
file->Append(ID_LOAD, "&Load module…\tCtrl-L");
file->Append(ID_RESTORE, "&Restore snapshot…\tCtrl-R");
file->Append(ID_SAVE, "&Save snapshot…\tCtrl-S");
file->AppendSeparator();
file->Append(ID_EXPORT, "&Export connections…\tCtrl-E");
file->AppendSeparator();
file->Append(ID_QUIT, "&Quit\tCtrl-Q");
auto *sysm = new wxMenu;
sysm->Append(ID_VERIFY, "&Verify\tCtrl-K");
auto *help = new wxMenu;
help->Append(ID_ABOUT, "&About");
auto *bar = new wxMenuBar;
bar->Append(file, "&File");
bar->Append(sysm, "&System");
bar->Append(help, "&Help");
SetMenuBar(bar);
CreateStatusBar();
SetStatusText("essim — wx frontend");
auto *panel = new wxPanel(this);
tree_ = new wxTreeCtrl(panel, wxID_ANY);
overview_ = new wxTextCtrl(panel, wxID_ANY, "", wxDefaultPosition,
wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY);
log_ = new wxTextCtrl(panel, wxID_ANY, "", wxDefaultPosition,
wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY);
wxFont mono(wxFontInfo().Family(wxFONTFAMILY_TELETYPE));
overview_->SetFont(mono);
log_->SetFont(mono);
auto *top = new wxBoxSizer(wxHORIZONTAL);
top->Add(tree_, 1, wxEXPAND | wxALL, 4);
top->Add(overview_, 1, wxEXPAND | wxALL, 4);
auto *root = new wxBoxSizer(wxVERTICAL);
root->Add(top, 2, wxEXPAND);
root->Add(new wxStaticText(panel, wxID_ANY, " Log"), 0, wxLEFT | wxTOP, 6);
root->Add(log_, 1, wxEXPAND | wxALL, 4);
panel->SetSizer(root);
Bind(wxEVT_MENU, &EssimFrame::OnLoad, this, ID_LOAD);
Bind(wxEVT_MENU, &EssimFrame::OnRestore, this, ID_RESTORE);
Bind(wxEVT_MENU, &EssimFrame::OnSave, this, ID_SAVE);
Bind(wxEVT_MENU, &EssimFrame::OnExport, this, ID_EXPORT);
Bind(wxEVT_MENU, &EssimFrame::OnVerify, this, ID_VERIFY);
Bind(wxEVT_MENU, &EssimFrame::OnQuit, this, ID_QUIT);
Bind(wxEVT_MENU, &EssimFrame::OnAbout, this, ID_ABOUT);
RebuildModelView();
}
void EssimFrame::Log(const wxString &line) {
log_->AppendText(line + "\n");
}
void EssimFrame::RebuildModelView() {
System *sys = fe_.system();
tree_->DeleteAllItems();
wxTreeItemId root = tree_->AddRoot("System");
int n_mods = 0, n_parts = 0, n_sigs = 0;
if (sys) {
std::vector<std::string> mods;
for (auto &mkv : *sys->modules()) mods.push_back(mkv.first);
std::sort(mods.begin(), mods.end());
n_mods = (int)mods.size();
for (const auto &mname : mods) {
Module *m = sys->modules()->get(mname);
int mp = (int)m->size();
int ms = (int)m->signals->size();
n_parts += mp;
n_sigs += ms;
wxTreeItemId mid = tree_->AppendItem(
root, wx(mname) + wxString::Format(" — %d part(s), %d signal(s)",
mp, ms));
std::vector<std::string> parts;
for (auto &pkv : *m) parts.push_back(pkv.first);
std::sort(parts.begin(), parts.end());
for (const auto &pname : parts) {
Part *p = m->get(pname);
wxString label = wx(pname)
+ wxString::Format(" (%d pin(s))", (int)p->size());
if (!p->connector_type.empty())
label += " [" + wx(p->connector_type) + "]";
tree_->AppendItem(mid, label);
}
}
}
tree_->Expand(root);
int n_conn = sys ? (int)sys->connections()->size() : 0;
wxString ov;
ov << "Modules: " << n_mods << "\n"
<< "Parts: " << n_parts << "\n"
<< "Signals: " << n_sigs << "\n"
<< "Connections: " << n_conn << "\n";
if (sys) {
app::VerifyReport r = app::verify(sys);
ov << "\nHealth (verify):\n"
<< wxString::Format(" pin-role mismatches: %d / %d typed pin(s)\n",
(int)r.role_mismatches.size(), r.typed_pins)
<< wxString::Format(" net inconsistencies: %d / %d bridged net(s)\n",
(int)r.net_inconsistencies.size(), r.bridged_nets)
<< wxString::Format(" orphan pins: %d (%d imported, %d dropped)\n",
r.orphan_total(), r.orphan_imported, r.orphan_dropped)
<< wxString::Format(" model anomalies: %d\n", r.model_total());
}
overview_->SetValue(ov);
}
void EssimFrame::OnLoad(wxCommandEvent &) {
wxFileDialog dlg(this, "Load a netlist / pinout file", "", "",
"All files (*.*)|*.*", wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK) return;
const wxString path = dlg.GetPath();
wxString modname = wxGetTextFromUser("Module name:", "Load module",
wxFileName(path).GetName(), this);
if (modname.empty()) return;
static const wxString kinds[] = {"mentor", "altium", "ods"};
int ki = wxGetSingleChoiceIndex("Import type:", "Load module",
WXSIZEOF(kinds), kinds, this);
if (ki < 0) return;
ImportType type;
app::import_type_from_name(kinds[ki].ToStdString(), type); // choice is valid
app::LoadResult r = app::load_module(
fe_.system(), modname.utf8_string(), path.utf8_string(), type);
if (!r.ok) {
Log("load failed: " + wx(r.error));
wxMessageBox(wx(r.error), "Load failed", wxOK | wxICON_ERROR, this);
return;
}
Log(wxString::Format(
"loaded '%s' from %s — %d part(s), %d signal(s)"
" (dropped %d; types: %d power / %d gnd / %d suspect)",
modname, path, r.parts, r.signals, r.dropped, r.power, r.gnd,
r.kept_other));
RebuildModelView();
}
void EssimFrame::OnRestore(wxCommandEvent &) {
wxFileDialog dlg(this, "Restore a system snapshot", "", "",
"essim snapshots (*.essim)|*.essim|All files (*.*)|*.*",
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK) return;
std::string err;
System *fresh = restore_system(dlg.GetPath().utf8_string(), err);
if (!fresh) {
Log("restore failed: " + wx(err));
wxMessageBox(wx(err), "Restore failed", wxOK | wxICON_ERROR, this);
return;
}
fe_.set_system(fresh);
Log("restored from " + dlg.GetPath());
RebuildModelView();
}
void EssimFrame::OnSave(wxCommandEvent &) {
wxFileDialog dlg(this, "Save system snapshot", "", "system.essim",
"essim snapshots (*.essim)|*.essim|All files (*.*)|*.*",
wxFD_SAVE | wxFD_OVERWRITE_PROMPT);
if (dlg.ShowModal() != wxID_OK) return;
std::string err;
if (save_system(fe_.system(), dlg.GetPath().utf8_string(), err)) {
Log("saved to " + dlg.GetPath());
} else {
Log("save failed: " + wx(err));
wxMessageBox(wx(err), "Save failed", wxOK | wxICON_ERROR, this);
}
}
void EssimFrame::OnExport(wxCommandEvent &) {
wxFileDialog dlg(this, "Export connections", "", "connections.csv",
"CSV (*.csv)|*.csv|OpenDocument sheet (*.ods)|*.ods",
wxFD_SAVE | wxFD_OVERWRITE_PROMPT);
if (dlg.ShowModal() != wxID_OK) return;
const std::string path = dlg.GetPath().utf8_string();
app::ExportFormat fmt;
if (!app::export_format_from_path(path, fmt)) {
wxMessageBox("Unknown export extension (use .csv or .ods).",
"Export failed", wxOK | wxICON_ERROR, this);
return;
}
app::ExportResult r = app::export_connections(fe_.system(), path, fmt);
if (r.ok) {
Log(wxString::Format("exported %d row(s) to %s", r.rows, dlg.GetPath()));
} else {
Log("export failed: " + wx(r.error));
wxMessageBox(wx(r.error), "Export failed", wxOK | wxICON_ERROR, this);
}
}
void EssimFrame::OnVerify(wxCommandEvent &) {
app::VerifyReport r = app::verify(fe_.system());
Log("verify:");
Log(wxString::Format(" %d pin-role mismatch(es) over %d typed pin(s)",
(int)r.role_mismatches.size(), r.typed_pins));
for (const auto &m : r.role_mismatches)
Log(wx(" " + m.module + "/" + m.part + "/" + m.pin + ": expected "
+ signal_type_name(m.expected) + ", got "
+ signal_type_name(m.actual)));
Log(wxString::Format(" %d inconsistent net(s) over %d bridged net(s)",
(int)r.net_inconsistencies.size(), r.bridged_nets));
Log(wxString::Format(" %d orphan pin(s) (%d imported, %d dropped)",
r.orphan_total(), r.orphan_imported, r.orphan_dropped));
auto log_anoms = [this](const std::vector<Anomaly> &v, const char *tail) {
Log(wxString::Format(" %d %s", (int)v.size(), tail));
for (const auto &a : v)
Log(wx(std::string(" [") + anomaly_kind_name(a.kind) + "] "
+ a.message));
};
log_anoms(r.pin_anomalies, "model-driven pin anomaly(ies)");
log_anoms(r.jtag_anomalies, "JTAG chain anomaly(ies)");
log_anoms(r.conflict_anomalies, "source-conflict(s)");
log_anoms(r.completeness_anomalies, "BSDL completeness issue(s)");
RebuildModelView();
}
void EssimFrame::OnQuit(wxCommandEvent &) { Close(true); }
void EssimFrame::OnAbout(wxCommandEvent &) {
wxMessageBox("essim — system digital twin\n\n"
"wxWidgets frontend over essim_core.",
"About essim", wxOK | wxICON_INFORMATION, this);
}

View File

@@ -0,0 +1,38 @@
#ifndef _WX_FRAME_HPP_
#define _WX_FRAME_HPP_
#include <wx/frame.h>
class WxFrontend;
class wxTreeCtrl;
class wxTextCtrl;
class wxCommandEvent;
// The essim main window. Holds no domain state of its own: it reads and mutates
// the System owned by the WxFrontend, calling the core/app operations directly
// (load, verify, export, save, restore) and rendering their results into a
// model tree, an overview panel and a log.
class EssimFrame : public wxFrame {
public:
explicit EssimFrame(WxFrontend &fe);
private:
// Menu handlers — each is a thin wrapper over a core/app operation.
void OnLoad(wxCommandEvent &);
void OnRestore(wxCommandEvent &);
void OnSave(wxCommandEvent &);
void OnExport(wxCommandEvent &);
void OnVerify(wxCommandEvent &);
void OnQuit(wxCommandEvent &);
void OnAbout(wxCommandEvent &);
void RebuildModelView(); ///< refresh tree + overview from the System
void Log(const wxString &line); ///< append a line to the log pane
WxFrontend &fe_;
wxTreeCtrl *tree_ = nullptr;
wxTextCtrl *overview_ = nullptr;
wxTextCtrl *log_ = nullptr;
};
#endif // _WX_FRAME_HPP_

View File

@@ -0,0 +1,105 @@
#include "frontends/wx/wx_frontend.hpp"
#include "frontends/wx/wx_frame.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/system.hpp"
#include <wx/app.h>
#include <wx/init.h>
#include <clocale>
#include <ostream>
#include <sstream>
#include <string>
namespace {
// Minimal wxApp: on init it shows the main window bound to the frontend.
class EssimApp : public wxApp {
public:
explicit EssimApp(WxFrontend &fe) : fe_(fe) {}
bool OnInit() override {
// Decode the UTF-8 in our narrow string literals (em dash, ellipsis…)
// correctly: wxString converts const char* via the C locale, which is
// "C" (ASCII) at startup. Set only LC_CTYPE — leave LC_NUMERIC as "C"
// so number formatting stays dot-decimal.
std::setlocale(LC_CTYPE, "");
(new EssimFrame(fe_))->Show(true);
return true;
}
private:
WxFrontend &fe_;
};
} // namespace
WxFrontend::WxFrontend() = default;
WxFrontend::~WxFrontend() = default;
void WxFrontend::ensure_system() {
if (!sys_) sys_.reset(new System());
}
void WxFrontend::set_system(System *fresh) {
sys_.reset(fresh);
}
void WxFrontend::BootDispatch(const std::string &raw) {
// The GUI has no command shell. Honour the boot commands that make sense
// headlessly: `restore <file>` seeds a snapshot; anything else is noted.
std::istringstream iss(raw);
std::string cmd;
iss >> cmd;
std::string arg;
std::getline(iss, arg);
if (std::size_t b = arg.find_first_not_of(" \t"); b != std::string::npos)
arg = arg.substr(b);
else
arg.clear();
if (cmd == "restore") {
std::string err;
System *fresh = restore_system(arg, err);
if (!fresh) {
output_ += "restore failed: " + err + "\n";
return;
}
sys_.reset(fresh);
output_ += "restored from " + arg + " ("
+ std::to_string(sys_->modules()->size()) + " module(s), "
+ std::to_string(sys_->connections()->size())
+ " connection(s))\n";
} else if (cmd == "source") {
output_ += "source: the wx frontend has no script interpreter "
"(use the tui frontend for scripts).\n";
} else if (!cmd.empty()) {
output_ += "boot: ignored '" + raw + "'.\n";
}
}
void WxFrontend::DumpCommandsMd(std::ostream &out) const {
out << "# essim — wx frontend\n\n"
<< "The wx frontend is menu-driven and exposes no textual command "
<< "registry. Generate the command reference from the tui frontend "
<< "(`-DESSIM_FRONTEND=tui`, then `essim --commands-md`).\n";
}
void WxFrontend::DumpOutput(std::ostream &out) const {
out << output_;
}
void WxFrontend::Run() {
ensure_system();
wxApp::SetInstance(new EssimApp(*this));
int argc = 0;
wxEntryStart(argc, static_cast<char **>(nullptr));
if (wxTheApp->CallOnInit())
wxTheApp->OnRun();
wxTheApp->OnExit();
wxEntryCleanup();
}

View File

@@ -0,0 +1,37 @@
#ifndef _WX_FRONTEND_HPP_
#define _WX_FRONTEND_HPP_
#include "frontends/frontend.hpp"
#include <memory>
#include <string>
class System;
// wxWidgets GUI frontend. Implements the shared Frontend interface so the same
// launcher (frontend_main) drives it: it owns the System and a console buffer,
// handles boot commands headlessly (for --restore/--batch), and Run() opens the
// wxWidgets window. The window itself (EssimFrame) drives essim_core / app::*
// operations directly — no command shell, no TUI reuse.
class WxFrontend : public Frontend {
public:
WxFrontend();
~WxFrontend() override;
// --- Frontend interface ---
void BootDispatch(const std::string &raw) override;
void DumpCommandsMd(std::ostream &out) const override;
void DumpOutput(std::ostream &out) const override;
void Run() override;
// --- used by the window (EssimFrame) ---
System *system() const { return sys_.get(); }
void set_system(System *fresh); ///< take ownership (used by Restore)
void ensure_system(); ///< create an empty System if none yet
private:
std::unique_ptr<System> sys_;
std::string output_; ///< console buffer surfaced by DumpOutput (batch)
};
#endif // _WX_FRONTEND_HPP_