aboutsummaryrefslogtreecommitdiff
path: root/app/source/Cplt/UI
diff options
context:
space:
mode:
Diffstat (limited to 'app/source/Cplt/UI')
-rw-r--r--app/source/Cplt/UI/UI.hpp48
-rw-r--r--app/source/Cplt/UI/UI_DatabaseView.cpp668
-rw-r--r--app/source/Cplt/UI/UI_Items.cpp252
-rw-r--r--app/source/Cplt/UI/UI_MainWindow.cpp237
-rw-r--r--app/source/Cplt/UI/UI_Settings.cpp8
-rw-r--r--app/source/Cplt/UI/UI_Templates.cpp977
-rw-r--r--app/source/Cplt/UI/UI_Utils.cpp315
-rw-r--r--app/source/Cplt/UI/UI_Workflows.cpp293
-rw-r--r--app/source/Cplt/UI/fwd.hpp6
9 files changed, 2804 insertions, 0 deletions
diff --git a/app/source/Cplt/UI/UI.hpp b/app/source/Cplt/UI/UI.hpp
new file mode 100644
index 0000000..0a80b4c
--- /dev/null
+++ b/app/source/Cplt/UI/UI.hpp
@@ -0,0 +1,48 @@
+#pragma once
+
+#include <imgui.h>
+
+namespace ImGui {
+
+void SetNextWindowSizeRelScreen(float xPercent, float yPercent, ImGuiCond cond = ImGuiCond_None);
+void SetNextWindowCentered(ImGuiCond cond = ImGuiCond_None);
+
+void PushDisabled();
+void PopDisabled();
+
+bool Button(const char* label, bool disabled);
+bool Button(const char* label, const ImVec2& sizeArg, bool disabled);
+
+void ErrorIcon();
+void ErrorMessage(const char* fmt, ...);
+void WarningIcon();
+void WarningMessage(const char* fmt, ...);
+
+enum class IconType
+{
+ Flow,
+ Circle,
+ Square,
+ Grid,
+ RoundSquare,
+ Diamond,
+};
+
+void DrawIcon(ImDrawList* drawList, const ImVec2& a, const ImVec2& b, IconType type, bool filled, ImU32 color, ImU32 innerColor);
+void Icon(const ImVec2& size, IconType type, bool filled, const ImVec4& color = ImVec4(1, 1, 1, 1), const ImVec4& innerColor = ImVec4(0, 0, 0, 0));
+
+bool Splitter(bool splitVertically, float thickness, float* size1, float* size2, float minSize1, float minSize2, float splitterLongAxisSize = -1.0f);
+
+} // namespace ImGui
+
+namespace UI {
+
+void MainWindow();
+
+void SettingsTab();
+void DatabaseViewTab();
+void ItemsTab();
+void WorkflowsTab();
+void TemplatesTab();
+
+} // namespace UI
diff --git a/app/source/Cplt/UI/UI_DatabaseView.cpp b/app/source/Cplt/UI/UI_DatabaseView.cpp
new file mode 100644
index 0000000..1e58eb0
--- /dev/null
+++ b/app/source/Cplt/UI/UI_DatabaseView.cpp
@@ -0,0 +1,668 @@
+#include "UI.hpp"
+
+#include <Cplt/Model/Filter.hpp>
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/Utils/I18n.hpp>
+#include <Cplt/Utils/ScopeGuard.hpp>
+#include <Cplt/Utils/Time.hpp>
+
+#include <IconsFontAwesome.h>
+#include <SQLiteCpp/Statement.h>
+#include <imgui.h>
+#include <tsl/robin_map.h>
+#include <cstdint>
+#include <iostream>
+#include <memory>
+#include <vector>
+
+namespace CPLT_UNITY_ID {
+
+// TODO move to Settings
+constexpr int kMaxEntriesPerPage = 32;
+constexpr int kSummaryItemCount = 3;
+constexpr int kSummaryMaxLength = 25;
+
+std::pair<int, int> SplitEntryIndex(int entryIdx)
+{
+ int page = entryIdx / kMaxEntriesPerPage;
+ int row = entryIdx % kMaxEntriesPerPage;
+ return { page, row };
+}
+
+enum class DeliveryDirection
+{
+ FactoryToWarehouse,
+ WarehouseToCustomer,
+};
+
+struct Item
+{
+ int ItemId;
+ int Count;
+};
+
+struct DeliveryEntry
+{
+ std::vector<Item> Items;
+ std::string ItemsSummary;
+ std::string ShipmentTime;
+ std::string ArriveTime;
+ DeliveryDirection Direction;
+
+ const char* StringifyDirection() const
+ {
+ switch (Direction) {
+ case DeliveryDirection::FactoryToWarehouse: return "Factory to warehouse";
+ case DeliveryDirection::WarehouseToCustomer: return "Warehouse to customer";
+ }
+ }
+};
+
+struct SaleEntry
+{
+ static constexpr auto kType = DeliveryDirection::WarehouseToCustomer;
+
+ std::vector<DeliveryEntry> AssociatedDeliveries;
+ std::vector<Item> Items;
+ std::string ItemsSummary;
+ std::string Customer;
+ std::string Deadline;
+ std::string DeliveryTime;
+ int Id;
+ bool DeliveriesCached = false;
+};
+
+struct PurchaseEntry
+{
+ static constexpr auto kType = DeliveryDirection::FactoryToWarehouse;
+
+ std::vector<DeliveryEntry> AssociatedDeliveries;
+ std::vector<Item> Items;
+ std::string ItemsSummary;
+ std::string Factory;
+ std::string OrderTime;
+ std::string DeliveryTime;
+ int Id;
+ bool DeliveriesCached;
+};
+
+template <class T>
+class GenericTableView
+{
+public:
+ // clang-format off
+ static constexpr bool kHasItems = requires(T t)
+ {
+ t.Items;
+ t.ItemsSummary;
+ };
+ static constexpr bool kHasCustomer = requires(T t) { t.Customer; };
+ static constexpr bool kHasDeadline = requires(T t) { t.Deadline; };
+ static constexpr bool kHasFactory = requires(T t) { t.Factory; };
+ static constexpr bool kHasOrderTime = requires(T t) { t.OrderTime; };
+ static constexpr bool kHasCompletionTime = requires(T t) { t.DeliveryTime; };
+ static constexpr int kColumnCount = kHasItems + kHasCustomer + kHasDeadline + kHasFactory + kHasOrderTime + kHasCompletionTime;
+ // clang-format on
+
+ using Page = std::vector<T>;
+
+ struct QueryStatements
+ {
+ SQLite::Statement* GetRowCount;
+ SQLite::Statement* GetRows;
+ SQLite::Statement* GetItems;
+ SQLite::Statement* FilterRows;
+ } Statements;
+
+protected:
+ // Translation entries for implementer to fill out
+ const char* mEditDialogTitle;
+
+ Project* mProject;
+ Page* mCurrentPage = nullptr;
+
+ /// Current active filter object, or \c nullptr.
+ std::unique_ptr<TableRowsFilter> mActiveFilter;
+
+ tsl::robin_map<int, Page> mPages;
+
+ /// A vector of entry indices (in \c mEntries) that are visible under the current filter.
+ std::vector<int> mActiveEntries;
+
+ /// Number of rows in the table.
+ int mRowCount;
+ /// Last possible page for the current set table and filter (inclusive).
+ int mLastPage;
+
+ /// The current page the user is on.
+ int mCurrentPageNumber;
+
+ /// Row index of the select entry
+ int mSelectRow;
+
+public:
+ /// Calculate the first visible row's entry index.
+ int GetFirstVisibleRowIdx() const
+ {
+ return mCurrentPageNumber * kMaxEntriesPerPage;
+ }
+
+ Project* GetProject() const
+ {
+ return mProject;
+ }
+
+ void OnProjectChanged(Project* newProject)
+ {
+ mProject = newProject;
+
+ auto& stmt = *Statements.GetRowCount;
+ if (stmt.executeStep()) {
+ mRowCount = stmt.getColumn(0).getInt();
+ } else {
+ std::cerr << "Failed to fetch row count from SQLite.\n";
+ mRowCount = 0;
+ }
+
+ mActiveFilter = nullptr;
+ mActiveEntries.clear();
+
+ mPages.clear();
+ mCurrentPage = nullptr;
+ UpdateLastPage();
+ SetPage(0);
+
+ mSelectRow = -1;
+ }
+
+ TableRowsFilter* GetFilter() const
+ {
+ return mActiveFilter.get();
+ }
+
+ void OnFilterChanged()
+ {
+ auto& stmt = *Statements.FilterRows;
+ // clang-format off
+ DEFER { stmt.reset(); };
+ // clang-format on
+
+ // TODO lazy loading when too many results
+ mActiveEntries.clear();
+ int columnIdx = stmt.getColumnIndex("Id");
+ while (stmt.executeStep()) {
+ mActiveEntries.push_back(stmt.getColumn(columnIdx).getInt());
+ }
+
+ UpdateLastPage();
+ SetPage(0);
+
+ mSelectRow = -1;
+ }
+
+ void OnFilterChanged(std::unique_ptr<TableRowsFilter> filter)
+ {
+ mActiveFilter = std::move(filter);
+ OnFilterChanged();
+ }
+
+ void Display()
+ {
+ bool dummy = true;
+
+ if (ImGui::Button(ICON_FA_ARROW_LEFT, mCurrentPageNumber == 0)) {
+ SetPage(mCurrentPageNumber - 1);
+ }
+
+ ImGui::SameLine();
+ // +1 to convert from 0-based indices to 1-based, for human legibility
+ ImGui::Text("%d/%d", mCurrentPageNumber + 1, mLastPage + 1);
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_ARROW_RIGHT, mCurrentPageNumber == mLastPage)) {
+ SetPage(mCurrentPageNumber + 1);
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_PLUS " " I18N_TEXT("Add", L10N_ADD))) {
+ // TODO
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_EDIT " " I18N_TEXT("Edit", L10N_EDIT), mSelectRow == -1)) {
+ ImGui::OpenPopup(mEditDialogTitle);
+ }
+ if (ImGui::BeginPopupModal(mEditDialogTitle, &dummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ auto& entry = (*mCurrentPage)[mSelectRow];
+ int entryIdx = GetFirstVisibleRowIdx() + mSelectRow;
+ EditEntry(entry, entryIdx, mSelectRow);
+ ImGui::EndPopup();
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_TRASH " " I18N_TEXT("Delete", L10N_DELETE), mSelectRow == -1)) {
+ // TODO
+ }
+
+ ImGui::Columns(2);
+ {
+ DisplayMainTable();
+ ImGui::NextColumn();
+
+ if (mSelectRow == -1) {
+ ImGui::TextWrapped("%s", I18N_TEXT("Select an entry to show associated deliveries", L10N_DATABASE_MESSAGE_NO_ORDER_SELECTED));
+ } else {
+ DisplayDeliveriesTable();
+ }
+ ImGui::NextColumn();
+ }
+ ImGui::Columns(1);
+ }
+
+ void SetPage(int page)
+ {
+ mCurrentPageNumber = page;
+ mCurrentPage = &LoadAndGetPage(page);
+ mSelectRow = -1;
+ }
+
+private:
+ static int CalcPageForRowId(int64_t entryIdx)
+ {
+ return entryIdx / kMaxEntriesPerPage;
+ }
+
+ /// Calculate range [begin, end) of index for the list of entries that are currently visible that the path-th page would show.
+ /// i.e. when there is a filter, look into \c mActiveEntryIndices; when there is no filter, use directly.
+ static std::pair<int64_t, int64_t> CalcRangeForPage(int page)
+ {
+ int begin = page * kMaxEntriesPerPage;
+ return { begin, begin + kMaxEntriesPerPage };
+ }
+
+ void DisplayMainTable()
+ {
+ if (ImGui::BeginTable("DataTable", kColumnCount, ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollX)) {
+
+ if constexpr (kHasCustomer) ImGui::TableSetupColumn(I18N_TEXT("Customer", L10N_DATABASE_COLUMN_CUSTOMER));
+ if constexpr (kHasDeadline) ImGui::TableSetupColumn(I18N_TEXT("Deadline", L10N_DATABASE_COLUMN_DEADLINE));
+ if constexpr (kHasFactory) ImGui::TableSetupColumn(I18N_TEXT("Factory", L10N_DATABASE_COLUMN_FACTORY));
+ if constexpr (kHasOrderTime) ImGui::TableSetupColumn(I18N_TEXT("Order time", L10N_DATABASE_COLUMN_ORDER_TIME));
+ if constexpr (kHasCompletionTime) ImGui::TableSetupColumn(I18N_TEXT("Completion time", L10N_DATABASE_COLUMN_DELIVERY_TIME));
+ if constexpr (kHasItems) ImGui::TableSetupColumn(I18N_TEXT("Items", L10N_DATABASE_COLUMN_ITEMS));
+ ImGui::TableHeadersRow();
+
+ if (mActiveFilter) {
+ // TODO
+ auto [begin, end] = CalcRangeForPage(mCurrentPageNumber);
+ end = std::min(end, (int64_t)mActiveEntries.size() - 1);
+ for (int i = begin; i < end; ++i) {
+ int rowIdx = mActiveEntries[i];
+ DisplayEntry(rowIdx);
+ }
+ } else {
+ int firstRowIdx = mCurrentPageNumber * kMaxEntriesPerPage;
+ auto& page = *mCurrentPage;
+
+ int end = std::min((int)page.size(), kMaxEntriesPerPage);
+ for (int i = 0; i < end; ++i) {
+ DisplayEntry(page[i], i, firstRowIdx + i);
+ }
+ }
+
+ ImGui::EndTable();
+ }
+ }
+
+ void DisplayEntry(int rowIdx)
+ {
+ // TODO
+ // auto [pageNumber, pageEntry] = SplitRowIndex(rowIdx);
+ // auto& entry = LoadAndGetPage(pageNumber)[pageEntry];
+ // DisplayEntry(entry, rowIdx);
+ }
+
+ void DisplayEntry(T& entry, int rowIdx, int entryIdx)
+ {
+ ImGui::PushID(rowIdx);
+ ImGui::TableNextRow();
+
+ if constexpr (kHasCustomer) {
+ ImGui::TableNextColumn();
+ if (ImGui::Selectable(entry.Customer.c_str(), mSelectRow == rowIdx, ImGuiSelectableFlags_SpanAllColumns)) {
+ mSelectRow = rowIdx;
+ }
+ }
+
+ if constexpr (kHasDeadline) {
+ ImGui::TableNextColumn();
+ ImGui::TextUnformatted(entry.Deadline.c_str());
+ }
+
+ if constexpr (kHasFactory) {
+ ImGui::TableNextColumn();
+ if (ImGui::Selectable(entry.Factory.c_str(), mSelectRow == rowIdx, ImGuiSelectableFlags_SpanAllColumns)) {
+ mSelectRow = rowIdx;
+ }
+ }
+
+ if constexpr (kHasOrderTime) {
+ ImGui::TableNextColumn();
+ ImGui::TextUnformatted(entry.OrderTime.c_str());
+ }
+
+ if constexpr (kHasCompletionTime) {
+ ImGui::TableNextColumn();
+ if (entry.DeliveryTime.empty()) {
+ ImGui::TextUnformatted(I18N_TEXT("Not delivered", L10N_DATABASE_MESSAGE_NOT_DELIVERED));
+ } else {
+ ImGui::TextUnformatted(entry.DeliveryTime.c_str());
+ }
+ }
+
+ if constexpr (kHasItems) {
+ ImGui::TableNextColumn();
+ if (ImGui::TreeNode(entry.ItemsSummary.c_str())) {
+ DrawItems(entry.Items);
+ ImGui::TreePop();
+ }
+ }
+
+ ImGui::PopID();
+ }
+
+ void EditEntry(T& entry, int rowIdx, int entryIdx)
+ {
+ // TODO
+ }
+
+ void DisplayDeliveriesTable()
+ {
+ if (ImGui::BeginTable("DeliveriesTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollX)) {
+
+ ImGui::TableSetupColumn(I18N_TEXT("Shipment time", L10N_DATABASE_COLUMN_SHIPMENT_TIME));
+ ImGui::TableSetupColumn(I18N_TEXT("Arrival time", L10N_DATABASE_COLUMN_ARRIVAL_TIME));
+ ImGui::TableSetupColumn(I18N_TEXT("Items", L10N_DATABASE_COLUMN_ITEMS));
+ ImGui::TableHeadersRow();
+
+ auto& entry = (*mCurrentPage)[mSelectRow];
+ auto& deliveries = entry.AssociatedDeliveries;
+ for (auto& delivery : deliveries) {
+ ImGui::TableNextRow();
+
+ ImGui::TableNextColumn();
+ ImGui::TextUnformatted(delivery.ShipmentTime.c_str());
+
+ ImGui::TableNextColumn();
+ ImGui::TextUnformatted(delivery.ArriveTime.c_str());
+
+ ImGui::TableNextColumn();
+ if (ImGui::TreeNode(delivery.ItemsSummary.c_str())) {
+ DrawItems(delivery.Items);
+ ImGui::TreePop();
+ }
+ }
+
+ ImGui::EndTable();
+ }
+ }
+
+ std::vector<Item> LoadItems(SQLite::Statement& stmt, int64_t id)
+ {
+ // clang-format off
+ DEFER { stmt.reset(); };
+ // clang-format on
+
+ stmt.bind(1, id);
+
+ std::vector<Item> entries;
+ int itemIdCol = stmt.getColumnIndex("ItemId");
+ int countCol = stmt.getColumnIndex("Count");
+ while (stmt.executeStep()) {
+ entries.push_back(Item{
+ .ItemId = stmt.getColumn(itemIdCol).getInt(),
+ .Count = stmt.getColumn(countCol).getInt(),
+ });
+ }
+
+ return entries;
+ }
+
+ std::string CreateItemsSummary(const std::vector<Item>& items)
+ {
+ if (items.empty()) {
+ return "<empty>";
+ }
+
+ std::string result;
+ for (int i = 0, max = std::min((int)items.size(), kSummaryItemCount); i < max; ++i) {
+ auto& name = mProject->Products.Find(items[i].ItemId)->GetName();
+ if (result.length() + name.length() > kSummaryMaxLength) {
+ break;
+ }
+
+ result += name;
+ result += ", ";
+ }
+
+ // Remove ", "
+ result.pop_back();
+ result.pop_back();
+
+ result += "...";
+
+ return result;
+ }
+
+ std::vector<DeliveryEntry> LoadDeliveriesEntries(int64_t orderId, DeliveryDirection type)
+ {
+ bool outgoingFlag;
+ switch (type) {
+ case DeliveryDirection::FactoryToWarehouse: outgoingFlag = false; break;
+ case DeliveryDirection::WarehouseToCustomer: outgoingFlag = true; break;
+ }
+
+ auto& stmt = mProject->Database.GetDeliveries().FilterByTypeAndId;
+ // clang-format off
+ DEFER { stmt.reset(); };
+ // clang-format on
+
+ stmt.bind(1, orderId);
+ stmt.bind(2, outgoingFlag);
+
+ std::vector<DeliveryEntry> entries;
+ int rowIdCol = stmt.getColumnIndex("Id");
+ int sendTimeCol = stmt.getColumnIndex("ShipmentTime");
+ int arrivalTimeCol = stmt.getColumnIndex("ArrivalTime");
+ while (stmt.executeStep()) {
+ auto items = LoadItems(
+ mProject->Database.GetDeliveries().GetItems,
+ stmt.getColumn(rowIdCol).getInt64());
+ auto summary = CreateItemsSummary(items);
+
+ entries.push_back(DeliveryEntry{
+ .Items = std::move(items),
+ .ItemsSummary = std::move(summary),
+ .ShipmentTime = TimeUtils::StringifyTimeStamp(stmt.getColumn(arrivalTimeCol).getInt64()),
+ .ArriveTime = TimeUtils::StringifyTimeStamp(stmt.getColumn(sendTimeCol).getInt64()),
+ .Direction = type,
+ });
+ }
+
+ return entries;
+ }
+
+ Page& LoadAndGetPage(int page)
+ {
+ auto iter = mPages.find(page);
+ if (iter != mPages.end()) {
+ return iter.value();
+ }
+
+ auto& stmt = *Statements.GetRows;
+ // clang-format off
+ DEFER { stmt.reset(); };
+ // clang-format on
+
+ stmt.bind(1, kMaxEntriesPerPage);
+ stmt.bind(2, page * kMaxEntriesPerPage);
+
+ // If a field flag is false, the column index won't be used (controlled by other if constexpr's downstream)
+ // so there is no UB here
+
+ // This column is always necessary (and present) because the deliveries table require it
+ int idCol = stmt.getColumnIndex("Id");
+
+ int customerCol;
+ if constexpr (kHasCustomer) customerCol = stmt.getColumnIndex("Customer");
+
+ int deadlineCol;
+ if constexpr (kHasDeadline) deadlineCol = stmt.getColumnIndex("Deadline");
+
+ int factoryCol;
+ if constexpr (kHasFactory) factoryCol = stmt.getColumnIndex("Factory");
+
+ int orderTimeCol;
+ if constexpr (kHasOrderTime) orderTimeCol = stmt.getColumnIndex("OrderTime");
+
+ int deliveryTimeCol;
+ if constexpr (kHasCompletionTime) deliveryTimeCol = stmt.getColumnIndex("DeliveryTime");
+
+ Page entries;
+ while (stmt.executeStep()) {
+ auto& entry = entries.emplace_back();
+
+ auto id = stmt.getColumn(idCol).getInt64();
+ entry.AssociatedDeliveries = LoadDeliveriesEntries(id, T::kType);
+
+ if constexpr (kHasItems) {
+ auto items = LoadItems(
+ *Statements.GetItems,
+ id);
+ auto itemsSummary = CreateItemsSummary(items);
+ entry.Items = std::move(items);
+ entry.ItemsSummary = std::move(itemsSummary);
+ }
+
+ if constexpr (kHasCustomer) {
+ auto customerId = stmt.getColumn(customerCol).getInt();
+ entry.Customer = mProject->Customers.Find(customerId)->GetName();
+ }
+
+ if constexpr (kHasDeadline) {
+ auto timeStamp = stmt.getColumn(deadlineCol).getInt64();
+ entry.Deadline = TimeUtils::StringifyTimeStamp(timeStamp);
+ }
+
+ if constexpr (kHasFactory) {
+ auto factoryId = stmt.getColumn(factoryCol).getInt();
+ entry.Factory = mProject->Factories.Find(factoryId)->GetName();
+ }
+
+ if constexpr (kHasOrderTime) {
+ auto timeStamp = stmt.getColumn(orderTimeCol).getInt64();
+ entry.OrderTime = TimeUtils::StringifyTimeStamp(timeStamp);
+ }
+
+ if constexpr (kHasCompletionTime) {
+ auto timeStamp = stmt.getColumn(deliveryTimeCol).getInt64();
+ entry.DeliveryTime = TimeUtils::StringifyTimeStamp(timeStamp);
+ }
+ }
+
+ auto [res, _] = mPages.try_emplace(page, std::move(entries));
+ return res.value();
+ }
+
+ void DrawItems(const std::vector<Item>& items)
+ {
+ for (auto& item : items) {
+ auto& name = mProject->Products.Find(item.ItemId)->GetName();
+ ImGui::Text("%s × %d", name.c_str(), item.Count);
+ }
+ }
+
+ void UpdateLastPage()
+ {
+ mLastPage = mActiveEntries.empty()
+ ? CalcPageForRowId(mRowCount)
+ : CalcPageForRowId(mActiveEntries.back());
+ }
+};
+
+class SalesTableView : public GenericTableView<SaleEntry>
+{
+public:
+ SalesTableView()
+ {
+ mEditDialogTitle = I18N_TEXT("Edit sales entry", L10N_DATABASE_SALES_VIEW_EDIT_DIALOG_TITLE);
+ }
+
+#pragma clang diagnostic push
+#pragma ide diagnostic ignored "HidingNonVirtualFunction"
+ void OnProjectChanged(Project* newProject)
+ {
+ auto& table = newProject->Database.GetSales();
+ Statements.GetRowCount = &table.GetRowCount;
+ Statements.GetRows = &table.GetRows;
+ Statements.GetItems = &table.GetItems;
+ // TODO
+ // stmts.FilterRowsStatement = ;
+
+ GenericTableView<SaleEntry>::OnProjectChanged(newProject);
+ }
+#pragma clang diagnostic pop
+};
+
+class PurchasesTableView : public GenericTableView<PurchaseEntry>
+{
+public:
+ PurchasesTableView()
+ {
+ mEditDialogTitle = I18N_TEXT("Edit purchase entry", L10N_DATABASE_PURCHASES_VIEW_EDIT_DIALOG_TITLE);
+ }
+
+#pragma clang diagnostic push
+#pragma ide diagnostic ignored "HidingNonVirtualFunction"
+ void OnProjectChanged(Project* newProject)
+ {
+ auto& table = newProject->Database.GetPurchases();
+ Statements.GetRowCount = &table.GetRowCount;
+ Statements.GetRows = &table.GetRows;
+ Statements.GetItems = &table.GetItems;
+ // TODO
+ // stmts.FilterRowsStatement = ;
+
+ GenericTableView<PurchaseEntry>::OnProjectChanged(newProject);
+ }
+#pragma clang diagnostic pop
+};
+} // namespace CPLT_UNITY_ID
+
+void UI::DatabaseViewTab()
+{
+ auto& gs = GlobalStates::GetInstance();
+
+ static Project* currentProject = nullptr;
+ static CPLT_UNITY_ID::SalesTableView sales;
+ static CPLT_UNITY_ID::PurchasesTableView purchases;
+
+ if (currentProject != gs.GetCurrentProject()) {
+ currentProject = gs.GetCurrentProject();
+ sales.OnProjectChanged(currentProject);
+ purchases.OnProjectChanged(currentProject);
+ }
+
+ if (ImGui::BeginTabBar("DatabaseViewTabs")) {
+ if (ImGui::BeginTabItem(I18N_TEXT("Sales", L10N_DATABASE_SALES_VIEW_TAB_NAME))) {
+ sales.Display();
+ ImGui::EndTabItem();
+ }
+ if (ImGui::BeginTabItem(I18N_TEXT("Purchases", L10N_DATABASE_PURCHASES_VIEW_TAB_NAME))) {
+ purchases.Display();
+ ImGui::EndTabItem();
+ }
+ ImGui::EndTabBar();
+ }
+}
diff --git a/app/source/Cplt/UI/UI_Items.cpp b/app/source/Cplt/UI/UI_Items.cpp
new file mode 100644
index 0000000..0170e1a
--- /dev/null
+++ b/app/source/Cplt/UI/UI_Items.cpp
@@ -0,0 +1,252 @@
+#include "UI.hpp"
+
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/Utils/I18n.hpp>
+
+#include <IconsFontAwesome.h>
+#include <imgui.h>
+#include <imgui_stdlib.h>
+
+namespace CPLT_UNITY_ID {
+
+enum class ActionResult
+{
+ Confirmed,
+ Canceled,
+ Pending,
+};
+
+/// \param list Item list that the item is in.
+/// \param item A non-null pointer to the currently being edited item. It should not change until this function returns a non-\c ActionResult::Pending value.
+template <class T>
+ActionResult ItemEditor(ItemList<T>& list, T* item)
+{
+ constexpr bool kHasDescription = requires(T t)
+ {
+ t.GetDescription();
+ };
+ constexpr bool kHasEmail = requires(T t)
+ {
+ t.GetEmail();
+ };
+
+ static bool duplicateName = false;
+
+ static std::string name;
+ static std::string description;
+ static std::string email;
+ if (name.empty()) {
+ name = item->GetName();
+ if constexpr (kHasDescription) description = item->GetDescription();
+ if constexpr (kHasEmail) email = item->GetEmail();
+ }
+
+ auto ClearStates = [&]() {
+ duplicateName = false;
+ name = {};
+ description = {};
+ };
+
+ if (ImGui::InputText(I18N_TEXT("Name", L10N_ITEM_COLUMN_NAME), &name)) {
+ duplicateName = name != item->GetName() && list.Find(name) != nullptr;
+ }
+ if constexpr (kHasDescription) ImGui::InputText(I18N_TEXT("Description", L10N_ITEM_COLUMN_DESCRIPTION), &description);
+ if constexpr (kHasEmail) ImGui::InputText(I18N_TEXT("Email", L10N_ITEM_COLUMN_EMAIL), &email);
+
+ if (name.empty()) {
+ ImGui::ErrorMessage(I18N_TEXT("Name cannot be empty", L10N_EMPTY_NAME_ERROR));
+ }
+ if (duplicateName) {
+ ImGui::ErrorMessage(I18N_TEXT("Duplicate name", L10N_DUPLICATE_NAME_ERROR));
+ }
+
+ // Return Value
+ auto rv = ActionResult::Pending;
+
+ if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM), name.empty() || duplicateName)) {
+ if (item->GetName() != name) {
+ item->SetName(std::move(name));
+ }
+ if constexpr (kHasDescription)
+ if (item->GetDescription() != description) {
+ item->SetDescription(std::move(description));
+ }
+ if constexpr (kHasEmail)
+ if (item->GetEmail() != email) {
+ item->SetEmail(std::move(email));
+ }
+
+ ImGui::CloseCurrentPopup();
+ ClearStates();
+ rv = ActionResult::Confirmed;
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ ClearStates();
+ rv = ActionResult::Canceled;
+ }
+
+ return rv;
+}
+
+template <class T>
+void ItemListEntries(ItemList<T>& list, int& selectedIdx)
+{
+ constexpr bool kHasDescription = requires(T t)
+ {
+ t.GetDescription();
+ };
+ constexpr bool kHasEmail = requires(T t)
+ {
+ t.GetEmail();
+ };
+ constexpr bool kHasStock = requires(T t)
+ {
+ t.GetPrice();
+ };
+ constexpr bool kHasPrice = requires(T t)
+ {
+ t.GetPrice();
+ };
+ constexpr int kColumns = 1 /* Name column */ + kHasDescription + kHasEmail + kHasStock + kHasPrice;
+
+ if (ImGui::BeginTable("", kColumns, ImGuiTableFlags_Borders)) {
+
+ ImGui::TableSetupColumn(I18N_TEXT("Name", L10N_ITEM_COLUMN_NAME));
+ if constexpr (kHasDescription) ImGui::TableSetupColumn(I18N_TEXT("Description", L10N_ITEM_COLUMN_DESCRIPTION));
+ if constexpr (kHasEmail) ImGui::TableSetupColumn(I18N_TEXT("Email", L10N_ITEM_COLUMN_EMAIL));
+ if constexpr (kHasStock) ImGui::TableSetupColumn(I18N_TEXT("Stock", L10N_ITEM_COLUMN_STOCK));
+ if constexpr (kHasPrice) ImGui::TableSetupColumn(I18N_TEXT("Price", L10N_ITEM_COLUMN_PRICE));
+ ImGui::TableHeadersRow();
+
+ size_t idx = 0;
+ for (auto& entry : list) {
+ if (entry.IsInvalid()) {
+ continue;
+ }
+
+ ImGui::TableNextRow();
+
+ ImGui::TableNextColumn();
+ if (ImGui::Selectable(entry.GetName().c_str(), selectedIdx == idx, ImGuiSelectableFlags_SpanAllColumns)) {
+ selectedIdx = idx;
+ }
+
+ if constexpr (kHasDescription) {
+ ImGui::TableNextColumn();
+ ImGui::TextUnformatted(entry.GetDescription().c_str());
+ }
+
+ if constexpr (kHasEmail) {
+ ImGui::TableNextColumn();
+ ImGui::TextUnformatted(entry.GetEmail().c_str());
+ }
+
+ if constexpr (kHasStock) {
+ ImGui::TableNextColumn();
+ ImGui::Text("%d", entry.GetStock());
+ }
+
+ if constexpr (kHasPrice) {
+ ImGui::TableNextColumn();
+ // TODO format in dollars
+ ImGui::Text("%d", entry.GetPrice());
+ }
+
+ idx++;
+ }
+ ImGui::EndTable();
+ }
+}
+
+template <class T>
+void ItemListEditor(ItemList<T>& list)
+{
+ bool opened = true;
+ static int selectedIdx = -1;
+ static T* editingItem = nullptr;
+
+ if (ImGui::Button(ICON_FA_PLUS " " I18N_TEXT("Add", L10N_ADD))) {
+ ImGui::SetNextWindowCentered();
+ ImGui::OpenPopup(I18N_TEXT("Add item", L10N_ITEM_ADD_DIALOG_TITLE));
+
+ editingItem = &list.Insert("");
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Add item", L10N_ITEM_ADD_DIALOG_TITLE), &opened, ImGuiWindowFlags_AlwaysAutoResize)) {
+ switch (ItemEditor(list, editingItem)) {
+ case ActionResult::Confirmed:
+ editingItem = nullptr;
+ break;
+ case ActionResult::Canceled:
+ list.Remove(editingItem->GetId());
+ editingItem = nullptr;
+ break;
+ default:
+ break;
+ }
+ ImGui::EndPopup();
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_EDIT " " I18N_TEXT("Edit", L10N_EDIT), selectedIdx == -1)) {
+ ImGui::SetNextWindowCentered();
+ ImGui::OpenPopup(I18N_TEXT("Edit item", L10N_ITEM_EDIT_DIALOG_TITLE));
+
+ editingItem = list.Find(selectedIdx);
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Edit item", L10N_ITEM_EDIT_DIALOG_TITLE), &opened, ImGuiWindowFlags_AlwaysAutoResize)) {
+ if (ItemEditor(list, editingItem) != ActionResult::Pending) {
+ editingItem = nullptr;
+ }
+ ImGui::EndPopup();
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_TRASH " " I18N_TEXT("Delete", L10N_DELETE), selectedIdx == -1)) {
+ ImGui::SetNextWindowCentered();
+ ImGui::OpenPopup(I18N_TEXT("Delete item", L10N_ITEM_DELETE_DIALOG_TITLE));
+
+ list.Remove(selectedIdx);
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Delete item", L10N_ITEM_DELETE_DIALOG_TITLE), &opened, ImGuiWindowFlags_AlwaysAutoResize)) {
+ ImGui::TextUnformatted(I18N_TEXT("Are you sure you want to delete this item?", L10N_ITEM_DELETE_DIALOG_MESSAGE));
+
+ if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM))) {
+ ImGui::CloseCurrentPopup();
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ }
+
+ ImGui::EndPopup();
+ }
+
+ ItemListEntries<T>(list, selectedIdx);
+}
+} // namespace CPLT_UNITY_ID
+
+void UI::ItemsTab()
+{
+ auto& gs = GlobalStates::GetInstance();
+
+ if (ImGui::BeginTabBar("ItemViewTabs")) {
+ if (ImGui::BeginTabItem(I18N_TEXT("Products", L10N_ITEM_CATEGORY_PRODUCT))) {
+ CPLT_UNITY_ID::ItemListEditor(gs.GetCurrentProject()->Products);
+ ImGui::EndTabItem();
+ }
+ if (ImGui::BeginTabItem(I18N_TEXT("Factories", L10N_ITEM_CATEGORY_FACTORY))) {
+ CPLT_UNITY_ID::ItemListEditor(gs.GetCurrentProject()->Factories);
+ ImGui::EndTabItem();
+ }
+ if (ImGui::BeginTabItem(I18N_TEXT("Customers", L10N_ITEM_CATEGORY_CUSTOMER))) {
+ CPLT_UNITY_ID::ItemListEditor(gs.GetCurrentProject()->Customers);
+ ImGui::EndTabItem();
+ }
+ ImGui::EndTabBar();
+ }
+}
diff --git a/app/source/Cplt/UI/UI_MainWindow.cpp b/app/source/Cplt/UI/UI_MainWindow.cpp
new file mode 100644
index 0000000..4653f79
--- /dev/null
+++ b/app/source/Cplt/UI/UI_MainWindow.cpp
@@ -0,0 +1,237 @@
+#include "UI.hpp"
+
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/Utils/I18n.hpp>
+
+#include <IconsFontAwesome.h>
+#include <imgui.h>
+#include <imgui_stdlib.h>
+#include <portable-file-dialogs.h>
+#include <filesystem>
+#include <memory>
+
+namespace fs = std::filesystem;
+
+namespace CPLT_UNITY_ID {
+void ProjectTab_Normal()
+{
+ auto& gs = GlobalStates::GetInstance();
+
+ if (ImGui::Button(ICON_FA_TIMES " " I18N_TEXT("Close", L10N_CLOSE))) {
+ gs.SetCurrentProject(nullptr);
+ return;
+ }
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_FOLDER " " I18N_TEXT("Open in filesystem", L10N_PROJECT_OPEN_IN_FILESYSTEM))) {
+ // TODO
+ }
+
+ ImGui::Text("%s %s", I18N_TEXT("Project name", L10N_PROJECT_NAME), gs.GetCurrentProject()->GetName().c_str());
+ ImGui::Text("%s %s", I18N_TEXT("Project path", L10N_PROJECT_PATH), gs.GetCurrentProject()->GetPathString().c_str());
+}
+
+void ProjectTab_NoProject()
+{
+ auto& gs = GlobalStates::GetInstance();
+
+ bool openedDummy = true;
+ bool openErrorDialog = false;
+ static std::string projectName;
+ static std::string dirName;
+ static fs::path dirPath;
+ static bool dirNameIsValid = false;
+
+ auto TrySelectPath = [&](fs::path newPath) {
+ if (fs::exists(newPath)) {
+ dirNameIsValid = true;
+ dirName = newPath.string();
+ dirPath = std::move(newPath);
+ } else {
+ dirNameIsValid = false;
+ }
+ };
+
+ if (ImGui::Button(I18N_TEXT("Create project....", L10N_PROJECT_NEW))) {
+ ImGui::SetNextWindowCentered();
+ ImGui::OpenPopup(I18N_TEXT("Create project wizard", L10N_PROJECT_NEW_DIALOG_TITLE));
+ }
+
+ // Make it so that the modal dialog has a close button
+ if (ImGui::BeginPopupModal(I18N_TEXT("Create project wizard", L10N_PROJECT_NEW_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ ImGui::InputTextWithHint("##ProjectName", I18N_TEXT("Project name", L10N_PROJECT_NAME), &projectName);
+
+ if (ImGui::InputTextWithHint("##ProjectPath", I18N_TEXT("Project path", L10N_PROJECT_PATH), &dirName)) {
+ // Changed, validate value
+ TrySelectPath(fs::path(dirName));
+ }
+ ImGui::SameLine();
+ if (ImGui::Button("...")) {
+ auto selection = pfd::select_folder(I18N_TEXT("Project path", L10N_PROJECT_NEW_PATH_DIALOG_TITLE)).result();
+ if (!selection.empty()) {
+ TrySelectPath(fs::path(selection));
+ }
+ }
+
+ if (projectName.empty()) {
+ ImGui::ErrorMessage("%s", I18N_TEXT("Name cannot be empty", L10N_EMPTY_NAME_ERROR));
+ }
+ if (!dirNameIsValid) {
+ ImGui::ErrorMessage("%s", I18N_TEXT("Invalid path", L10N_INVALID_PATH_ERROR));
+ }
+
+ ImGui::Spacing();
+
+ if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM), !dirNameIsValid || projectName.empty())) {
+ ImGui::CloseCurrentPopup();
+
+ gs.SetCurrentProject(std::make_unique<Project>(std::move(dirPath), std::move(projectName)));
+
+ // Dialog just got closed, reset states
+ projectName.clear();
+ dirName.clear();
+ dirPath.clear();
+ dirNameIsValid = false;
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ }
+
+ ImGui::EndPopup();
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Open project...", L10N_PROJECT_OPEN))) {
+ auto selection = pfd::open_file(I18N_TEXT("Open project", L10N_PROJECT_OPEN_DIALOG_TITLE)).result();
+ if (!selection.empty()) {
+ fs::path path(selection[0]);
+
+ try {
+ // Project's constructor wants a path to directory containing cplt_project.json
+ gs.SetCurrentProject(std::make_unique<Project>(path.parent_path()));
+ openErrorDialog = false;
+ } catch (const std::exception& e) {
+ openErrorDialog = true;
+ }
+ }
+ }
+
+ // TODO cleanup UI
+ // Recent projects
+
+ ImGui::Separator();
+ ImGui::TextUnformatted(I18N_TEXT("Recent projects", L10N_PROJECT_RECENTS));
+
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Clear", L10N_PROJECT_RECENTS_CLEAR))) {
+ gs.ClearRecentProjects();
+ }
+
+ auto& rp = gs.GetRecentProjects();
+ // End of vector is the most recently used, so that appending has less overhead
+ size_t toRemoveIdx = rp.size();
+
+ if (rp.empty()) {
+ ImGui::TextUnformatted(I18N_TEXT("No recent projects", L10N_PROJECT_RECENTS_NONE_PRESENT));
+ } else {
+ for (auto it = rp.rbegin(); it != rp.rend(); ++it) {
+ auto& [path, recent] = *it;
+ ImGui::TextUnformatted(recent.c_str());
+
+ size_t idx = std::distance(it, rp.rend()) - 1;
+ ImGui::PushID(idx);
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_FOLDER_OPEN)) {
+ try {
+ gs.SetCurrentProject(std::make_unique<Project>(path));
+ openErrorDialog = false;
+ } catch (const std::exception& e) {
+ openErrorDialog = true;
+ }
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip(I18N_TEXT("Open this project", L10N_PROJECT_RECENTS_OPEN_TOOLTIP));
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_TRASH)) {
+ toRemoveIdx = idx;
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip(I18N_TEXT("Delete this project from the Recent Projects list, the project itself will not be affected", L10N_PROJECT_RECENTS_DELETE_TOOLTIP));
+ }
+
+ ImGui::PopID();
+ }
+ }
+
+ if (toRemoveIdx != rp.size()) {
+ gs.RemoveRecentProject(toRemoveIdx);
+ }
+
+ if (openErrorDialog) {
+ ImGui::SetNextWindowCentered();
+ ImGui::OpenPopup(I18N_TEXT("Error", L10N_ERROR));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Error", L10N_ERROR), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ ImGui::ErrorMessage("%s", I18N_TEXT("Invalid project file", L10N_PROJECT_INVALID_PROJECT_FORMAT));
+ ImGui::EndPopup();
+ }
+}
+} // namespace CPLT_UNITY_ID
+
+void UI::MainWindow()
+{
+ auto& gs = GlobalStates::GetInstance();
+
+ auto windowSize = ImGui::GetMainViewport()->Size;
+ ImGui::SetNextWindowSize({ windowSize.x, windowSize.y });
+ ImGui::SetNextWindowPos({ 0, 0 });
+ ImGui::Begin("##MainWindow", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize);
+ if (ImGui::BeginTabBar("MainWindowTabs")) {
+ if (ImGui::BeginTabItem(ICON_FA_COGS " " I18N_TEXT("Settings", L10N_MAIN_TAB_SETTINGS))) {
+ UI::SettingsTab();
+ ImGui::EndTabItem();
+ }
+
+ if (ImGui::BeginTabItem(ICON_FA_FILE " " I18N_TEXT("Project", L10N_MAIN_WINDOW_TAB_PROJECT), nullptr)) {
+ if (gs.HasCurrentProject()) {
+ CPLT_UNITY_ID::ProjectTab_Normal();
+ } else {
+ CPLT_UNITY_ID::ProjectTab_NoProject();
+ }
+ ImGui::EndTabItem();
+ }
+ if (!gs.HasCurrentProject()) {
+ // No project open, simply skip all project specific tabs
+ goto endTab;
+ }
+
+ if (ImGui::BeginTabItem(ICON_FA_DATABASE " " I18N_TEXT("Data", L10N_MAIN_WINDOW_TAB_DATABASE_VIEW))) {
+ UI::DatabaseViewTab();
+ ImGui::EndTabItem();
+ }
+
+ if (ImGui::BeginTabItem(ICON_FA_BOX " " I18N_TEXT("Items", L10N_MAIN_WINDOW_TAB_ITEMS))) {
+ UI::ItemsTab();
+ ImGui::EndTabItem();
+ }
+
+ if (ImGui::BeginTabItem(ICON_FA_SCROLL " " I18N_TEXT("Workflows", L10N_MAIN_WINDOW_TAB_WORKFLOWS))) {
+ UI::WorkflowsTab();
+ ImGui::EndTabItem();
+ }
+
+ if (ImGui::BeginTabItem(ICON_FA_TABLE " " I18N_TEXT("Templates", L10N_MAIN_WINDOW_TAB_TEMPLATES))) {
+ UI::TemplatesTab();
+ ImGui::EndTabItem();
+ }
+
+ endTab:
+ ImGui::EndTabBar();
+ }
+ ImGui::End();
+}
diff --git a/app/source/Cplt/UI/UI_Settings.cpp b/app/source/Cplt/UI/UI_Settings.cpp
new file mode 100644
index 0000000..71a752a
--- /dev/null
+++ b/app/source/Cplt/UI/UI_Settings.cpp
@@ -0,0 +1,8 @@
+#include <Cplt/UI/UI.hpp>
+
+#include <imgui.h>
+
+void UI::SettingsTab()
+{
+ // TODO
+}
diff --git a/app/source/Cplt/UI/UI_Templates.cpp b/app/source/Cplt/UI/UI_Templates.cpp
new file mode 100644
index 0000000..e01a97d
--- /dev/null
+++ b/app/source/Cplt/UI/UI_Templates.cpp
@@ -0,0 +1,977 @@
+#include "UI.hpp"
+
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/Model/Template/TableTemplate.hpp>
+#include <Cplt/Model/Template/TableTemplateIterator.hpp>
+#include <Cplt/Model/Template/Template.hpp>
+#include <Cplt/Utils/I18n.hpp>
+
+#include <IconsFontAwesome.h>
+#include <imgui.h>
+#include <imgui_extra_math.h>
+#include <imgui_internal.h>
+#include <imgui_stdlib.h>
+#include <charconv>
+#include <fstream>
+#include <iostream>
+#include <utility>
+#include <variant>
+
+namespace CPLT_UNITY_ID {
+class TemplateUI
+{
+public:
+ static std::unique_ptr<TemplateUI> CreateByKind(std::unique_ptr<Template> tmpl);
+ static std::unique_ptr<TemplateUI> CreateByKind(Template::Kind kind);
+
+ virtual ~TemplateUI() = default;
+ virtual void Display() = 0;
+ virtual void Close() = 0;
+};
+
+// Table template styles
+constexpr ImU32 kSingleParamOutline = IM_COL32(255, 255, 0, 255);
+constexpr ImU32 kArrayGroupOutline = IM_COL32(255, 0, 0, 255);
+
+class TableTemplateUI : public TemplateUI
+{
+private:
+ std::unique_ptr<TableTemplate> mTable;
+
+ struct UICell
+ {
+ bool Hovered = false;
+ bool Held = false;
+ bool Selected = false;
+ };
+ std::vector<UICell> mUICells;
+
+ struct UIArrayGroup
+ {
+ ImVec2 Pos;
+ ImVec2 Size;
+ };
+ std::vector<UIArrayGroup> mUIArrayGroups;
+
+ struct Sizer
+ {
+ bool Hovered = false;
+ bool Held = false;
+ };
+ std::vector<Sizer> mRowSizers;
+ std::vector<Sizer> mColSizers;
+
+ /* Selection range */
+ Vec2i mSelectionTL;
+ Vec2i mSelectionBR;
+
+ /* Selection states */
+
+ /// "CStates" stands for "Constant cell selection States"
+ struct CStates
+ {
+ };
+
+ /// "SStates" stands for "Singular parameter selection States".
+ struct SStates
+ {
+ std::string EditBuffer;
+ bool ErrorDuplicateVarName;
+ bool HasLeftAG;
+ bool HasRightAG;
+ };
+
+ /// "AStates" stands for "Array group parameter selection States".
+ struct AStates
+ {
+ std::string EditBuffer;
+ bool ErrorDuplicateVarName;
+ };
+
+ // "RStates" stands for "Range selection States".
+ struct RStates
+ {
+ };
+
+ union
+ {
+ // Initialize to this element
+ std::monostate mIdleState{};
+ CStates mCS;
+ SStates mSS;
+ AStates mAS;
+ RStates mRS;
+ };
+
+ /* Table resizer dialog states */
+ int mNewTableWidth;
+ int mNewTableHeight;
+
+ /* Table states */
+ enum EditMode
+ {
+ ModeEditing,
+ ModeColumnResizing,
+ ModeRowResizing,
+ };
+ EditMode mMode = ModeEditing;
+
+ float mStartDragDim;
+ /// Depending on row/column sizer being dragged, this will be the y/x coordinate
+ float mStartDragMouseCoordinate;
+
+ bool mDirty = false;
+ bool mFirstDraw = true;
+
+public:
+ TableTemplateUI(std::unique_ptr<TableTemplate> table)
+ : mTable{ std::move(table) }
+ , mSelectionTL{ -1, -1 }
+ , mSelectionBR{ -1, -1 }
+ {
+ // TODO debug code
+ Resize(6, 5);
+ }
+
+ ~TableTemplateUI() override
+ {
+ // We can't move this to be a destructor of the union
+ // because that way it would run after the destruction of mTable
+ if (!IsSelected()) {
+ // Case: mIdleState
+ // Noop
+ } else if (mSelectionTL == mSelectionBR) {
+ switch (mTable->GetCell(mSelectionTL).Type) {
+ case TableCell::ConstantCell:
+ // Case: mCS
+ // Noop
+ break;
+
+ case TableCell::SingularParametricCell:
+ // Case: mSS
+ mSS.EditBuffer.std::string::~string();
+ break;
+
+ case TableCell::ArrayParametricCell:
+ // Case: mAS
+ mAS.EditBuffer.std::string::~string();
+ break;
+ }
+ } else {
+ // Case: mRS
+ // Noop
+ }
+ }
+
+ void Display() override
+ {
+ ImGui::Columns(2);
+ if (mFirstDraw) {
+ mFirstDraw = false;
+ ImGui::SetColumnWidth(0, ImGui::GetWindowWidth() * 0.15f);
+ }
+
+ DisplayInspector();
+ ImGui::NextColumn();
+
+ auto initialPos = ImGui::GetCursorPos();
+ DisplayTable();
+ DisplayTableResizers(initialPos);
+ ImGui::NextColumn();
+
+ ImGui::Columns(1);
+ }
+
+ void Close() override
+ {
+ // TODO
+ }
+
+ void Resize(int width, int height)
+ {
+ mTable->Resize(width, height);
+ mUICells.resize(width * height);
+ mUIArrayGroups.resize(mTable->GetArrayGroupCount());
+ mRowSizers.resize(width);
+ mColSizers.resize(height);
+
+ for (size_t i = 0; i < mUIArrayGroups.size(); ++i) {
+ auto& ag = mTable->GetArrayGroup(i);
+ auto& uag = mUIArrayGroups[i];
+
+ auto itemSpacing = ImGui::GetStyle().ItemSpacing;
+ uag.Pos.x = CalcTablePixelWidth() + itemSpacing.x;
+ uag.Pos.y = CalcTablePixelHeight() + itemSpacing.y;
+
+ uag.Size.x = mTable->GetRowHeight(ag.Row);
+ uag.Size.y = 0;
+ for (int x = ag.LeftCell; x <= ag.RightCell; ++x) {
+ uag.Size.y += mTable->GetColumnWidth(x);
+ }
+ }
+
+ mSelectionTL = { 0, 0 };
+ mSelectionBR = { 0, 0 };
+ }
+
+private:
+ void DisplayInspector()
+ {
+ bool openedDummy = true;
+
+ // This is an id, no need to localize
+ if (ImGui::BeginTabBar("Inspector")) {
+ if (ImGui::BeginTabItem(I18N_TEXT("Cell", L10N_TABLE_CELL))) {
+ if (!IsSelected()) {
+ ImGui::Text(I18N_TEXT("Select a cell to edit", L10N_TABLE_CELL_SELECT_MSG));
+ } else if (mSelectionTL == mSelectionBR) {
+ DisplayCellProperties(mSelectionTL);
+ } else {
+ DisplayRangeProperties(mSelectionTL, mSelectionBR);
+ }
+ ImGui::EndTabItem();
+ }
+
+ auto OpenPopup = [](const char* name) {
+ // Act as if ImGui::OpenPopup is executed in the previous id stack frame (tab bar level)
+ // Note: we can't simply use ImGui::GetItemID() here, because that would return the id of the ImGui::Button
+ auto tabBar = ImGui::GetCurrentContext()->CurrentTabBar;
+ auto id = tabBar->Tabs[tabBar->LastTabItemIdx].ID;
+ ImGui::PopID();
+ ImGui::OpenPopup(name);
+ ImGui::PushOverrideID(id);
+ };
+ if (ImGui::BeginTabItem(I18N_TEXT("Table", L10N_TABLE))) {
+ if (ImGui::Button(I18N_TEXT("Configure table properties...", L10N_TABLE_CONFIGURE_PROPERTIES))) {
+ mNewTableWidth = mTable->GetTableWidth();
+ mNewTableHeight = mTable->GetTableHeight();
+ OpenPopup(I18N_TEXT("Table properties", L10N_TABLE_PROPERTIES));
+ }
+
+ int mode = mMode;
+ ImGui::RadioButton(I18N_TEXT("Edit table", L10N_TABLE_EDIT_TABLE), &mode, ModeEditing);
+ ImGui::RadioButton(I18N_TEXT("Resize column widths", L10N_TABLE_EDIT_RESIZE_COLS), &mode, ModeColumnResizing);
+ ImGui::RadioButton(I18N_TEXT("Resize rows heights", L10N_TABLE_EDIT_RESIZE_ROWS), &mode, ModeRowResizing);
+ mMode = static_cast<EditMode>(mode);
+
+ // Table contents
+ DisplayTableContents();
+
+ ImGui::EndTabItem();
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Table properties", L10N_TABLE_PROPERTIES), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ DisplayTableProperties();
+ ImGui::EndPopup();
+ }
+
+ ImGui::EndTabBar();
+ }
+ }
+
+ static char NthUppercaseLetter(int n)
+ {
+ return (char)((int)'A' + n);
+ }
+
+ static void ExcelRow(int row, char* bufferBegin, char* bufferEnd)
+ {
+ auto res = std::to_chars(bufferBegin, bufferEnd, row);
+ if (res.ec != std::errc()) {
+ return;
+ }
+ }
+
+ static char* ExcelColumn(int column, char* bufferBegin, char* bufferEnd)
+ {
+ // https://stackoverflow.com/a/182924/11323702
+
+ int dividend = column;
+ int modulo;
+
+ char* writeHead = bufferEnd - 1;
+ *writeHead = '\0';
+ --writeHead;
+
+ while (dividend > 0) {
+ if (writeHead < bufferBegin) {
+ return nullptr;
+ }
+
+ modulo = (dividend - 1) % 26;
+
+ *writeHead = NthUppercaseLetter(modulo);
+ --writeHead;
+
+ dividend = (dividend - modulo) / 26;
+ }
+
+ // `writeHead` at this point would be a one-past-the-bufferEnd reverse iterator (i.e. one-past-the-(text)beginning in the bufferBegin)
+ // add 1 to get to the actual beginning of the text
+ return writeHead + 1;
+ }
+
+ void DisplayCellProperties(Vec2i pos)
+ {
+ auto& cell = mTable->GetCell(pos);
+ auto& uiCell = mUICells[pos.y * mTable->GetTableWidth() + pos.x];
+
+ char colStr[8]; // 2147483647 -> FXSHRXW, len == 7, along with \0
+ char* colBegin = ExcelColumn(pos.x + 1, std::begin(colStr), std::end(colStr));
+ char rowStr[11]; // len(2147483647) == 10, along with \0
+ ExcelRow(pos.y + 1, std::begin(rowStr), std::end(rowStr));
+ ImGui::Text(I18N_TEXT("Location: %s%s", L10N_TABLE_CELL_POS), colBegin, rowStr);
+
+ switch (cell.Type) {
+ case TableCell::ConstantCell:
+ ImGui::Text(I18N_TEXT("Type: Constant", L10N_TABLE_CELL_TYPE_CONST));
+ break;
+ case TableCell::SingularParametricCell:
+ ImGui::Text(I18N_TEXT("Type: Single parameter", L10N_TABLE_CELL_TYPE_PARAM));
+ break;
+ case TableCell::ArrayParametricCell:
+ ImGui::Text(I18N_TEXT("Type: Array group", L10N_TABLE_CELL_TYPE_CREATE_AG));
+ break;
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_EDIT)) {
+ ImGui::OpenPopup("ConvertCtxMenu");
+ }
+ if (ImGui::BeginPopup("ConvertCtxMenu")) {
+ bool constantEnabled = cell.Type != TableCell::ConstantCell;
+ if (ImGui::MenuItem(I18N_TEXT("Convert to regular cell", L10N_TABLE_CELL_CONV_CONST), nullptr, false, constantEnabled)) {
+ mTable->SetCellType(pos, TableCell::ConstantCell);
+ ResetCS();
+ }
+
+ bool singleEnabled = cell.Type != TableCell::SingularParametricCell;
+ if (ImGui::MenuItem(I18N_TEXT("Convert to parameter cell", L10N_TABLE_CELL_CONV_PARAM), nullptr, false, singleEnabled)) {
+ mTable->SetCellType(pos, TableCell::SingularParametricCell);
+ ResetSS(pos);
+ }
+
+ bool arrayEnabled = cell.Type != TableCell::ArrayParametricCell;
+ if (ImGui::MenuItem(I18N_TEXT("Add to a new array group", L10N_TABLE_CELL_CONV_CREATE_AG), nullptr, false, arrayEnabled)) {
+ mTable->AddArrayGroup(pos.y, pos.x, pos.x); // Use automatically generated name
+ ResetAS(pos);
+ }
+
+ bool leftEnabled = mSS.HasLeftAG && arrayEnabled;
+ if (ImGui::MenuItem(I18N_TEXT("Add to the array group to the left", L10N_TABLE_CELL_CONV_ADD_AG_LEFT), nullptr, false, leftEnabled)) {
+ auto& leftCell = mTable->GetCell({ pos.x - 1, pos.y });
+ mTable->ExtendArrayGroupRight(leftCell.DataId, 1);
+ ResetAS(pos);
+ }
+
+ bool rightEnabled = mSS.HasRightAG && arrayEnabled;
+ if (ImGui::MenuItem(I18N_TEXT("Add to the array group to the right", L10N_TABLE_CELL_CONV_ADD_AG_RIGHT), nullptr, false, rightEnabled)) {
+ auto& rightCell = mTable->GetCell({ pos.x + 1, pos.y });
+ mTable->ExtendArrayGroupLeft(rightCell.DataId, 1);
+ ResetAS(pos);
+ }
+
+ ImGui::EndPopup();
+ }
+
+ ImGui::Spacing();
+
+ constexpr auto kLeft = I18N_TEXT("Left", L10N_TABLE_CELL_ALIGN_LEFT);
+ constexpr auto kCenter = I18N_TEXT("Center", L10N_TABLE_CELL_ALIGN_CENTER);
+ constexpr auto kRight = I18N_TEXT("Right", L10N_TABLE_CELL_ALIGN_RIGHT);
+
+ const char* horizontalText;
+ switch (cell.HorizontalAlignment) {
+ case TableCell::AlignAxisMin: horizontalText = kLeft; break;
+ case TableCell::AlignCenter: horizontalText = kCenter; break;
+ case TableCell::AlignAxisMax: horizontalText = kRight; break;
+ }
+
+ if (ImGui::BeginCombo(I18N_TEXT("Horizontal alignment", L10N_TABLE_CELL_HORIZONTAL_ALIGNMENT), horizontalText)) {
+ if (ImGui::Selectable(kLeft, cell.HorizontalAlignment == TableCell::AlignAxisMin)) {
+ cell.HorizontalAlignment = TableCell::AlignAxisMin;
+ }
+ if (ImGui::Selectable(kCenter, cell.HorizontalAlignment == TableCell::AlignCenter)) {
+ cell.HorizontalAlignment = TableCell::AlignCenter;
+ }
+ if (ImGui::Selectable(kRight, cell.HorizontalAlignment == TableCell::AlignAxisMax)) {
+ cell.HorizontalAlignment = TableCell::AlignAxisMax;
+ }
+ ImGui::EndCombo();
+ }
+
+ constexpr auto kTop = I18N_TEXT("Left", L10N_TABLE_CELL_ALIGN_TOP);
+ constexpr auto kMiddle = I18N_TEXT("Middle", L10N_TABLE_CELL_ALIGN_MIDDLE);
+ constexpr auto kBottom = I18N_TEXT("Right", L10N_TABLE_CELL_ALIGN_BOTTOM);
+
+ const char* verticalText;
+ switch (cell.VerticalAlignment) {
+ case TableCell::AlignAxisMin: verticalText = kTop; break;
+ case TableCell::AlignCenter: verticalText = kMiddle; break;
+ case TableCell::AlignAxisMax: verticalText = kBottom; break;
+ }
+
+ if (ImGui::BeginCombo(I18N_TEXT("Vertical alignment", L10N_TABLE_CELL_VERTICAL_ALIGNMENT), verticalText)) {
+ if (ImGui::Selectable(kTop, cell.VerticalAlignment == TableCell::AlignAxisMin)) {
+ cell.VerticalAlignment = TableCell::AlignAxisMin;
+ }
+ if (ImGui::Selectable(kMiddle, cell.VerticalAlignment == TableCell::AlignCenter)) {
+ cell.VerticalAlignment = TableCell::AlignCenter;
+ }
+ if (ImGui::Selectable(kBottom, cell.VerticalAlignment == TableCell::AlignAxisMax)) {
+ cell.VerticalAlignment = TableCell::AlignAxisMax;
+ }
+ ImGui::EndCombo();
+ }
+
+ switch (cell.Type) {
+ case TableCell::ConstantCell:
+ ImGui::InputText(I18N_TEXT("Content", L10N_TABLE_CELL_CONTENT), &cell.Content);
+ break;
+
+ case TableCell::SingularParametricCell:
+ if (ImGui::InputText(I18N_TEXT("Variable name", L10N_TABLE_CELL_VAR_NAME), &mSS.EditBuffer)) {
+ // Sync name change to table
+ bool success = mTable->UpdateParameterName(cell.Content, mSS.EditBuffer);
+ if (success) {
+ // Flush name to display content
+ cell.Content = mSS.EditBuffer;
+ mSS.ErrorDuplicateVarName = false;
+ } else {
+ mSS.ErrorDuplicateVarName = true;
+ }
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip(I18N_TEXT("Name of the parameter link to this cell.", L10N_TABLE_CELL_VAR_TOOLTIP));
+ }
+
+ if (mSS.ErrorDuplicateVarName) {
+ ImGui::ErrorMessage(I18N_TEXT("Variable name duplicated.", L10N_TABLE_CELL_VAR_NAME_DUP));
+ }
+ break;
+
+ case TableCell::ArrayParametricCell:
+ if (ImGui::InputText(I18N_TEXT("Variable name", L10N_TABLE_CELL_VAR_NAME), &mAS.EditBuffer)) {
+ auto ag = mTable->GetArrayGroup(cell.DataId);
+ bool success = ag.UpdateCellName(cell.Content, mAS.EditBuffer);
+ if (success) {
+ cell.Content = mAS.EditBuffer;
+ mAS.ErrorDuplicateVarName = false;
+ } else {
+ mAS.ErrorDuplicateVarName = true;
+ }
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip(I18N_TEXT("Name of the parameter link to this cell; local within the array group.", L10N_TABLE_CELL_ARRAY_VAR_TOOLTIP));
+ }
+
+ if (mAS.ErrorDuplicateVarName) {
+ ImGui::ErrorMessage(I18N_TEXT("Variable name duplicated.", L10N_TABLE_CELL_VAR_NAME_DUP));
+ }
+ break;
+ }
+ }
+
+ void DisplayRangeProperties(Vec2i tl, Vec2i br)
+ {
+ // TODO
+ }
+
+ void DisplayTableContents()
+ {
+ if (ImGui::TreeNode(ICON_FA_BONG " " I18N_TEXT("Parameters", L10N_TABLE_SINGLE_PARAMS))) {
+ TableSingleParamsIter iter(*mTable);
+ while (iter.HasNext()) {
+ auto& cell = iter.Next();
+ if (ImGui::Selectable(cell.Content.c_str())) {
+ SelectCell(cell.Location);
+ }
+ }
+ ImGui::TreePop();
+ }
+ if (ImGui::TreeNode(ICON_FA_LIST " " I18N_TEXT("Array groups", L10N_TABLE_ARRAY_GROUPS))) {
+ TableArrayGroupsIter iter(*mTable);
+ // For each array group
+ while (iter.HasNext()) {
+ if (ImGui::TreeNode(iter.PeekNameCStr())) {
+ auto& ag = iter.Peek();
+ // For each cell in the array group
+ for (int x = ag.LeftCell; x <= ag.RightCell; ++x) {
+ Vec2i pos{ x, ag.Row };
+ auto& cell = mTable->GetCell(pos);
+ if (ImGui::Selectable(cell.Content.c_str())) {
+ SelectCell(pos);
+ }
+ }
+ ImGui::TreePop();
+ }
+ iter.Next();
+ }
+ ImGui::TreePop();
+ }
+ }
+
+ void DisplayTableProperties()
+ {
+ ImGui::InputInt(I18N_TEXT("Width", L10N_TABLE_WIDTH), &mNewTableWidth);
+ ImGui::InputInt(I18N_TEXT("Height", L10N_TABLE_HEIGHT), &mNewTableHeight);
+
+ if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM))) {
+ ImGui::CloseCurrentPopup();
+ Resize(mNewTableWidth, mNewTableHeight);
+ }
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ }
+
+ // TODO
+ }
+
+ void DisplayTable()
+ {
+ struct CellPalette
+ {
+ ImU32 Regular;
+ ImU32 Hovered;
+ ImU32 Active;
+
+ ImU32 GetColorFor(const UICell& cell) const
+ {
+ if (cell.Held) {
+ return Active;
+ } else if (cell.Hovered) {
+ return Hovered;
+ } else {
+ return Regular;
+ }
+ }
+ };
+
+ CellPalette constantPalette{
+ .Regular = ImGui::GetColorU32(ImGuiCol_Button),
+ .Hovered = ImGui::GetColorU32(ImGuiCol_ButtonHovered),
+ .Active = ImGui::GetColorU32(ImGuiCol_ButtonActive),
+ };
+ CellPalette paramPalette{
+ .Regular = IM_COL32(0, 214, 4, 102),
+ .Hovered = IM_COL32(0, 214, 4, 255),
+ .Active = IM_COL32(0, 191, 2, 255),
+ };
+
+ // TODO array group color
+
+ auto navHighlight = ImGui::GetColorU32(ImGuiCol_NavHighlight);
+
+ int colCount = mTable->GetTableWidth();
+ int rowCount = mTable->GetTableHeight();
+ for (int rowIdx = 0; rowIdx < rowCount; ++rowIdx) {
+ int rowHeight = mTable->GetRowHeight(rowIdx);
+
+ for (int colIdx = 0; colIdx < colCount; ++colIdx) {
+ int colWidth = mTable->GetColumnWidth(colIdx);
+
+ int i = rowIdx * colCount + colIdx;
+ auto window = ImGui::GetCurrentWindow();
+ auto id = window->GetID(i);
+
+ Vec2i cellLoc{ colIdx, rowIdx };
+ auto& cell = mTable->GetCell(cellLoc);
+ auto& uiCell = mUICells[i];
+
+ ImVec2 size(colWidth, rowHeight);
+ ImRect rect{
+ window->DC.CursorPos,
+ window->DC.CursorPos + ImGui::CalcItemSize(size, 0.0f, 0.0f),
+ };
+
+ /* Draw cell selection */
+
+ if (uiCell.Selected) {
+ constexpr int mt = 2; // Marker Thickness
+ constexpr int ms = 8; // Marker Size
+
+ ImVec2 outerTL(rect.Min - ImVec2(mt, mt));
+ ImVec2 outerBR(rect.Max + ImVec2(mt, mt));
+
+ // Top left
+ window->DrawList->AddRectFilled(outerTL + ImVec2(0, 0), outerTL + ImVec2(ms, mt), navHighlight);
+ window->DrawList->AddRectFilled(outerTL + ImVec2(0, mt), outerTL + ImVec2(mt, ms), navHighlight);
+
+ // Top right
+ ImVec2 outerTR(outerBR.x, outerTL.y);
+ window->DrawList->AddRectFilled(outerTR + ImVec2(-ms, 0), outerTR + ImVec2(0, mt), navHighlight);
+ window->DrawList->AddRectFilled(outerTR + ImVec2(-mt, mt), outerTR + ImVec2(0, ms), navHighlight);
+
+ // Bottom right
+ window->DrawList->AddRectFilled(outerBR + ImVec2(-ms, -mt), outerBR + ImVec2(0, 0), navHighlight);
+ window->DrawList->AddRectFilled(outerBR + ImVec2(-mt, -ms), outerBR + ImVec2(0, -mt), navHighlight);
+
+ // Bottom left
+ ImVec2 outerBL(outerTL.x, outerBR.y);
+ window->DrawList->AddRectFilled(outerBL + ImVec2(0, -mt), outerBL + ImVec2(ms, 0), navHighlight);
+ window->DrawList->AddRectFilled(outerBL + ImVec2(0, -ms), outerBL + ImVec2(mt, -mt), navHighlight);
+ }
+
+ /* Draw cell body */
+
+ CellPalette* palette;
+ switch (cell.Type) {
+ case TableCell::ConstantCell:
+ palette = &constantPalette;
+ break;
+
+ case TableCell::SingularParametricCell:
+ case TableCell::ArrayParametricCell:
+ palette = &paramPalette;
+ break;
+ }
+
+ window->DrawList->AddRectFilled(rect.Min, rect.Max, palette->GetColorFor(uiCell));
+
+ /* Draw cell content */
+
+ auto content = cell.Content.c_str();
+ auto contentEnd = content + cell.Content.size();
+ auto textSize = ImGui::CalcTextSize(content, contentEnd);
+
+ ImVec2 textRenderPos;
+ switch (cell.HorizontalAlignment) {
+ case TableCell::AlignAxisMin: textRenderPos.x = rect.Min.x; break;
+ case TableCell::AlignCenter: textRenderPos.x = rect.Min.x + colWidth / 2 - textSize.x / 2; break;
+ case TableCell::AlignAxisMax: textRenderPos.x = rect.Max.x - textSize.x; break;
+ }
+ switch (cell.VerticalAlignment) {
+ case TableCell::AlignAxisMin: textRenderPos.y = rect.Min.y; break;
+ case TableCell::AlignCenter: textRenderPos.y = rect.Min.y + rowHeight / 2 - textSize.y / 2; break;
+ case TableCell::AlignAxisMax: textRenderPos.y = rect.Max.y - textSize.y; break;
+ }
+ window->DrawList->AddText(textRenderPos, IM_COL32(0, 0, 0, 255), content, contentEnd);
+
+ /* Update ImGui cursor */
+
+ ImGui::ItemSize(size);
+ if (!ImGui::ItemAdd(rect, id)) {
+ goto logicEnd;
+ }
+
+ if (mMode != ModeEditing) {
+ goto logicEnd;
+ }
+ if (ImGui::ButtonBehavior(rect, id, &uiCell.Hovered, &uiCell.Held)) {
+ if (ImGui::GetIO().KeyShift && IsSelected()) {
+ SelectRange(mSelectionTL, { colIdx, rowIdx });
+ } else {
+ SelectCell({ colIdx, rowIdx });
+ }
+ }
+
+ logicEnd:
+ // Don't SameLine() if we are on the last cell in the row
+ if (colIdx != colCount - 1) {
+ ImGui::SameLine();
+ }
+ }
+ }
+
+ for (auto& uag : mUIArrayGroups) {
+ ImGui::GetCurrentWindow()->DrawList->AddRect(
+ uag.Pos - ImVec2(1, 1),
+ uag.Pos + uag.Size + ImVec2(1, 1),
+ kArrayGroupOutline);
+ }
+ }
+
+ void DisplayResizers(
+ ImVec2 pos,
+ ImVec2 sizerDim,
+ ImVec2 sizerOffset,
+ std::type_identity_t<float ImVec2::*> vecCompGetter,
+ std::type_identity_t<int (TableTemplate::*)() const> lenGetter,
+ std::type_identity_t<int (TableTemplate::*)(int) const> dimGetter,
+ std::type_identity_t<void (TableTemplate::*)(int, int)> dimSetter)
+ {
+ auto window = ImGui::GetCurrentWindow();
+ auto spacing = ImGui::GetStyle().ItemSpacing.*vecCompGetter;
+
+ auto regularColor = ImGui::GetColorU32(ImGuiCol_Button);
+ auto hoveredColor = ImGui::GetColorU32(ImGuiCol_ButtonHovered);
+ auto activeColor = ImGui::GetColorU32(ImGuiCol_ButtonActive);
+
+ auto GetColor = [&](const Sizer& sizer) -> ImU32 {
+ if (sizer.Held) {
+ return activeColor;
+ } else if (sizer.Hovered) {
+ return hoveredColor;
+ } else {
+ return regularColor;
+ }
+ };
+
+ for (int ix = 0; ix < (mTable.get()->*lenGetter)(); ++ix) {
+ // ImGui uses float for sizes, our table uses int (because excel uses int)
+ // Convert here to avoid mountains of narrowing warnings below
+ auto dim = (float)(mTable.get()->*dimGetter)(ix);
+
+ pos.*vecCompGetter += dim;
+ ImRect rect{
+ pos - sizerOffset,
+ pos - sizerOffset + ImGui::CalcItemSize(sizerDim, 0.0f, 0.0f),
+ };
+ pos.*vecCompGetter += spacing;
+
+ auto& sizer = mColSizers[ix];
+ auto id = window->GetID(ix);
+ window->DrawList->AddRectFilled(rect.Min, rect.Max, GetColor(sizer));
+
+ if (ImGui::ButtonBehavior(rect, id, &sizer.Hovered, &sizer.Held, ImGuiButtonFlags_PressedOnClick)) {
+ mStartDragDim = dim;
+ mStartDragMouseCoordinate = ImGui::GetMousePos().*vecCompGetter;
+ }
+ if (sizer.Held) {
+ float change = ImGui::GetMousePos().*vecCompGetter - mStartDragMouseCoordinate;
+ float colWidth = std::max(mStartDragDim + change, 1.0f);
+ (mTable.get()->*dimSetter)(ix, (int)colWidth);
+ }
+ }
+ }
+
+ void DisplayTableResizers(ImVec2 topLeftPixelPos)
+ {
+ constexpr float kExtraSideLength = 5.0f;
+ constexpr float kExtraAxialLength = 2.0f;
+
+ switch (mMode) {
+ case ModeEditing: break;
+
+ case ModeColumnResizing:
+ ImGui::PushID("Cols");
+ DisplayResizers(
+ topLeftPixelPos,
+ ImVec2(
+ ImGui::GetStyle().ItemSpacing.x + kExtraSideLength * 2,
+ CalcTablePixelHeight() + kExtraAxialLength * 2),
+ ImVec2(kExtraSideLength, kExtraAxialLength),
+ &ImVec2::x,
+ &TableTemplate::GetTableWidth,
+ &TableTemplate::GetColumnWidth,
+ &TableTemplate::SetColumnWidth);
+ ImGui::PopID();
+ break;
+
+ case ModeRowResizing:
+ ImGui::PushID("Rows");
+ DisplayResizers(
+ topLeftPixelPos,
+ ImVec2(
+ CalcTablePixelWidth() + kExtraAxialLength * 2,
+ ImGui::GetStyle().ItemSpacing.y + kExtraSideLength * 2),
+ ImVec2(kExtraAxialLength, kExtraSideLength),
+ &ImVec2::y,
+ &TableTemplate::GetTableHeight,
+ &TableTemplate::GetRowHeight,
+ &TableTemplate::SetRowHeight);
+ ImGui::PopID();
+ break;
+ }
+ }
+
+ float CalcTablePixelWidth() const
+ {
+ float horizontalSpacing = ImGui::GetStyle().ItemSpacing.x;
+ float width = 0;
+ for (int x = 0; x < mTable->GetTableWidth(); ++x) {
+ width += mTable->GetColumnWidth(x);
+ width += horizontalSpacing;
+ }
+ return width - horizontalSpacing;
+ }
+
+ float CalcTablePixelHeight() const
+ {
+ float verticalSpacing = ImGui::GetStyle().ItemSpacing.y;
+ float height = 0;
+ for (int y = 0; y < mTable->GetTableHeight(); ++y) {
+ height += mTable->GetRowHeight(y);
+ height += verticalSpacing;
+ }
+ return height - verticalSpacing;
+ }
+
+ template <class TFunction>
+ void ForeachSelectedCell(const TFunction& func)
+ {
+ for (int y = mSelectionTL.y; y <= mSelectionBR.y; ++y) {
+ for (int x = mSelectionTL.x; x <= mSelectionBR.x; ++x) {
+ int i = y * mTable->GetTableWidth() + x;
+ func(i, x, y);
+ }
+ }
+ }
+
+ bool IsSelected() const
+ {
+ return mSelectionTL.x != -1;
+ }
+
+ void ClearSelection()
+ {
+ if (IsSelected()) {
+ ForeachSelectedCell([this](int i, int, int) {
+ auto& uiCell = mUICells[i];
+ uiCell.Selected = false;
+ });
+ }
+
+ mSelectionTL = { -1, -1 };
+ mSelectionBR = { -1, -1 };
+
+ ResetIdleState();
+ }
+
+ void ResetIdleState()
+ {
+ mIdleState = {};
+ }
+
+ void SelectRange(Vec2i p1, Vec2i p2)
+ {
+ ClearSelection();
+
+ if (p2.x < p1.x) {
+ std::swap(p2.x, p1.x);
+ }
+ if (p2.y < p1.y) {
+ std::swap(p2.y, p1.y);
+ }
+
+ mSelectionTL = p1;
+ mSelectionBR = p2;
+
+ ForeachSelectedCell([this](int i, int, int) {
+ auto& uiCell = mUICells[i];
+ uiCell.Selected = true;
+ });
+
+ ResetRS();
+ }
+
+ void ResetRS()
+ {
+ mRS = {};
+ }
+
+ void SelectCell(Vec2i pos)
+ {
+ ClearSelection();
+
+ mSelectionTL = pos;
+ mSelectionBR = pos;
+
+ int i = pos.y * mTable->GetTableWidth() + pos.x;
+ mUICells[i].Selected = true;
+
+ switch (mTable->GetCell(pos).Type) {
+ case TableCell::ConstantCell: ResetCS(); break;
+ case TableCell::SingularParametricCell: ResetSS(pos); break;
+ case TableCell::ArrayParametricCell: ResetAS(pos); break;
+ }
+ }
+
+ void ResetCS()
+ {
+ mCS = {};
+ }
+
+ void ResetSS(Vec2i pos)
+ {
+ new (&mSS) SStates{
+ .EditBuffer = mTable->GetCell(pos).Content,
+ .ErrorDuplicateVarName = false,
+ .HasLeftAG = pos.x > 1 && mTable->GetCell({ pos.x - 1, pos.y }).Type == TableCell::ArrayParametricCell,
+ .HasRightAG = pos.x < mTable->GetTableWidth() - 1 && mTable->GetCell({ pos.x + 1, pos.y }).Type == TableCell::ArrayParametricCell,
+ };
+ }
+
+ void ResetAS(Vec2i pos)
+ {
+ new (&mAS) AStates{
+ .EditBuffer = mTable->GetCell(pos).Content,
+ .ErrorDuplicateVarName = false,
+ };
+ }
+};
+
+template <class TTarget>
+static auto CastTemplateAs(std::unique_ptr<Template>& input) requires std::is_base_of_v<Template, TTarget>
+{
+ return std::unique_ptr<TTarget>(static_cast<TTarget*>(input.release()));
+}
+
+std::unique_ptr<TemplateUI> TemplateUI::CreateByKind(std::unique_ptr<Template> tmpl)
+{
+ switch (tmpl->GetKind()) {
+ case Template::KD_Table: return std::make_unique<TableTemplateUI>(CastTemplateAs<TableTemplate>(tmpl));
+ case Template::InvalidKind: break;
+ }
+ return nullptr;
+}
+
+std::unique_ptr<TemplateUI> TemplateUI::CreateByKind(Template::Kind kind)
+{
+ switch (kind) {
+ case Template::KD_Table: return std::make_unique<TableTemplateUI>(std::make_unique<TableTemplate>());
+ case Template::InvalidKind: break;
+ }
+ return nullptr;
+}
+} // namespace CPLT_UNITY_ID
+
+void UI::TemplatesTab()
+{
+ auto& project = *GlobalStates::GetInstance().GetCurrentProject();
+
+ static std::unique_ptr<CPLT_UNITY_ID::TemplateUI> openTemplate;
+ static AssetList::ListState state;
+ bool openedDummy = true;
+
+ // Toolbar item: close
+ if (ImGui::Button(ICON_FA_TIMES " " I18N_TEXT("Close", L10N_CLOSE), openTemplate == nullptr)) {
+ openTemplate->Close();
+ openTemplate = nullptr;
+ }
+
+ // Toolbar item: open...
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Open asset...", L10N_ASSET_OPEN))) {
+ ImGui::OpenPopup(I18N_TEXT("Open asset", L10N_ASSET_OPEN_DIALOG_TITLE));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Open asset", L10N_ASSET_OPEN_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ if (ImGui::Button(ICON_FA_FOLDER_OPEN " " I18N_TEXT("Open", L10N_OPEN), state.SelectedAsset == nullptr)) {
+ ImGui::CloseCurrentPopup();
+
+ auto tmpl = project.Templates.Load(*state.SelectedAsset);
+ openTemplate = CPLT_UNITY_ID::TemplateUI::CreateByKind(std::move(tmpl));
+ }
+ ImGui::SameLine();
+ project.Templates.DisplayControls(state);
+ project.Templates.DisplayDetailsList(state);
+
+ ImGui::EndPopup();
+ }
+
+ // Toolbar item: manage...
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Manage assets...", L10N_ASSET_MANAGE))) {
+ ImGui::OpenPopup(I18N_TEXT("Manage assets", L10N_ASSET_MANAGE_DIALOG_TITLE));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Manage assets", L10N_ASSET_MANAGE_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ project.Templates.DisplayControls(state);
+ project.Templates.DisplayDetailsList(state);
+ ImGui::EndPopup();
+ }
+
+ if (openTemplate) {
+ openTemplate->Display();
+ }
+}
diff --git a/app/source/Cplt/UI/UI_Utils.cpp b/app/source/Cplt/UI/UI_Utils.cpp
new file mode 100644
index 0000000..a2bf692
--- /dev/null
+++ b/app/source/Cplt/UI/UI_Utils.cpp
@@ -0,0 +1,315 @@
+#include "UI.hpp"
+
+#include <IconsFontAwesome.h>
+#include <imgui.h>
+
+#define IMGUI_DEFINE_MATH_OPERATORS
+#include <imgui_internal.h>
+
+void ImGui::SetNextWindowSizeRelScreen(float xPercent, float yPercent, ImGuiCond cond)
+{
+ auto vs = ImGui::GetMainViewport()->Size;
+ ImGui::SetNextWindowSize({ vs.x * xPercent, vs.y * yPercent }, cond);
+}
+
+void ImGui::SetNextWindowCentered(ImGuiCond cond)
+{
+ auto vs = ImGui::GetMainViewport()->Size;
+ ImGui::SetNextWindowPos({ vs.x / 2, vs.y / 2 }, cond, { 0.5f, 0.5f });
+}
+
+void ImGui::PushDisabled()
+{
+ ImGui::PushItemFlag(ImGuiItemFlags_Disabled, true);
+ ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f * ImGui::GetStyle().Alpha);
+}
+
+void ImGui::PopDisabled()
+{
+ ImGui::PopItemFlag();
+ ImGui::PopStyleVar();
+}
+
+bool ImGui::Button(const char* label, bool disabled)
+{
+ return Button(label, ImVec2{}, disabled);
+}
+
+bool ImGui::Button(const char* label, const ImVec2& sizeArg, bool disabled)
+{
+ if (disabled) PushDisabled();
+ bool res = ImGui::Button(label, sizeArg);
+ if (disabled) PopDisabled();
+
+ // Help clang-tidy's static analyzer: if the button is disabled, res should always be false
+ assert(!disabled || (disabled && !res));
+
+ return res;
+}
+
+void ImGui::ErrorIcon()
+{
+ ImGui::PushStyleColor(ImGuiCol_Text, ImVec4{ 237 / 255.0f, 67 / 255.0f, 55 / 255.0f, 1.0f }); // #ED4337
+ ImGui::Text(ICON_FA_EXCLAMATION_CIRCLE);
+ ImGui::PopStyleColor();
+}
+
+void ImGui::ErrorMessage(const char* fmt, ...)
+{
+ ErrorIcon();
+ SameLine();
+
+ va_list args;
+ va_start(args, fmt);
+ TextV(fmt, args);
+ va_end(args);
+}
+
+void ImGui::WarningIcon()
+{
+ ImGui::PushStyleColor(ImGuiCol_Text, ImVec4{ 255 / 255.0f, 184 / 255.0f, 24 / 255.0f, 1.0f }); // #FFB818
+ ImGui::Text(ICON_FA_EXCLAMATION_TRIANGLE);
+ ImGui::PopStyleColor();
+}
+
+void ImGui::WarningMessage(const char* fmt, ...)
+{
+ WarningIcon();
+ SameLine();
+
+ va_list args;
+ va_start(args, fmt);
+ TextV(fmt, args);
+ va_end(args);
+}
+
+void ImGui::DrawIcon(ImDrawList* drawList, const ImVec2& a, const ImVec2& b, IconType type, bool filled, ImU32 color, ImU32 innerColor)
+{
+ // Taken from https://github.com/thedmd/imgui-node-editor/blob/master/examples/blueprints-example/utilities/drawing.cpp
+ // ax::NodeEditor::DrawIcon
+
+ // Brace style was adapted but no names are changed
+
+ auto rect = ImRect(a, b);
+ auto rect_x = rect.Min.x;
+ auto rect_y = rect.Min.y;
+ auto rect_w = rect.Max.x - rect.Min.x;
+ auto rect_h = rect.Max.y - rect.Min.y;
+ auto rect_center_x = (rect.Min.x + rect.Max.x) * 0.5f;
+ auto rect_center_y = (rect.Min.y + rect.Max.y) * 0.5f;
+ auto rect_center = ImVec2(rect_center_x, rect_center_y);
+ const auto outline_scale = rect_w / 24.0f;
+ const auto extra_segments = static_cast<int>(2 * outline_scale); // for full circle
+
+ if (type == IconType::Flow) {
+ const auto origin_scale = rect_w / 24.0f;
+
+ const auto offset_x = 1.0f * origin_scale;
+ const auto offset_y = 0.0f * origin_scale;
+ const auto margin = 2.0f * origin_scale;
+ const auto rounding = 0.1f * origin_scale;
+ const auto tip_round = 0.7f; // percentage of triangle edge (for tip)
+ //const auto edge_round = 0.7f; // percentage of triangle edge (for corner)
+ const auto canvas = ImRect(
+ rect.Min.x + margin + offset_x,
+ rect.Min.y + margin + offset_y,
+ rect.Max.x - margin + offset_x,
+ rect.Max.y - margin + offset_y);
+ const auto canvas_x = canvas.Min.x;
+ const auto canvas_y = canvas.Min.y;
+ const auto canvas_w = canvas.Max.x - canvas.Min.x;
+ const auto canvas_h = canvas.Max.y - canvas.Min.y;
+
+ const auto left = canvas_x + canvas_w * 0.5f * 0.3f;
+ const auto right = canvas_x + canvas_w - canvas_w * 0.5f * 0.3f;
+ const auto top = canvas_y + canvas_h * 0.5f * 0.2f;
+ const auto bottom = canvas_y + canvas_h - canvas_h * 0.5f * 0.2f;
+ const auto center_y = (top + bottom) * 0.5f;
+ //const auto angle = AX_PI * 0.5f * 0.5f * 0.5f;
+
+ const auto tip_top = ImVec2(canvas_x + canvas_w * 0.5f, top);
+ const auto tip_right = ImVec2(right, center_y);
+ const auto tip_bottom = ImVec2(canvas_x + canvas_w * 0.5f, bottom);
+
+ drawList->PathLineTo(ImVec2(left, top) + ImVec2(0, rounding));
+ drawList->PathBezierCurveTo(
+ ImVec2(left, top),
+ ImVec2(left, top),
+ ImVec2(left, top) + ImVec2(rounding, 0));
+ drawList->PathLineTo(tip_top);
+ drawList->PathLineTo(tip_top + (tip_right - tip_top) * tip_round);
+ drawList->PathBezierCurveTo(
+ tip_right,
+ tip_right,
+ tip_bottom + (tip_right - tip_bottom) * tip_round);
+ drawList->PathLineTo(tip_bottom);
+ drawList->PathLineTo(ImVec2(left, bottom) + ImVec2(rounding, 0));
+ drawList->PathBezierCurveTo(
+ ImVec2(left, bottom),
+ ImVec2(left, bottom),
+ ImVec2(left, bottom) - ImVec2(0, rounding));
+
+ if (!filled) {
+ if (innerColor & 0xFF000000) {
+ drawList->AddConvexPolyFilled(drawList->_Path.Data, drawList->_Path.Size, innerColor);
+ }
+
+ drawList->PathStroke(color, true, 2.0f * outline_scale);
+ } else {
+ drawList->PathFillConvex(color);
+ }
+ } else {
+ auto triangleStart = rect_center_x + 0.32f * rect_w;
+
+ auto rect_offset = -static_cast<int>(rect_w * 0.25f * 0.25f);
+
+ rect.Min.x += rect_offset;
+ rect.Max.x += rect_offset;
+ rect_x += rect_offset;
+ rect_center_x += rect_offset * 0.5f;
+ rect_center.x += rect_offset * 0.5f;
+
+ if (type == IconType::Circle) {
+ const auto c = rect_center;
+
+ if (!filled) {
+ const auto r = 0.5f * rect_w / 2.0f - 0.5f;
+
+ if (innerColor & 0xFF000000)
+ drawList->AddCircleFilled(c, r, innerColor, 12 + extra_segments);
+ drawList->AddCircle(c, r, color, 12 + extra_segments, 2.0f * outline_scale);
+ } else {
+ drawList->AddCircleFilled(c, 0.5f * rect_w / 2.0f, color, 12 + extra_segments);
+ }
+ }
+
+ if (type == IconType::Square) {
+ if (filled) {
+ const auto r = 0.5f * rect_w / 2.0f;
+ const auto p0 = rect_center - ImVec2(r, r);
+ const auto p1 = rect_center + ImVec2(r, r);
+
+ drawList->AddRectFilled(p0, p1, color, 0, 15 + extra_segments);
+ } else {
+ const auto r = 0.5f * rect_w / 2.0f - 0.5f;
+ const auto p0 = rect_center - ImVec2(r, r);
+ const auto p1 = rect_center + ImVec2(r, r);
+
+ if (innerColor & 0xFF000000)
+ drawList->AddRectFilled(p0, p1, innerColor, 0, 15 + extra_segments);
+
+ drawList->AddRect(p0, p1, color, 0, 15 + extra_segments, 2.0f * outline_scale);
+ }
+ }
+
+ if (type == IconType::Grid) {
+ const auto r = 0.5f * rect_w / 2.0f;
+ const auto w = ceilf(r / 3.0f);
+
+ const auto baseTl = ImVec2(floorf(rect_center_x - w * 2.5f), floorf(rect_center_y - w * 2.5f));
+ const auto baseBr = ImVec2(floorf(baseTl.x + w), floorf(baseTl.y + w));
+
+ auto tl = baseTl;
+ auto br = baseBr;
+ for (int i = 0; i < 3; ++i) {
+ tl.x = baseTl.x;
+ br.x = baseBr.x;
+ drawList->AddRectFilled(tl, br, color);
+ tl.x += w * 2;
+ br.x += w * 2;
+ if (i != 1 || filled)
+ drawList->AddRectFilled(tl, br, color);
+ tl.x += w * 2;
+ br.x += w * 2;
+ drawList->AddRectFilled(tl, br, color);
+
+ tl.y += w * 2;
+ br.y += w * 2;
+ }
+
+ triangleStart = br.x + w + 1.0f / 24.0f * rect_w;
+ }
+
+ if (type == IconType::RoundSquare) {
+ if (filled) {
+ const auto r = 0.5f * rect_w / 2.0f;
+ const auto cr = r * 0.5f;
+ const auto p0 = rect_center - ImVec2(r, r);
+ const auto p1 = rect_center + ImVec2(r, r);
+
+ drawList->AddRectFilled(p0, p1, color, cr, 15);
+ } else {
+ const auto r = 0.5f * rect_w / 2.0f - 0.5f;
+ const auto cr = r * 0.5f;
+ const auto p0 = rect_center - ImVec2(r, r);
+ const auto p1 = rect_center + ImVec2(r, r);
+
+ if (innerColor & 0xFF000000)
+ drawList->AddRectFilled(p0, p1, innerColor, cr, 15);
+
+ drawList->AddRect(p0, p1, color, cr, 15, 2.0f * outline_scale);
+ }
+ } else if (type == IconType::Diamond) {
+ if (filled) {
+ const auto r = 0.607f * rect_w / 2.0f;
+ const auto c = rect_center;
+
+ drawList->PathLineTo(c + ImVec2(0, -r));
+ drawList->PathLineTo(c + ImVec2(r, 0));
+ drawList->PathLineTo(c + ImVec2(0, r));
+ drawList->PathLineTo(c + ImVec2(-r, 0));
+ drawList->PathFillConvex(color);
+ } else {
+ const auto r = 0.607f * rect_w / 2.0f - 0.5f;
+ const auto c = rect_center;
+
+ drawList->PathLineTo(c + ImVec2(0, -r));
+ drawList->PathLineTo(c + ImVec2(r, 0));
+ drawList->PathLineTo(c + ImVec2(0, r));
+ drawList->PathLineTo(c + ImVec2(-r, 0));
+
+ if (innerColor & 0xFF000000)
+ drawList->AddConvexPolyFilled(drawList->_Path.Data, drawList->_Path.Size, innerColor);
+
+ drawList->PathStroke(color, true, 2.0f * outline_scale);
+ }
+ } else {
+ const auto triangleTip = triangleStart + rect_w * (0.45f - 0.32f);
+
+ drawList->AddTriangleFilled(
+ ImVec2(ceilf(triangleTip), rect_y + rect_h * 0.5f),
+ ImVec2(triangleStart, rect_center_y + 0.15f * rect_h),
+ ImVec2(triangleStart, rect_center_y - 0.15f * rect_h),
+ color);
+ }
+ }
+}
+
+void ImGui::Icon(const ImVec2& size, IconType type, bool filled, const ImVec4& color, const ImVec4& innerColor)
+{
+ // Taken from https://github.com/thedmd/imgui-node-editor/blob/master/examples/blueprints-example/utilities/widgets.cpp
+ // ax::NodeEditor::Icon
+
+ if (ImGui::IsRectVisible(size))
+ {
+ auto cursorPos = ImGui::GetCursorScreenPos();
+ auto drawList = ImGui::GetWindowDrawList();
+ DrawIcon(drawList, cursorPos, cursorPos + size, type, filled, ImColor(color), ImColor(innerColor));
+ }
+
+ ImGui::Dummy(size);
+}
+
+bool ImGui::Splitter(bool splitVertically, float thickness, float* size1, float* size2, float minSize1, float minSize2, float splitterLongAxisSize)
+{
+ // Taken from https://github.com/thedmd/imgui-node-editor/blob/master/examples/blueprints-example/blueprints-example.cpp
+ // ::Splitter
+
+ ImGuiContext& g = *GImGui;
+ ImGuiWindow* window = g.CurrentWindow;
+ ImGuiID id = window->GetID("##Splitter");
+ ImRect bb;
+ bb.Min = window->DC.CursorPos + (splitVertically ? ImVec2(*size1, 0.0f) : ImVec2(0.0f, *size1));
+ bb.Max = bb.Min + CalcItemSize(splitVertically ? ImVec2(thickness, splitterLongAxisSize) : ImVec2(splitterLongAxisSize, thickness), 0.0f, 0.0f);
+ return SplitterBehavior(bb, id, splitVertically ? ImGuiAxis_X : ImGuiAxis_Y, size1, size2, minSize1, minSize2, 0.0f);
+}
diff --git a/app/source/Cplt/UI/UI_Workflows.cpp b/app/source/Cplt/UI/UI_Workflows.cpp
new file mode 100644
index 0000000..5eea53a
--- /dev/null
+++ b/app/source/Cplt/UI/UI_Workflows.cpp
@@ -0,0 +1,293 @@
+#include "UI.hpp"
+
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/Model/Workflow/Nodes/DocumentNodes.hpp>
+#include <Cplt/Model/Workflow/Nodes/NumericNodes.hpp>
+#include <Cplt/Model/Workflow/Nodes/TextNodes.hpp>
+#include <Cplt/Model/Workflow/Nodes/UserInputNodes.hpp>
+#include <Cplt/Model/Workflow/Workflow.hpp>
+#include <Cplt/Utils/I18n.hpp>
+#include <Cplt/Utils/Macros.hpp>
+
+#include <IconsFontAwesome.h>
+#include <imgui.h>
+#include <imgui_node_editor.h>
+#include <imgui_stdlib.h>
+#include <memory>
+#include <span>
+#include <vector>
+
+namespace ImNodes = ax::NodeEditor;
+
+namespace CPLT_UNITY_ID {
+class WorkflowUI
+{
+private:
+ std::unique_ptr<Workflow> mWorkflow;
+
+ ImNodes::EditorContext* mContext;
+
+ ImNodes::NodeId mContextMenuNodeId = 0;
+ ImNodes::PinId mContextMenuPinId = 0;
+ ImNodes::LinkId mContextMenuLinkId = 0;
+
+public:
+ WorkflowUI(std::unique_ptr<Workflow> workflow)
+ : mWorkflow{ std::move(workflow) }
+ {
+ mContext = ImNodes::CreateEditor();
+ }
+
+ ~WorkflowUI()
+ {
+ ImNodes::DestroyEditor(mContext);
+ }
+
+ void Display()
+ {
+ ImNodes::SetCurrentEditor(mContext);
+ ImNodes::Begin("");
+
+ // Defer creation of tooltip because within the node editor, cursor positioning is going to be off
+ const char* tooltipMessage = nullptr;
+
+ for (auto& node : mWorkflow->GetNodes()) {
+ if (!node) continue;
+
+ ImNodes::BeginNode(node->GetId());
+ node->Draw();
+ ImNodes::EndNode();
+ }
+
+ for (auto& conn : mWorkflow->GetConnections()) {
+ if (!conn.IsValid()) continue;
+
+ auto srcId = mWorkflow->GetNodes()[conn.SourceNode]->GetOutputPinUniqueId(conn.SourcePin);
+ auto dstId = mWorkflow->GetNodes()[conn.DestinationNode]->GetInputPinUniqueId(conn.DestinationPin);
+ ImNodes::Link(conn.GetLinkId(), srcId, dstId);
+ }
+
+ if (ImNodes::BeginCreate()) {
+ ImNodes::PinId src = 0, dst = 0;
+ if (ImNodes::QueryNewLink(&src, &dst)) {
+ if (!src || !dst) {
+ goto createError;
+ }
+
+ auto [srcNode, srcPinId, srcIsOutput] = mWorkflow->DisassembleGlobalPinId(src);
+ auto [dstNode, dstPinId, dstIsOutput] = mWorkflow->DisassembleGlobalPinId(dst);
+
+ if (srcNode == dstNode) {
+ ImNodes::RejectNewItem();
+ goto createError;
+ }
+
+ if (srcIsOutput == dstIsOutput) {
+ ImNodes::RejectNewItem();
+ goto createError;
+ }
+
+ auto srcPin = srcNode->GetOutputPin(srcPinId);
+ auto dstPin = dstNode->GetOutputPin(dstPinId);
+
+ if (srcPin.MatchingType != dstPin.MatchingType) {
+ ImNodes::RejectNewItem();
+ goto createError;
+ }
+
+ if (ImNodes::AcceptNewItem()) {
+ mWorkflow->Connect(*srcNode, srcPinId, *dstNode, dstPinId);
+ }
+ }
+
+ ImNodes::PinId newNodePin = 0;
+ if (ImNodes::QueryNewNode(&newNodePin)) {
+ auto [node, pinId, isOutput] = mWorkflow->DisassembleGlobalPinId(newNodePin);
+
+ if ((isOutput && node->GetOutputPin(pinId).IsConnected()) ||
+ (!isOutput && node->GetInputPin(pinId).IsConnected()))
+ {
+ ImNodes::RejectNewItem();
+ goto createError;
+ }
+
+ if (ImNodes::AcceptNewItem()) {
+ ImNodes::Suspend();
+ ImGui::BeginPopup("CreateNodeCtxMenu");
+ ImNodes::Resume();
+ }
+ }
+ }
+ createError:
+ ImNodes::EndCreate();
+
+ if (ImNodes::BeginDelete()) {
+ ImNodes::LinkId deletedLinkId;
+ if (ImNodes::QueryDeletedLink(&deletedLinkId)) {
+ auto& conn = *mWorkflow->GetConnectionByLinkId(deletedLinkId);
+ mWorkflow->RemoveConnection(conn.Id);
+ }
+
+ ImNodes::NodeId deletedNodeId;
+ if (ImNodes::QueryDeletedNode(&deletedNodeId)) {
+ auto node = mWorkflow->GetNodeByNodeId(deletedNodeId);
+ if (!node) {
+ ImNodes::RejectDeletedItem();
+ goto deleteError;
+ }
+
+ if (node->IsLocked()) {
+ ImNodes::RejectDeletedItem();
+ goto deleteError;
+ }
+ }
+ }
+ deleteError:
+ ImNodes::EndDelete();
+
+ // Popups
+ ImNodes::Suspend();
+ if (ImNodes::ShowNodeContextMenu(&mContextMenuNodeId)) {
+ ImGui::OpenPopup("NodeCtxMenu");
+ } else if (ImNodes::ShowPinContextMenu(&mContextMenuPinId)) {
+ ImGui::OpenPopup("PinCtxMenu");
+ } else if (ImNodes::ShowLinkContextMenu(&mContextMenuLinkId)) {
+ ImGui::OpenPopup("LinkCtxMenu");
+ }
+
+ if (ImGui::BeginPopup("NodeCtxMenu")) {
+ auto& node = *mWorkflow->GetNodeByNodeId(mContextMenuNodeId);
+ node.DrawDebugInfo();
+
+ if (ImGui::MenuItem(ICON_FA_TRASH " " I18N_TEXT("Delete", L10N_DELETE))) {
+ ImNodes::DeleteNode(mContextMenuNodeId);
+ }
+
+ ImGui::EndPopup();
+ }
+
+ if (ImGui::BeginPopup("PinCtxMenu")) {
+ auto [node, pinId, isOutput] = mWorkflow->DisassembleGlobalPinId(mContextMenuPinId);
+ if (isOutput) {
+ node->DrawOutputPinDebugInfo(pinId);
+ } else {
+ node->DrawInputPinDebugInfo(pinId);
+ }
+
+ if (ImGui::MenuItem(ICON_FA_UNLINK " " I18N_TEXT("Disconnect", L10N_DISCONNECT))) {
+ if (isOutput) {
+ auto& pin = node->GetOutputPin(pinId);
+ if (pin.IsConnected()) {
+ auto linkId = mWorkflow->GetConnectionById(pin.Connection)->GetLinkId();
+ ImNodes::DeleteLink(linkId);
+ }
+ } else {
+ auto& pin = node->GetInputPin(pinId);
+ if (pin.IsConstantConnection()) {
+ // TODO
+ } else if (pin.IsConnected()) {
+ auto linkId = mWorkflow->GetConnectionById(pin.Connection)->GetLinkId();
+ ImNodes::DeleteLink(linkId);
+ }
+ }
+ }
+
+ ImGui::EndPopup();
+ }
+
+ if (ImGui::BeginPopup("LinkCtxMenu")) {
+ auto& conn = *mWorkflow->GetConnectionByLinkId(mContextMenuLinkId);
+ conn.DrawDebugInfo();
+
+ if (ImGui::MenuItem(ICON_FA_TRASH " " I18N_TEXT("Delete", L10N_DELETE))) {
+ ImNodes::DeleteLink(mContextMenuLinkId);
+ }
+
+ ImGui::EndPopup();
+ }
+
+ if (ImGui::BeginPopup("CreateNodeCtxMenu")) {
+ for (int i = WorkflowNode::CG_Numeric; i < WorkflowNode::InvalidCategory; ++i) {
+ auto category = (WorkflowNode::Category)i;
+ auto members = WorkflowNode::QueryCategoryMembers(category);
+
+ if (ImGui::BeginMenu(WorkflowNode::FormatCategory(category))) {
+ for (auto member : members) {
+ if (ImGui::MenuItem(WorkflowNode::FormatKind(member))) {
+ // Create node
+ auto uptr = WorkflowNode::CreateByKind(member);
+ mWorkflow->AddNode(std::move(uptr));
+ }
+ }
+ ImGui::EndMenu();
+ }
+ }
+ ImGui::EndPopup();
+ }
+
+ if (tooltipMessage) {
+ ImGui::BeginTooltip();
+ ImGui::TextUnformatted(tooltipMessage);
+ ImGui::EndTooltip();
+ }
+ ImNodes::Resume();
+
+ ImNodes::End();
+ }
+
+ void Close()
+ {
+ // TODO
+ }
+};
+} // namespace CPLT_UNITY_ID
+
+void UI::WorkflowsTab()
+{
+ auto& project = *GlobalStates::GetInstance().GetCurrentProject();
+
+ static std::unique_ptr<CPLT_UNITY_ID::WorkflowUI> openWorkflow;
+ static AssetList::ListState state;
+ bool openedDummy = true;
+
+ // Toolbar item: close
+ if (ImGui::Button(ICON_FA_TIMES " " I18N_TEXT("Close", L10N_CLOSE), openWorkflow == nullptr)) {
+ openWorkflow->Close();
+ openWorkflow = nullptr;
+ }
+
+ // Toolbar item: open...
+ ImGui::SameLine();
+ if (ImGui::Button((I18N_TEXT("Open asset...", L10N_ASSET_OPEN)))) {
+ ImGui::OpenPopup(I18N_TEXT("Open asset", L10N_ASSET_OPEN_DIALOG_TITLE));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Open asset", L10N_ASSET_OPEN_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ if (ImGui::Button(ICON_FA_FOLDER_OPEN " " I18N_TEXT("Open", L10N_OPEN), state.SelectedAsset == nullptr)) {
+ ImGui::CloseCurrentPopup();
+
+ auto workflow = project.Workflows.Load(*state.SelectedAsset);
+ openWorkflow = std::make_unique<CPLT_UNITY_ID::WorkflowUI>(std::move(workflow));
+ }
+ ImGui::SameLine();
+ project.Workflows.DisplayControls(state);
+ project.Workflows.DisplayDetailsList(state);
+
+ ImGui::EndPopup();
+ }
+
+ // Toolbar item: manage...
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Manage assets...", L10N_ASSET_MANAGE))) {
+ ImGui::OpenPopup(I18N_TEXT("Manage assets", L10N_ASSET_MANAGE_DIALOG_TITLE));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Manage assets", L10N_ASSET_MANAGE_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ project.Workflows.DisplayControls(state);
+ project.Workflows.DisplayDetailsList(state);
+ ImGui::EndPopup();
+ }
+
+ if (openWorkflow) {
+ openWorkflow->Display();
+ }
+}
diff --git a/app/source/Cplt/UI/fwd.hpp b/app/source/Cplt/UI/fwd.hpp
new file mode 100644
index 0000000..756e567
--- /dev/null
+++ b/app/source/Cplt/UI/fwd.hpp
@@ -0,0 +1,6 @@
+#pragma once
+
+// UI.hpp
+namespace ImGui {
+enum class IconType;
+}