From 982929f4b648badf2a9513f80e906191df1a7242 Mon Sep 17 00:00:00 2001 From: dhanavanthesh Date: Fri, 20 Mar 2026 13:55:03 +0530 Subject: [PATCH] feat(gui): UserTally editor Closes TODO.md line 46. - Replace // TODO !! with full UserTallyView widget - Add/remove/rename/duplicate tallies, 14 bin variables, linspace generator - Live summary, traffic-light colors, memory estimate, 6 templates - notifyDataChanged() replaces setData(idx, QVariant()) across gui views --- TODO.md | 2 +- source/gui/CMakeLists.txt | 2 + source/gui/assets/data/tally_templates.json | 77 ++ source/gui/assets/ionicons/copy-outline.svg | 1 + source/gui/materialsdefview.cpp | 32 +- source/gui/opentrim.qrc | 2 + source/gui/optionsmodel.cpp | 57 +- source/gui/optionsmodel.h | 1 + source/gui/regionsview.cpp | 16 +- source/gui/simulationoptionsview.cpp | 8 +- source/gui/simulationoptionsview.h | 2 + source/gui/usertallyview.cpp | 1171 +++++++++++++++++++ source/gui/usertallyview.h | 169 +++ source/lib/parse_json.cpp | 7 +- 14 files changed, 1499 insertions(+), 48 deletions(-) create mode 100644 source/gui/assets/data/tally_templates.json create mode 100644 source/gui/assets/ionicons/copy-outline.svg create mode 100644 source/gui/usertallyview.cpp create mode 100644 source/gui/usertallyview.h diff --git a/TODO.md b/TODO.md index 95c3878..a9cb3bd 100644 --- a/TODO.md +++ b/TODO.md @@ -43,7 +43,7 @@ ### GUI: - [ ] Better help in configuration. A foldable dedicated text browser widget to show info? -- [ ] Implement UserTally options/definition in GUI +- [X] Implement UserTally options/definition in GUI - [X] Getting Started - [X] About diff --git a/source/gui/CMakeLists.txt b/source/gui/CMakeLists.txt index fb80061..2c08469 100644 --- a/source/gui/CMakeLists.txt +++ b/source/gui/CMakeLists.txt @@ -9,6 +9,7 @@ set(GUI_HEADERS simulationoptionsview.h periodictablewidget.h materialsdefview.h + usertallyview.h optionsmodel.h floatlineedit.h mydatawidgetmapper.h @@ -31,6 +32,7 @@ set(GUI_SOURCES simulationoptionsview.cpp periodictablewidget.cpp materialsdefview.cpp + usertallyview.cpp optionsmodel.cpp floatlineedit.cpp mydatawidgetmapper.cpp diff --git a/source/gui/assets/data/tally_templates.json b/source/gui/assets/data/tally_templates.json new file mode 100644 index 0000000..5e85e93 --- /dev/null +++ b/source/gui/assets/data/tally_templates.json @@ -0,0 +1,77 @@ +[ + { + "id": "DepthProfile", + "description": "Ion implantation depth distribution along x-axis (100 bins, 0-100 nm)", + "event": "IonStop", + "bins": { "x": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, + 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, + 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0, 29.0, + 30.0, 31.0, 32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0, + 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0, 49.0, + 50.0, 51.0, 52.0, 53.0, 54.0, 55.0, 56.0, 57.0, 58.0, 59.0, + 60.0, 61.0, 62.0, 63.0, 64.0, 65.0, 66.0, 67.0, 68.0, 69.0, + 70.0, 71.0, 72.0, 73.0, 74.0, 75.0, 76.0, 77.0, 78.0, 79.0, + 80.0, 81.0, 82.0, 83.0, 84.0, 85.0, 86.0, 87.0, 88.0, 89.0, + 90.0, 91.0, 92.0, 93.0, 94.0, 95.0, 96.0, 97.0, 98.0, 99.0, 100.0] } + }, + { + "id": "AngularDistribution", + "description": "Angular distribution of exiting ions (direction cosines nx, ny)", + "event": "IonExit", + "bins": { + "nx": [-1.0, -0.9, -0.8, -0.6, -0.4, -0.2, 0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0], + "ny": [-1.0, -0.8, -0.6, -0.4, -0.2, 0.0, 0.2, 0.4, 0.6, 0.8, 1.0] + } + }, + { + "id": "EnergySpectrum", + "description": "Energy spectrum of stopped ions (logarithmic bins, 1 eV - 1 MeV)", + "event": "IonStop", + "bins": { "E": [1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, + 1000.0, 2000.0, 5000.0, 10000.0, 20000.0, 50000.0, 100000.0, + 200000.0, 500000.0, 1000000.0] } + }, + { + "id": "ImplantationMap", + "description": "2D implantation position map (x-depth vs y-lateral, 50x20 bins)", + "event": "IonStop", + "bins": { + "x": [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 18.0, + 20.0, 22.0, 24.0, 26.0, 28.0, 30.0, 32.0, 34.0, 36.0, 38.0, + 40.0, 42.0, 44.0, 46.0, 48.0, 50.0, 52.0, 54.0, 56.0, 58.0, + 60.0, 62.0, 64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0, + 80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0, 96.0, 98.0, 100.0], + "y": [-50.0, -45.0, -40.0, -35.0, -30.0, -25.0, -20.0, -15.0, -10.0, -5.0, + 0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0] + } + }, + { + "id": "VacancyDepthProfile", + "description": "Vacancy depth distribution along x-axis (100 bins, 0-100 nm)", + "event": "Vacancy", + "bins": { "x": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, + 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, + 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0, 29.0, + 30.0, 31.0, 32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0, + 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0, 49.0, + 50.0, 51.0, 52.0, 53.0, 54.0, 55.0, 56.0, 57.0, 58.0, 59.0, + 60.0, 61.0, 62.0, 63.0, 64.0, 65.0, 66.0, 67.0, 68.0, 69.0, + 70.0, 71.0, 72.0, 73.0, 74.0, 75.0, 76.0, 77.0, 78.0, 79.0, + 80.0, 81.0, 82.0, 83.0, 84.0, 85.0, 86.0, 87.0, 88.0, 89.0, + 90.0, 91.0, 92.0, 93.0, 94.0, 95.0, 96.0, 97.0, 98.0, 99.0, 100.0] } + }, + { + "id": "LateralSpread", + "description": "Cylindrical radius r vs depth x (lateral spread map, 20x50 bins)", + "event": "IonStop", + "bins": { + "r": [0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0, 17.5, 20.0, 22.5, + 25.0, 27.5, 30.0, 32.5, 35.0, 37.5, 40.0, 42.5, 45.0, 47.5, 50.0], + "x": [0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0, 18.0, + 20.0, 22.0, 24.0, 26.0, 28.0, 30.0, 32.0, 34.0, 36.0, 38.0, + 40.0, 42.0, 44.0, 46.0, 48.0, 50.0, 52.0, 54.0, 56.0, 58.0, + 60.0, 62.0, 64.0, 66.0, 68.0, 70.0, 72.0, 74.0, 76.0, 78.0, + 80.0, 82.0, 84.0, 86.0, 88.0, 90.0, 92.0, 94.0, 96.0, 98.0, 100.0] + } + } +] diff --git a/source/gui/assets/ionicons/copy-outline.svg b/source/gui/assets/ionicons/copy-outline.svg new file mode 100644 index 0000000..ef3cb11 --- /dev/null +++ b/source/gui/assets/ionicons/copy-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/source/gui/materialsdefview.cpp b/source/gui/materialsdefview.cpp index b223715..e251cd7 100644 --- a/source/gui/materialsdefview.cpp +++ b/source/gui/materialsdefview.cpp @@ -118,9 +118,7 @@ void MaterialsDefView::addMaterial() materials.push_back(newMaterial); setWidgetData(); // widgets updated cbMaterialID->setCurrentText(id); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); emit materialsChanged(); } @@ -140,9 +138,7 @@ void MaterialsDefView::editMaterialName() cbMaterialID->setItemText(i, id); material::material_desc_t &m = model_->options()->Target.materials[i]; m.id = id.toStdString(); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); } setValueData(); // update material name } @@ -160,9 +156,7 @@ void MaterialsDefView::removeMaterial() auto &materials = model_->options()->Target.materials; materials.erase(materials.begin() + i); setWidgetData(); // widgets updated - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); emit materialsChanged(); } @@ -245,9 +239,7 @@ void MaterialsDefView::setDensity(double v) QString matid = cbMaterialID->currentText(); material::material_desc_t &mat = materials[i]; mat.density = v; - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); } void MaterialsDefView::selectColor() @@ -269,9 +261,7 @@ void MaterialsDefView::selectColor() if (clr.isValid()) { mat.color = clr.name(QColor::HexArgb).toStdString(); setBtMatColor(clr); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); emit materialsChanged(); } @@ -431,9 +421,7 @@ bool MaterialCompositionModel::setData(const QModelIndex &index, const QVariant default:; } - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); return true; } @@ -451,9 +439,7 @@ bool MaterialCompositionModel::insertRows(int position, int rows, const QModelIn mat->composition.push_back(atom::parameters()); endInsertRows(); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); return true; } @@ -473,9 +459,7 @@ bool MaterialCompositionModel::removeRows(int position, int rows, const QModelIn mat->composition.erase(mat->composition.begin() + position); endRemoveRows(); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(materialsIndex_, QVariant()); + model_->notifyDataChanged(materialsIndex_); return true; } diff --git a/source/gui/opentrim.qrc b/source/gui/opentrim.qrc index bd0ff0d..a4cf7f9 100644 --- a/source/gui/opentrim.qrc +++ b/source/gui/opentrim.qrc @@ -26,6 +26,7 @@ assets/ionicons/list-outline.png assets/ionicons/bar-chart-outline.png assets/lucide/chart-line.svg + assets/data/tally_templates.json md/quick_start.md md/images/intro.png md/images/intro22.png @@ -37,6 +38,7 @@ assets/ionicons/open-outline.svg assets/3d/cubeFlat.obj assets/ionicons/create-outline.svg + assets/ionicons/copy-outline.svg examples/270keV_He_on_C.json diff --git a/source/gui/optionsmodel.cpp b/source/gui/optionsmodel.cpp index 9a5ef4a..746a032 100644 --- a/source/gui/optionsmodel.cpp +++ b/source/gui/optionsmodel.cpp @@ -67,9 +67,19 @@ QVariant OptionsItem::value() const } bool OptionsItem::setValue(const QVariant &v) { + QString vnew = v.toString(); + if (vnew.trimmed().isEmpty()) { + const QString k = key().trimmed(); + const QString jp = QString::fromStdString(jpath_); + if (k == "materials" || k == "regions" || k == "UserTally" + || jp.endsWith("/materials") || jp.endsWith("/regions") || jp.endsWith("/UserTally")) { + vnew = "[]"; + } + } + QVariant v0 = value(); - if (v0.toString() != v.toString()) { - set_(v.toString()); + if (v0.toString() != vnew) { + set_(vnew); return true; } return false; @@ -112,6 +122,21 @@ bool OptionsItem::get_(QString &qs) const bool OptionsItem::set_(const QString &qs) { std::string s = qs.toStdString(); + if (qs.trimmed().isEmpty()) { + const QString jp = QString::fromStdString(jpath_); + if (jp.endsWith("/materials") || jp.endsWith("/regions") || jp.endsWith("/UserTally")) { + s = "[]"; + } + + QString current; + if (s.empty() && get_(current)) { + const QString trimmed = current.trimmed(); + if (trimmed.startsWith('[')) + s = "[]"; + else if (trimmed.startsWith('{')) + s = "{}"; + } + } std::ostringstream os; bool ret = options_->set(jpath_, s, &os); if (!ret) { @@ -450,17 +475,25 @@ QVariant OptionsModel::data(const QModelIndex &index, int role) const } bool OptionsModel::setData(const QModelIndex &index, const QVariant &value, int role) { - // int col = index.column(); if (Qt::EditRole == role) { - // if (col == 1) { + if (!index.isValid() || index.column() != 1 || !value.isValid()) + return false; + OptionsItem *item = static_cast(index.internalPointer()); + // Base OptionsItem represents struct/array containers; these should never be edited directly. + if (typeid(*item) == typeid(OptionsItem)) + return false; + if (item->setValue(value)) emit dataChanged(index, index, { Qt::EditRole }); return true; - //} } return false; } +void OptionsModel::notifyDataChanged(const QModelIndex &index) +{ + emit dataChanged(index, index, { Qt::EditRole }); +} QVariant OptionsModel::headerData(int section, Qt::Orientation orientation, int role) const { if (role != Qt::DisplayRole) @@ -552,10 +585,16 @@ int OptionsModel::columnCount(const QModelIndex &parent) const Qt::ItemFlags OptionsModel::flags(const QModelIndex &index) const { - if (index.column() == 1) - return Qt::ItemIsEditable | QAbstractItemModel::flags(index); - else - return QAbstractItemModel::flags(index); + Qt::ItemFlags baseFlags = QAbstractItemModel::flags(index); + if (!index.isValid() || index.column() != 1) + return baseFlags; + + OptionsItem *item = static_cast(index.internalPointer()); + // Only typed option nodes are editable. Container nodes (struct/array) are read-only. + if (item && typeid(*item) != typeid(OptionsItem)) + return baseFlags | Qt::ItemIsEditable; + + return baseFlags; } OptionsItem *OptionsModel::getItem(const QModelIndex &index) const diff --git a/source/gui/optionsmodel.h b/source/gui/optionsmodel.h index 41f4c86..a3dd628 100644 --- a/source/gui/optionsmodel.h +++ b/source/gui/optionsmodel.h @@ -206,6 +206,7 @@ class OptionsModel : public QAbstractItemModel Qt::ItemFlags flags(const QModelIndex &index) const override; bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + void notifyDataChanged(const QModelIndex &index); OptionsItem *getItem(const QModelIndex &index) const; diff --git a/source/gui/regionsview.cpp b/source/gui/regionsview.cpp index d9fae3c..a234a66 100644 --- a/source/gui/regionsview.cpp +++ b/source/gui/regionsview.cpp @@ -137,9 +137,7 @@ bool RegionsModel::setData(const QModelIndex &index, const QVariant &value, int break; } - // fake setData just to let model_ know that - // underlying data changed - model_->setData(regionsIndex_, QVariant()); + model_->notifyDataChanged(regionsIndex_); emit dataChanged(index, index); @@ -161,9 +159,7 @@ bool RegionsModel::insertRows(int position, int rows, const QModelIndex &parent) opt->Target.regions.push_back(reg); endInsertRows(); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(regionsIndex_, QVariant()); + model_->notifyDataChanged(regionsIndex_); return true; } @@ -181,9 +177,7 @@ bool RegionsModel::removeRows(int position, int rows, const QModelIndex &parent) regions.erase(it); endRemoveRows(); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(regionsIndex_, QVariant()); + model_->notifyDataChanged(regionsIndex_); return true; } @@ -215,9 +209,7 @@ bool RegionsModel::moveRow(int from, int to) regions.insert(regions.begin() + to, reg); endInsertRows(); - // fake setData just to let model_ know that - // underlying data changed - model_->setData(regionsIndex_, QVariant()); + model_->notifyDataChanged(regionsIndex_); return true; } diff --git a/source/gui/simulationoptionsview.cpp b/source/gui/simulationoptionsview.cpp index 3e14ba9..4195730 100644 --- a/source/gui/simulationoptionsview.cpp +++ b/source/gui/simulationoptionsview.cpp @@ -3,6 +3,7 @@ #include "periodic_table.h" #include "periodictablewidget.h" #include "materialsdefview.h" +#include "usertallyview.h" #include "regionsview.h" #include "optionsmodel.h" #include "mydatawidgetmapper.h" @@ -72,7 +73,8 @@ SimulationOptionsView::SimulationOptionsView(MainUI *iui, QWidget *parent) else if (category == "Target") widget = createTargetTab(idx); else if (category == "UserTally") { - // TODO !! + userTallyView_ = new UserTallyView(model); + widget = userTallyView_; } else widget = createTab(idx); @@ -193,6 +195,8 @@ void SimulationOptionsView::revert() mapper->model()->setOptions(opt); mapper->revert(); materialsView->setWidgetData(); + if (userTallyView_) + userTallyView_->setWidgetData(); regionsView->revert(); jsonView->setPlainText(QString::fromStdString(ionsui->driverObj()->json())); @@ -443,6 +447,8 @@ void SimulationOptionsView::onDriverStatusChanged() mapper->setEnabled(isreset); btSelectIon->setEnabled(isreset); materialsView->setEnabled(isreset); + if (userTallyView_) + userTallyView_->setEnabled(isreset); regionsView->setEnabled(isreset); if (isreset) applyRules(); diff --git a/source/gui/simulationoptionsview.h b/source/gui/simulationoptionsview.h index a470cb3..99e8062 100644 --- a/source/gui/simulationoptionsview.h +++ b/source/gui/simulationoptionsview.h @@ -13,6 +13,7 @@ class QLineEdit; class MyDataWidgetMapper; class MaterialsDefView; +class UserTallyView; class RegionsView; class SimBoxView; class OptionsModel; @@ -74,6 +75,7 @@ public slots: QLineEdit *simTitle; MyDataWidgetMapper *mapper; MaterialsDefView *materialsView; + UserTallyView *userTallyView_{ nullptr }; RegionsView *regionsView; SimBoxView *simBoxView; QDialogButtonBox *buttonBox; diff --git a/source/gui/usertallyview.cpp b/source/gui/usertallyview.cpp new file mode 100644 index 0000000..7551315 --- /dev/null +++ b/source/gui/usertallyview.cpp @@ -0,0 +1,1171 @@ +#include "usertallyview.h" + +#include "json_defs_p.h" +#include "optionsmodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace { + +const char *kTemplatePath = ":/assets/data/tally_templates.json"; + +struct VariableInfo { + const char *name; + const char *description; +}; + +const VariableInfo kVars[] = { + { "x", "Position x" }, { "y", "Position y" }, + { "z", "Position z" }, { "r", "Radius" }, + { "rho", "Cylindrical rho" }, { "cosTheta", "Direction cosine theta" }, + { "nx", "Direction x" }, { "ny", "Direction y" }, + { "nz", "Direction z" }, { "E", "Ion energy" }, + { "Tdam", "Damage energy" }, { "V", "Vacancies" }, + { "atom_id", "Atomic species" }, { "recoil_id", "Recoil generation" } +}; + +Event parseEvent(const QString &name) +{ + if (name == "IonExit") + return Event::IonExit; + if (name == "Vacancy") + return Event::Vacancy; + if (name == "Replacement") + return Event::Replacement; + if (name == "CascadeComplete") + return Event::CascadeComplete; + if (name == "BoundaryCrossing") + return Event::BoundaryCrossing; + return Event::IonStop; +} + +QString eventToString(Event ev) +{ + return event_name(ev); +} + +void setSpin3(QDoubleSpinBox *sb[3], const vector3 &v) +{ + sb[0]->setValue(v.x()); + sb[1]->setValue(v.y()); + sb[2]->setValue(v.z()); +} + +vector3 spin3Value(QDoubleSpinBox *sb[3]) +{ + return vector3(sb[0]->value(), sb[1]->value(), sb[2]->value()); +} + +bool isLabFrame(const coord_sys &cs) +{ + const vector3 O0(0.f, 0.f, 0.f); + const vector3 Z0(0.f, 0.f, 1.f); + const vector3 XZ0(1.f, 0.f, 1.f); + return cs.origin == O0 && cs.zaxis == Z0 && cs.xzvector == XZ0; +} + +std::vector binsToRows(const user_tally::bin_var_t &bins) +{ + std::vector rows; + auto pushIf = [&rows](const char *name, const std::vector &v) { + if (v.size() >= 2) + rows.push_back({ name, BinVariableModel::edgesToString(v) }); + }; + + pushIf("x", bins.x); + pushIf("y", bins.y); + pushIf("z", bins.z); + pushIf("r", bins.r); + pushIf("rho", bins.rho); + pushIf("cosTheta", bins.cosTheta); + pushIf("nx", bins.nx); + pushIf("ny", bins.ny); + pushIf("nz", bins.nz); + pushIf("E", bins.E); + pushIf("Tdam", bins.Tdam); + pushIf("V", bins.V); + pushIf("atom_id", bins.atom_id); + pushIf("recoil_id", bins.recoil_id); + + return rows; +} + +void rowsToBins(const std::vector &rows, user_tally::bin_var_t &bins) +{ + bins = user_tally::bin_var_t(); + + for (const auto &r : rows) { + std::vector edges = BinVariableModel::parseEdges(r.edges); + if (edges.size() < 2) + continue; + + if (r.variable == "x") + bins.x = edges; + else if (r.variable == "y") + bins.y = edges; + else if (r.variable == "z") + bins.z = edges; + else if (r.variable == "r") + bins.r = edges; + else if (r.variable == "rho") + bins.rho = edges; + else if (r.variable == "cosTheta") + bins.cosTheta = edges; + else if (r.variable == "nx") + bins.nx = edges; + else if (r.variable == "ny") + bins.ny = edges; + else if (r.variable == "nz") + bins.nz = edges; + else if (r.variable == "E") + bins.E = edges; + else if (r.variable == "Tdam") + bins.Tdam = edges; + else if (r.variable == "V") + bins.V = edges; + else if (r.variable == "atom_id") + bins.atom_id = edges; + else if (r.variable == "recoil_id") + bins.recoil_id = edges; + } +} + +class VariableComboDelegate : public QStyledItemDelegate +{ +public: + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &, const QModelIndex &index) const override + { + // Only show variables not already used by other rows + const auto *m = static_cast(index.model()); + QSet used; + for (int r = 0; r < m->rowCount(); ++r) { + if (r == index.row()) continue; + used.insert(m->data(m->index(r, 0)).toString()); + } + + QComboBox *cb = new QComboBox(parent); + const QStringList vars = BinVariableModel::variableNames(); + for (const QString &v : vars) { + if (!used.contains(v)) + cb->addItem(v + " - " + BinVariableModel::variableDescription(v), v); + } + + // CRITICAL: commit to model immediately when user picks a new variable + connect(cb, QOverload::of(&QComboBox::activated), + [cb, this]() { + emit const_cast(this)->commitData(cb); + }); + + return cb; + } + + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + QComboBox *cb = static_cast(editor); + const QString var = index.model()->data(index, Qt::EditRole).toString(); + int i = cb->findData(var); + if (i >= 0) + cb->setCurrentIndex(i); + } + + void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override + { + QComboBox *cb = static_cast(editor); + model->setData(index, cb->currentData(), Qt::EditRole); + } +}; + +} // namespace + +BinVariableModel::BinVariableModel(QObject *parent) : QAbstractTableModel(parent) { } + +int BinVariableModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return static_cast(rows_.size()); +} + +int BinVariableModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return 5; +} + +QVariant BinVariableModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) + return QVariant(); + + const Row &r = rows_[index.row()]; + + if (role == Qt::BackgroundRole && index.column() == 1) { + const std::vector edges = parseEdges(r.edges); + if (!isStrictlyMonotonic(edges)) + return QColor(255, 220, 220); + } + + if (role != Qt::DisplayRole && role != Qt::EditRole) + return QVariant(); + + switch (index.column()) { + case 0: + return r.variable; + case 1: + return r.edges; + case 2: { + const std::vector edges = parseEdges(r.edges); + if (edges.size() < 2) + return 0; + return static_cast(edges.size() - 1); + } + case 3: + return "~"; + case 4: + return "x"; + default: + break; + } + + return QVariant(); +} + +QVariant BinVariableModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole || orientation != Qt::Horizontal) + return QVariant(); + + switch (section) { + case 0: + return "Variable"; + case 1: + return "Bin edges"; + case 2: + return "N bins"; + case 3: + return "[~]"; + case 4: + return "[x]"; + default: + return QVariant(); + } +} + +Qt::ItemFlags BinVariableModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) + return Qt::NoItemFlags; + + Qt::ItemFlags f = Qt::ItemIsEnabled | Qt::ItemIsSelectable; + if (index.column() == 0) + f |= Qt::ItemIsEditable; + return f; +} + +bool BinVariableModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (!index.isValid() || role != Qt::EditRole) + return false; + + Row &r = rows_[index.row()]; + if (index.column() == 0) + r.variable = value.toString(); + else if (index.column() == 1) { + // Normalize: re-serialize parsed edges so garbage tokens (e.g. "8}") are visibly dropped + const std::vector parsed = parseEdges(value.toString()); + r.edges = parsed.size() >= 2 ? edgesToString(parsed) : value.toString(); + } + else + return false; + + emit dataChanged(index, index); + emit dataChanged(this->index(index.row(), 2), this->index(index.row(), 2)); + emit rowsChanged(); + return true; +} + +bool BinVariableModel::insertRows(int row, int count, const QModelIndex &parent) +{ + if (count <= 0) + return false; + + beginInsertRows(parent, row, row + count - 1); + for (int i = 0; i < count; ++i) + rows_.insert(rows_.begin() + row, { "x", "0 1" }); + endInsertRows(); + emit rowsChanged(); + return true; +} + +bool BinVariableModel::removeRows(int row, int count, const QModelIndex &parent) +{ + if (count <= 0 || row < 0 || row + count > rowCount()) + return false; + + beginRemoveRows(parent, row, row + count - 1); + rows_.erase(rows_.begin() + row, rows_.begin() + row + count); + endRemoveRows(); + emit rowsChanged(); + return true; +} + +void BinVariableModel::setRows(const std::vector &rows) +{ + beginResetModel(); + rows_ = rows; + endResetModel(); + emit rowsChanged(); +} + +std::vector BinVariableModel::rows() const +{ + return rows_; +} + +void BinVariableModel::clear() +{ + beginResetModel(); + rows_.clear(); + endResetModel(); + emit rowsChanged(); +} + +QStringList BinVariableModel::variableNames() +{ + QStringList vars; + for (const auto &v : kVars) + vars << v.name; + return vars; +} + +QString BinVariableModel::variableDescription(const QString &name) +{ + for (const auto &v : kVars) { + if (name == v.name) + return v.description; + } + return QString(); +} + +std::vector BinVariableModel::parseEdges(const QString &text) +{ + std::vector out; + const QStringList parts = text.split(QRegularExpression("[\\s,;]+"), Qt::SkipEmptyParts); + for (const QString &p : parts) { + bool ok = false; + float v = p.toFloat(&ok); + if (ok) + out.push_back(v); + } + return out; +} + +QString BinVariableModel::edgesToString(const std::vector &edges) +{ + QStringList s; + for (float v : edges) + s << QString::number(v, 'g', 8); + return s.join(' '); +} + +bool BinVariableModel::isStrictlyMonotonic(const std::vector &edges) +{ + if (edges.size() < 2) + return false; + for (size_t i = 1; i < edges.size(); ++i) { + if (!(edges[i] > edges[i - 1])) + return false; + } + return true; +} + +LinspaceDialog::LinspaceDialog(QWidget *parent) : QDialog(parent) +{ + setWindowTitle("Generate bin edges"); + QVBoxLayout *v = new QVBoxLayout(this); + + QFormLayout *f = new QFormLayout; + min_ = new QDoubleSpinBox; + max_ = new QDoubleSpinBox; + bins_ = new QSpinBox; + + min_->setRange(-1e9, 1e9); + max_->setRange(-1e9, 1e9); + min_->setDecimals(6); + max_->setDecimals(6); + bins_->setRange(1, 1000000); + + min_->setValue(0.0); + max_->setValue(1.0); + bins_->setValue(10); + + f->addRow("Min value:", min_); + f->addRow("Max value:", max_); + f->addRow("N bins:", bins_); + v->addLayout(f); + + preview_ = new QLabel; + v->addWidget(preview_); + + QDialogButtonBox *box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(box, &QDialogButtonBox::rejected, this, &QDialog::reject); + v->addWidget(box); + + connect(min_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + &LinspaceDialog::updatePreview); + connect(max_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + &LinspaceDialog::updatePreview); + connect(bins_, QOverload::of(&QSpinBox::valueChanged), this, + &LinspaceDialog::updatePreview); + updatePreview(); +} + +std::vector LinspaceDialog::edges() const +{ + const int n = bins_->value(); + const double a = min_->value(); + const double b = max_->value(); + std::vector e; + e.reserve(static_cast(n) + 1); + + if (n <= 0) + return e; + + const double step = (b - a) / static_cast(n); + for (int i = 0; i <= n; ++i) + e.push_back(static_cast(a + step * i)); + + return e; +} + +void LinspaceDialog::updatePreview() +{ + const std::vector e = edges(); + if (e.size() < 2) { + preview_->setText("No edges generated"); + return; + } + + QString txt = QString("%1 edges, %2 bins\n").arg(e.size()).arg(e.size() - 1); + txt += QString("%1, %2, ... , %3, %4") + .arg(e.front(), 0, 'g', 6) + .arg(e[1], 0, 'g', 6) + .arg(e[e.size() - 2], 0, 'g', 6) + .arg(e.back(), 0, 'g', 6); + preview_->setText(txt); +} + +TallyTemplateDialog::TallyTemplateDialog(QWidget *parent) : QDialog(parent) +{ + setWindowTitle("Tally Templates"); + resize(760, 460); + + QVBoxLayout *root = new QVBoxLayout(this); + QHBoxLayout *body = new QHBoxLayout; + + list_ = new QListWidget; + preview_ = new QTextBrowser; + preview_->setReadOnly(true); + + body->addWidget(list_, 1); + body->addWidget(preview_, 2); + root->addLayout(body, 1); + + QDialogButtonBox *box = new QDialogButtonBox(QDialogButtonBox::Cancel); + accept_ = box->addButton("Load into current tally", QDialogButtonBox::AcceptRole); + accept_->setEnabled(false); + connect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(box, &QDialogButtonBox::rejected, this, &QDialog::reject); + root->addWidget(box); + + connect(list_, &QListWidget::currentRowChanged, this, &TallyTemplateDialog::onCurrentTemplateChanged); + + const bool ok = loadTemplates(); + if (ok && list_->count() > 0) + list_->setCurrentRow(0); + else { + preview_->setPlainText("No templates available. Check resource file tally_templates.json."); + accept_->setEnabled(false); + } +} + +bool TallyTemplateDialog::loadTemplates() +{ + entries_.clear(); + + QFile f(kTemplatePath); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) + return false; + + ojson j; + try { + j = ojson::parse(f.readAll().toStdString(), nullptr, true, true); + } catch (...) { + return false; + } + + if (!j.is_array()) + return false; + + for (const auto &node : j) { + if (!node.is_object()) + continue; + + TemplateEntry e; + e.parameters.id = node.value("id", std::string("Template")); + e.parameters.description = node.value("description", std::string()); + e.parameters.event = parseEvent( + QString::fromStdString(node.value("event", std::string("IonStop")))); + + if (node.contains("bins") && node["bins"].is_object()) { + for (auto it = node["bins"].begin(); it != node["bins"].end(); ++it) { + if (!it.value().is_array()) + continue; + std::vector edges = it.value().get>(); + const std::string k = it.key(); + if (k == "x") e.parameters.bins.x = edges; + else if (k == "y") e.parameters.bins.y = edges; + else if (k == "z") e.parameters.bins.z = edges; + else if (k == "r") e.parameters.bins.r = edges; + else if (k == "rho") e.parameters.bins.rho = edges; + else if (k == "cosTheta") e.parameters.bins.cosTheta = edges; + else if (k == "nx") e.parameters.bins.nx = edges; + else if (k == "ny") e.parameters.bins.ny = edges; + else if (k == "nz") e.parameters.bins.nz = edges; + else if (k == "E") e.parameters.bins.E = edges; + else if (k == "Tdam") e.parameters.bins.Tdam = edges; + else if (k == "V") e.parameters.bins.V = edges; + else if (k == "atom_id") e.parameters.bins.atom_id = edges; + else if (k == "recoil_id") e.parameters.bins.recoil_id = edges; + } + } + + entries_.push_back(e); + list_->addItem(QString::fromStdString(e.parameters.id)); + } + + return !entries_.empty(); +} + +user_tally::parameters TallyTemplateDialog::selectedTemplate() const +{ + int i = list_->currentRow(); + if (i < 0 || i >= static_cast(entries_.size())) + return user_tally::parameters(); + return entries_[i].parameters; +} + +void TallyTemplateDialog::refreshPreview(int row) +{ + if (row < 0 || row >= static_cast(entries_.size())) { + preview_->clear(); + accept_->setEnabled(false); + return; + } + + const auto &p = entries_[row].parameters; + std::vector rows = binsToRows(p.bins); + + QString html; + html += QString("Event: %1
").arg(eventToString(p.event)); + html += QString("Description: %1

") + .arg(QString::fromStdString(p.description).toHtmlEscaped()); + html += "Bins:
"; + for (const auto &r : rows) { + const std::vector edges = BinVariableModel::parseEdges(r.edges); + if (edges.size() >= 2) { + html += QString("%1 : %2 → %3   (%4 bins)
") + .arg(r.variable.toHtmlEscaped()) + .arg(edges.front(), 0, 'g', 4) + .arg(edges.back(), 0, 'g', 4) + .arg(edges.size() - 1); + } else { + html += QString("%1 : %2
") + .arg(r.variable.toHtmlEscaped()) + .arg(r.edges.toHtmlEscaped()); + } + } + + preview_->setHtml(html); + accept_->setEnabled(true); +} + +void TallyTemplateDialog::onCurrentTemplateChanged(int row) +{ + refreshPreview(row); +} + +UserTallyView::UserTallyView(OptionsModel *m, QWidget *parent) + : QWidget(parent), + model_(m), + binModel_(new BinVariableModel(this)) +{ + tallyIndex_ = model_->index("UserTally"); + + QVBoxLayout *root = new QVBoxLayout(this); + + QHBoxLayout *top = new QHBoxLayout; + top->addWidget(new QLabel("Tally:")); + cbTallyID_ = new QComboBox; + cbTallyID_->setMinimumContentsLength(16); + top->addWidget(cbTallyID_, 1); + + btDbTally_ = new QToolButton; + btDbTally_->setIcon(QIcon(":/assets/ionicons/document-text-outline.svg")); + btDbTally_->setToolTip("Load tally template"); + top->addWidget(btDbTally_); + + btAddTally_ = new QToolButton; + btAddTally_->setIcon(QIcon(":/assets/ionicons/add-outline.svg")); + btAddTally_->setToolTip("Add new tally"); + top->addWidget(btAddTally_); + + btDupTally_ = new QToolButton; + btDupTally_->setIcon(QIcon(":/assets/ionicons/copy-outline.svg")); + btDupTally_->setToolTip("Duplicate tally"); + top->addWidget(btDupTally_); + + btDelTally_ = new QToolButton; + btDelTally_->setIcon(QIcon(":/assets/ionicons/remove-outline.svg")); + btDelTally_->setToolTip("Remove tally"); + top->addWidget(btDelTally_); + + btEdtTally_ = new QToolButton; + btEdtTally_->setIcon(QIcon(":/assets/ionicons/create-outline.svg")); + btEdtTally_->setToolTip("Rename tally"); + top->addWidget(btEdtTally_); + + root->addLayout(top); + + summaryLabel_ = new QLabel("No bins defined - tally is inactive"); + memoryLabel_ = new QLabel("Memory: 0 B per tally flush"); + warningLabel_ = new QLabel; + warningLabel_->setStyleSheet("color: #b22222;"); + root->addWidget(summaryLabel_); + root->addWidget(memoryLabel_); + root->addWidget(warningLabel_); + + QFormLayout *form = new QFormLayout; + leDescription_ = new QLineEdit; + cbEvent_ = new QComboBox; + cbEvent_->addItems({ "IonStop", "IonExit", "Vacancy", "Replacement", "CascadeComplete", "BoundaryCrossing" }); + form->addRow("Description:", leDescription_); + form->addRow("Event:", cbEvent_); + root->addLayout(form); + + QGroupBox *csBox = new QGroupBox("Coordinate system"); + QVBoxLayout *csV = new QVBoxLayout(csBox); + cbUseLabFrame_ = new QCheckBox("Use lab frame"); + csV->addWidget(cbUseLabFrame_); + + coordWidget_ = new QWidget; + QGridLayout *cg = new QGridLayout(coordWidget_); + + for (int i = 0; i < 3; ++i) { + sbOrigin_[i] = new QDoubleSpinBox; + sbZaxis_[i] = new QDoubleSpinBox; + sbXZvec_[i] = new QDoubleSpinBox; + + sbOrigin_[i]->setRange(-1e6, 1e6); + sbZaxis_[i]->setRange(-1e3, 1e3); + sbXZvec_[i]->setRange(-1e3, 1e3); + + sbOrigin_[i]->setDecimals(6); + sbZaxis_[i]->setDecimals(6); + sbXZvec_[i]->setDecimals(6); + } + + cg->addWidget(new QLabel("Origin:"), 0, 0); + cg->addWidget(sbOrigin_[0], 0, 1); + cg->addWidget(sbOrigin_[1], 0, 2); + cg->addWidget(sbOrigin_[2], 0, 3); + + cg->addWidget(new QLabel("Z-axis:"), 1, 0); + cg->addWidget(sbZaxis_[0], 1, 1); + cg->addWidget(sbZaxis_[1], 1, 2); + cg->addWidget(sbZaxis_[2], 1, 3); + + cg->addWidget(new QLabel("XZ-plane vec:"), 2, 0); + cg->addWidget(sbXZvec_[0], 2, 1); + cg->addWidget(sbXZvec_[1], 2, 2); + cg->addWidget(sbXZvec_[2], 2, 3); + + csV->addWidget(coordWidget_); + root->addWidget(csBox); + + QHBoxLayout *binsHdr = new QHBoxLayout; + binsHdr->addWidget(new QLabel("Bin variables")); + binsHdr->addStretch(); + btAddBin_ = new QToolButton; + btAddBin_->setIcon(QIcon(":/assets/ionicons/add-outline.svg")); + btAddBin_->setToolTip("Add variable"); + binsHdr->addWidget(btAddBin_); + root->addLayout(binsHdr); + + binTableView_ = new QTableView; + binTableView_->setModel(binModel_); + binTableView_->setItemDelegateForColumn(0, new VariableComboDelegate); + binTableView_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + binTableView_->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + binTableView_->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + binTableView_->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents); + binTableView_->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); + root->addWidget(binTableView_, 1); + + // Keep variable combo always visible — no click needed to see the dropdown + connect(binModel_, &BinVariableModel::rowsChanged, this, [this]() { + for (int r = 0; r < binModel_->rowCount(); ++r) + binTableView_->openPersistentEditor(binModel_->index(r, 0)); + }); + + // When a variable name changes in one row, close+reopen all persistent combos + // so their item lists (which filter out already-used variables) stay current. + // QueuedConnection: defer until after the activated signal stack unwinds, + // so we never destroy the combo box while its signal handler is still running. + connect(binModel_, &BinVariableModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &) { + if (tl.column() != 0) + return; + for (int r = 0; r < binModel_->rowCount(); ++r) { + binTableView_->closePersistentEditor(binModel_->index(r, 0)); + binTableView_->openPersistentEditor(binModel_->index(r, 0)); + } + }, Qt::QueuedConnection); + + connect(btAddTally_, &QToolButton::clicked, this, &UserTallyView::addTally); + connect(btDupTally_, &QToolButton::clicked, this, &UserTallyView::duplicateTally); + connect(btDelTally_, &QToolButton::clicked, this, &UserTallyView::removeTally); + connect(btEdtTally_, &QToolButton::clicked, this, &UserTallyView::renameTally); + connect(btDbTally_, &QToolButton::clicked, this, &UserTallyView::loadFromTemplate); + connect(btAddBin_, &QToolButton::clicked, this, &UserTallyView::addBinVariable); + + connect(cbTallyID_, QOverload::of(&QComboBox::currentIndexChanged), this, + &UserTallyView::updateSelectedTally); + connect(leDescription_, &QLineEdit::editingFinished, this, &UserTallyView::setValueData); + connect(cbEvent_, QOverload::of(&QComboBox::currentIndexChanged), this, + &UserTallyView::setValueData); + connect(cbUseLabFrame_, &QCheckBox::stateChanged, this, &UserTallyView::onUseLabFrameChanged); + connect(binModel_, &BinVariableModel::rowsChanged, this, &UserTallyView::setValueData); + connect(binModel_, &BinVariableModel::rowsChanged, this, &UserTallyView::updateSummaryLabel); + connect(binTableView_, &QTableView::clicked, this, &UserTallyView::onBinTableClicked); + + for (int i = 0; i < 3; ++i) { + connect(sbOrigin_[i], QOverload::of(&QDoubleSpinBox::valueChanged), this, + &UserTallyView::setValueData); + connect(sbZaxis_[i], QOverload::of(&QDoubleSpinBox::valueChanged), this, + &UserTallyView::setValueData); + connect(sbXZvec_[i], QOverload::of(&QDoubleSpinBox::valueChanged), this, + &UserTallyView::setValueData); + } + + setWidgetData(); +} + +void UserTallyView::setWidgetData() +{ + cbTallyID_->blockSignals(true); + cbTallyID_->clear(); + + auto &ut = model_->options()->UserTally; + for (const auto &t : ut) + cbTallyID_->addItem(QString::fromStdString(t.id)); + + cbTallyID_->blockSignals(false); + + if (!ut.empty()) { + cbTallyID_->setCurrentIndex(0); + updateSelectedTally(); + } else { + leDescription_->clear(); + cbEvent_->setCurrentIndex(0); + cbUseLabFrame_->setChecked(true); + onUseLabFrameChanged(); + binModel_->clear(); + warningLabel_->clear(); + updateSummaryLabel(); + setTallyEditorEnabled(false); + } + + const bool has = !ut.empty(); + cbTallyID_->setEnabled(has); + btDupTally_->setEnabled(has); + btDelTally_->setEnabled(has); + btEdtTally_->setEnabled(has); +} + +void UserTallyView::setTallyEditorEnabled(bool enabled) +{ + leDescription_->setEnabled(enabled); + cbEvent_->setEnabled(enabled); + cbUseLabFrame_->setEnabled(enabled); + coordWidget_->setEnabled(enabled && !cbUseLabFrame_->isChecked()); + btAddBin_->setEnabled(enabled); + binTableView_->setEnabled(enabled); +} + +void UserTallyView::setValueData() +{ + if (syncingUi_) + return; + + user_tally::parameters *t = currentTally(); + if (!t) + return; + + t->description = leDescription_->text().toStdString(); + t->event = currentEvent(); + + if (cbUseLabFrame_->isChecked()) { + t->coordinate_system.reset(); + } else { + t->coordinate_system.origin = spin3Value(sbOrigin_); + t->coordinate_system.zaxis = spin3Value(sbZaxis_); + t->coordinate_system.xzvector = spin3Value(sbXZvec_); + } + + rowsToBins(binModel_->rows(), t->bins); + + model_->notifyDataChanged(tallyIndex_); + updateSummaryLabel(); +} + +void UserTallyView::addTally() +{ + user_tally::parameters p; + p.id = QString("Tally%1").arg(model_->options()->UserTally.size() + 1).toStdString(); + p.description = "User tally"; + p.event = Event::IonStop; + + model_->options()->UserTally.push_back(p); + model_->notifyDataChanged(tallyIndex_); + + setWidgetData(); + cbTallyID_->setCurrentIndex(cbTallyID_->count() - 1); +} + +void UserTallyView::removeTally() +{ + int i = cbTallyID_->currentIndex(); + auto &ut = model_->options()->UserTally; + if (i < 0 || i >= static_cast(ut.size())) + return; + + QMessageBox::StandardButton ret = QMessageBox::warning( + this, "Remove tally", + QString("%1 is being removed.\nClick OK to proceed.").arg(cbTallyID_->currentText()), + QMessageBox::Ok | QMessageBox::Cancel); + if (ret != QMessageBox::Ok) + return; + + ut.erase(ut.begin() + i); + model_->notifyDataChanged(tallyIndex_); + setWidgetData(); +} + +void UserTallyView::duplicateTally() +{ + int i = cbTallyID_->currentIndex(); + auto &ut = model_->options()->UserTally; + if (i < 0 || i >= static_cast(ut.size())) + return; + + user_tally::parameters copy = ut[i]; + QString baseId = QString::fromStdString(copy.id); + if (baseId.isEmpty()) + baseId = "Tally"; + + QString newId = baseId + "_copy"; + int suffix = 2; + auto exists = [&ut](const QString &id) { + for (const auto &t : ut) { + if (QString::fromStdString(t.id) == id) + return true; + } + return false; + }; + while (exists(newId)) { + newId = QString("%1_copy%2").arg(baseId).arg(suffix++); + } + + copy.id = newId.toStdString(); + ut.push_back(copy); + model_->notifyDataChanged(tallyIndex_); + + setWidgetData(); + cbTallyID_->setCurrentText(newId); +} + +void UserTallyView::renameTally() +{ + int i = cbTallyID_->currentIndex(); + auto &ut = model_->options()->UserTally; + if (i < 0 || i >= static_cast(ut.size())) + return; + + bool ok = false; + QString s = QInputDialog::getText(this, "Rename tally", "New tally id", QLineEdit::Normal, + cbTallyID_->currentText(), &ok); + if (!ok || s.trimmed().isEmpty()) + return; + + s = s.trimmed(); + ut[i].id = s.toStdString(); + model_->notifyDataChanged(tallyIndex_); + + setWidgetData(); + cbTallyID_->setCurrentText(s); +} + +void UserTallyView::addBinVariable() +{ + // Pick the first variable not already used in any row + const QStringList allVars = BinVariableModel::variableNames(); + const std::vector existing = binModel_->rows(); + QString pick; + for (const QString &v : allVars) { + bool used = false; + for (const auto &r : existing) + if (r.variable == v) { used = true; break; } + if (!used) { pick = v; break; } + } + if (pick.isEmpty()) + return; // all 14 variables are already in use + + const int newRow = binModel_->rowCount(); + binModel_->insertRows(newRow, 1); + binModel_->setData(binModel_->index(newRow, 0), pick); +} + +void UserTallyView::loadFromTemplate() +{ + user_tally::parameters *t = currentTally(); + if (!t) { + addTally(); + t = currentTally(); + if (!t) + return; + } + + TallyTemplateDialog dlg(this); + + if (dlg.exec() != QDialog::Accepted) + return; + + user_tally::parameters p = dlg.selectedTemplate(); + t->description = p.description; + t->event = p.event; + t->bins = p.bins; + + updateSelectedTally(); + model_->notifyDataChanged(tallyIndex_); +} + +void UserTallyView::updateSelectedTally() +{ + const user_tally::parameters *t = currentTally(); + if (!t) { + setTallyEditorEnabled(false); + return; + } + + syncingUi_ = true; + + leDescription_->blockSignals(true); + cbEvent_->blockSignals(true); + + leDescription_->setText(QString::fromStdString(t->description)); + setCurrentEvent(t->event); + + setSpin3(sbOrigin_, t->coordinate_system.origin); + setSpin3(sbZaxis_, t->coordinate_system.zaxis); + setSpin3(sbXZvec_, t->coordinate_system.xzvector); + + cbUseLabFrame_->setChecked(isLabFrame(t->coordinate_system)); + onUseLabFrameChanged(); + + binModel_->setRows(binsToRows(t->bins)); + + leDescription_->blockSignals(false); + cbEvent_->blockSignals(false); + syncingUi_ = false; + + setTallyEditorEnabled(true); + + btDelTally_->setEnabled(true); + btEdtTally_->setEnabled(true); + btDupTally_->setEnabled(true); + + updateSummaryLabel(); +} + +void UserTallyView::updateSummaryLabel() +{ + const std::vector rows = binModel_->rows(); + + QStringList names; + QStringList dims; + long long totalBins = 1; + bool hasBins = false; + bool hasInvalid = false; + + for (const auto &r : rows) { + const std::vector edges = BinVariableModel::parseEdges(r.edges); + if (edges.size() < 2) { + hasInvalid = true; + continue; + } + if (!BinVariableModel::isStrictlyMonotonic(edges)) { + hasInvalid = true; + continue; + } + + const int n = static_cast(edges.size() - 1); + names << r.variable; + dims << QString::number(n); + totalBins *= n; + hasBins = true; + } + + if (!hasBins) { + summaryLabel_->setText("No bins defined - tally is inactive"); + summaryLabel_->setStyleSheet(""); + memoryLabel_->setText("Memory: 0 B per tally flush"); + } else if (names.size() == 1) { + summaryLabel_->setText( + QString("1D tally: %1 (%2 bins)").arg(names.join(" ")).arg(totalBins)); + } else { + summaryLabel_->setText(QString("%1D tally: %2 (%3 = %4 bins)") + .arg(names.size()) + .arg(names.join(" x ")) + .arg(dims.join(" x ")) + .arg(totalBins)); + } + + if (hasBins) { + if (totalBins >= 200000) + summaryLabel_->setStyleSheet("color: #b22222; font-weight: bold;"); + else if (totalBins >= 10000) + summaryLabel_->setStyleSheet("color: #e07000; font-weight: bold;"); + else + summaryLabel_->setStyleSheet(""); + } + + const double bytes = static_cast(totalBins) * sizeof(double); + QString memStr; + if (bytes < 1024.0) + memStr = QString("%1 B").arg(bytes, 0, 'f', 0); + else if (bytes < 1024.0 * 1024.0) + memStr = QString("%1 KB").arg(bytes / 1024.0, 0, 'f', 1); + else + memStr = QString("%1 MB").arg(bytes / (1024.0 * 1024.0), 0, 'f', 2); + memoryLabel_->setText(QString("Memory: ~%1 per tally flush").arg(memStr)); + + QStringList warnings; + if (hasInvalid) + warnings << "Bin edges must be strictly monotonic for all rows."; + if (totalBins > 500000) + warnings << "Large tally warning: total bins exceed 500000."; + warningLabel_->setText(warnings.join(" ")); + + // Disable [+] when all 14 variables are already in use + if (currentTally()) { + const bool hasRoom = binModel_->rowCount() + < static_cast(BinVariableModel::variableNames().size()); + btAddBin_->setEnabled(hasRoom); + } +} + +void UserTallyView::applyLinspaceToCurrentRow() +{ + const QModelIndex idx = binTableView_->currentIndex(); + if (!idx.isValid()) + return; + + LinspaceDialog dlg(this); + if (dlg.exec() != QDialog::Accepted) + return; + + const std::vector edges = dlg.edges(); + if (edges.size() < 2) + return; + + binModel_->setData(binModel_->index(idx.row(), 1), BinVariableModel::edgesToString(edges)); +} + +void UserTallyView::onBinTableClicked(const QModelIndex &index) +{ + if (!index.isValid()) + return; + + if (index.column() == 3) { + binTableView_->setCurrentIndex(index); + applyLinspaceToCurrentRow(); + } else if (index.column() == 4) { + binModel_->removeRows(index.row(), 1); + } +} + +void UserTallyView::onUseLabFrameChanged(int) +{ + const bool useLab = cbUseLabFrame_->isChecked(); + coordWidget_->setVisible(!useLab); + coordWidget_->setEnabled(!useLab); + setValueData(); +} + +Event UserTallyView::currentEvent() const +{ + return parseEvent(cbEvent_->currentText()); +} + +void UserTallyView::setCurrentEvent(Event ev) +{ + int i = cbEvent_->findText(eventToString(ev)); + if (i < 0) + i = 0; + cbEvent_->setCurrentIndex(i); +} + +user_tally::parameters *UserTallyView::currentTally() +{ + int i = cbTallyID_->currentIndex(); + auto &ut = model_->options()->UserTally; + if (i < 0 || i >= static_cast(ut.size())) + return nullptr; + return &ut[i]; +} + +const user_tally::parameters *UserTallyView::currentTally() const +{ + int i = cbTallyID_->currentIndex(); + const auto &ut = model_->options()->UserTally; + if (i < 0 || i >= static_cast(ut.size())) + return nullptr; + return &ut[i]; +} diff --git a/source/gui/usertallyview.h b/source/gui/usertallyview.h new file mode 100644 index 0000000..6a7e297 --- /dev/null +++ b/source/gui/usertallyview.h @@ -0,0 +1,169 @@ +#ifndef USERTALLYVIEW_H +#define USERTALLYVIEW_H + +#include +#include +#include +#include + +#include "mcdriver.h" + +class QCheckBox; +class QComboBox; +class QDoubleSpinBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableView; +class QToolButton; +class QModelIndex; +class QStringList; +class QListWidget; +class QTextBrowser; + +class OptionsModel; + +class BinVariableModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + struct Row { + QString variable; + QString edges; + }; + + explicit BinVariableModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + bool insertRows(int row, int count, const QModelIndex &parent = QModelIndex()) override; + bool removeRows(int row, int count, const QModelIndex &parent = QModelIndex()) override; + + void setRows(const std::vector &rows); + std::vector rows() const; + void clear(); + + static QStringList variableNames(); + static QString variableDescription(const QString &name); + static std::vector parseEdges(const QString &text); + static QString edgesToString(const std::vector &edges); + static bool isStrictlyMonotonic(const std::vector &edges); + +signals: + void rowsChanged(); + +private: + std::vector rows_; +}; + +class LinspaceDialog : public QDialog +{ + Q_OBJECT + +public: + explicit LinspaceDialog(QWidget *parent = nullptr); + std::vector edges() const; + +private slots: + void updatePreview(); + +private: + QDoubleSpinBox *min_; + QDoubleSpinBox *max_; + QSpinBox *bins_; + QLabel *preview_; +}; + +class TallyTemplateDialog : public QDialog +{ + Q_OBJECT + +public: + explicit TallyTemplateDialog(QWidget *parent = nullptr); + user_tally::parameters selectedTemplate() const; + +private slots: + void onCurrentTemplateChanged(int row); + +private: + struct TemplateEntry { + user_tally::parameters parameters; + }; + + bool loadTemplates(); + void refreshPreview(int row); + + QListWidget *list_; + QTextBrowser *preview_; + QPushButton *accept_; + std::vector entries_; +}; + +class UserTallyView : public QWidget +{ + Q_OBJECT + +public: + explicit UserTallyView(OptionsModel *m, QWidget *parent = nullptr); + +public slots: + void setWidgetData(); + void setValueData(); + void addTally(); + void removeTally(); + void renameTally(); + void duplicateTally(); + void addBinVariable(); + void loadFromTemplate(); + void updateSelectedTally(); + void updateSummaryLabel(); + +private slots: + void applyLinspaceToCurrentRow(); + void onBinTableClicked(const QModelIndex &index); + void onUseLabFrameChanged(int state = 0); + +private: + Event currentEvent() const; + void setCurrentEvent(Event ev); + void setTallyEditorEnabled(bool enabled); + user_tally::parameters *currentTally(); + const user_tally::parameters *currentTally() const; + + QComboBox *cbTallyID_; + QToolButton *btDbTally_; + QToolButton *btAddTally_; + QToolButton *btDupTally_; + QToolButton *btDelTally_; + QToolButton *btEdtTally_; + QToolButton *btAddBin_; + + QLabel *summaryLabel_; + QLabel *memoryLabel_; + QLabel *warningLabel_; + + QLineEdit *leDescription_; + QComboBox *cbEvent_; + + QCheckBox *cbUseLabFrame_; + QWidget *coordWidget_; + QDoubleSpinBox *sbOrigin_[3]; + QDoubleSpinBox *sbZaxis_[3]; + QDoubleSpinBox *sbXZvec_[3]; + + BinVariableModel *binModel_; + QTableView *binTableView_; + bool syncingUi_ = false; + + OptionsModel *model_; + QPersistentModelIndex tallyIndex_; +}; + +#endif // USERTALLYVIEW_H diff --git a/source/lib/parse_json.cpp b/source/lib/parse_json.cpp index 853ecde..14c1aa8 100644 --- a/source/lib/parse_json.cpp +++ b/source/lib/parse_json.cpp @@ -428,7 +428,12 @@ void mcconfig::set_impl_(const std::string &path, const std::string &json_str) { ojson j(*this); ojson::json_pointer ptr(path.c_str()); - ojson v = ojson::parse(json_str); + std::string normalized = json_str; + if (normalized.empty()) { + // Defensive guard: empty input is invalid JSON. Preserve existing value instead. + normalized = j.at(ptr).dump(); + } + ojson v = ojson::parse(normalized); j.at(ptr) = v; *this = j; validate(true);