aboutsummaryrefslogtreecommitdiff
path: root/app/source/Cplt
diff options
context:
space:
mode:
Diffstat (limited to 'app/source/Cplt')
-rw-r--r--app/source/Cplt/Entrypoint/Backend.hpp23
-rw-r--r--app/source/Cplt/Entrypoint/Backend_DirectX11.cpp250
-rw-r--r--app/source/Cplt/Entrypoint/Backend_DirectX12.cpp470
-rw-r--r--app/source/Cplt/Entrypoint/Backend_Metal.mm40
-rw-r--r--app/source/Cplt/Entrypoint/Backend_OpenGL2.cpp106
-rw-r--r--app/source/Cplt/Entrypoint/Backend_OpenGL3.cpp121
-rw-r--r--app/source/Cplt/Entrypoint/Backend_Vulkan.cpp438
-rw-r--r--app/source/Cplt/Entrypoint/main.cpp163
-rw-r--r--app/source/Cplt/Locale/zh_CN.h159
-rw-r--r--app/source/Cplt/Model/Assets.cpp306
-rw-r--r--app/source/Cplt/Model/Assets.hpp130
-rw-r--r--app/source/Cplt/Model/Database.cpp163
-rw-r--r--app/source/Cplt/Model/Database.hpp79
-rw-r--r--app/source/Cplt/Model/Filter.cpp1
-rw-r--r--app/source/Cplt/Model/Filter.hpp6
-rw-r--r--app/source/Cplt/Model/GlobalStates.cpp163
-rw-r--r--app/source/Cplt/Model/GlobalStates.hpp55
-rw-r--r--app/source/Cplt/Model/Items.cpp114
-rw-r--r--app/source/Cplt/Model/Items.hpp253
-rw-r--r--app/source/Cplt/Model/Project.cpp168
-rw-r--r--app/source/Cplt/Model/Project.hpp57
-rw-r--r--app/source/Cplt/Model/Template/TableTemplate.cpp591
-rw-r--r--app/source/Cplt/Model/Template/TableTemplate.hpp223
-rw-r--r--app/source/Cplt/Model/Template/TableTemplateIterator.cpp52
-rw-r--r--app/source/Cplt/Model/Template/TableTemplateIterator.hpp35
-rw-r--r--app/source/Cplt/Model/Template/Template.hpp68
-rw-r--r--app/source/Cplt/Model/Template/Template_Main.cpp214
-rw-r--r--app/source/Cplt/Model/Template/Template_RTTI.cpp29
-rw-r--r--app/source/Cplt/Model/Template/fwd.hpp11
-rw-r--r--app/source/Cplt/Model/Workflow/Evaluation.cpp174
-rw-r--r--app/source/Cplt/Model/Workflow/Evaluation.hpp67
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.cpp18
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.hpp13
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/NumericNodes.cpp94
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/NumericNodes.hpp44
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/TextNodes.cpp231
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/TextNodes.hpp53
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.cpp32
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.hpp23
-rw-r--r--app/source/Cplt/Model/Workflow/Nodes/fwd.hpp15
-rw-r--r--app/source/Cplt/Model/Workflow/Value.hpp94
-rw-r--r--app/source/Cplt/Model/Workflow/ValueInternals.hpp21
-rw-r--r--app/source/Cplt/Model/Workflow/Value_Main.cpp35
-rw-r--r--app/source/Cplt/Model/Workflow/Value_RTTI.cpp174
-rw-r--r--app/source/Cplt/Model/Workflow/Values/Basic.cpp111
-rw-r--r--app/source/Cplt/Model/Workflow/Values/Basic.hpp67
-rw-r--r--app/source/Cplt/Model/Workflow/Values/Database.cpp88
-rw-r--r--app/source/Cplt/Model/Workflow/Values/Database.hpp51
-rw-r--r--app/source/Cplt/Model/Workflow/Values/Dictionary.cpp49
-rw-r--r--app/source/Cplt/Model/Workflow/Values/Dictionary.hpp25
-rw-r--r--app/source/Cplt/Model/Workflow/Values/List.cpp100
-rw-r--r--app/source/Cplt/Model/Workflow/Values/List.hpp50
-rw-r--r--app/source/Cplt/Model/Workflow/Values/fwd.hpp17
-rw-r--r--app/source/Cplt/Model/Workflow/Workflow.hpp316
-rw-r--r--app/source/Cplt/Model/Workflow/Workflow_Main.cpp846
-rw-r--r--app/source/Cplt/Model/Workflow/Workflow_RTTI.cpp143
-rw-r--r--app/source/Cplt/Model/Workflow/fwd.hpp22
-rw-r--r--app/source/Cplt/Model/fwd.hpp35
-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
-rw-r--r--app/source/Cplt/Utils/Color.hpp202
-rw-r--r--app/source/Cplt/Utils/Hash.hpp15
-rw-r--r--app/source/Cplt/Utils/I18n.hpp10
-rw-r--r--app/source/Cplt/Utils/IO/Archive.cpp57
-rw-r--r--app/source/Cplt/Utils/IO/Archive.hpp18
-rw-r--r--app/source/Cplt/Utils/IO/CstdioFile.cpp36
-rw-r--r--app/source/Cplt/Utils/IO/CstdioFile.hpp17
-rw-r--r--app/source/Cplt/Utils/IO/DataStream.cpp283
-rw-r--r--app/source/Cplt/Utils/IO/DataStream.hpp210
-rw-r--r--app/source/Cplt/Utils/IO/FileStream.cpp7
-rw-r--r--app/source/Cplt/Utils/IO/FileStream.hpp97
-rw-r--r--app/source/Cplt/Utils/IO/FileStream_Cstdio.inl126
-rw-r--r--app/source/Cplt/Utils/IO/FileStream_Custom.inl358
-rw-r--r--app/source/Cplt/Utils/IO/Helper.hpp43
-rw-r--r--app/source/Cplt/Utils/IO/StringIntegration.hpp37
-rw-r--r--app/source/Cplt/Utils/IO/TslArrayIntegration.hpp50
-rw-r--r--app/source/Cplt/Utils/IO/TslRobinIntegration.hpp78
-rw-r--r--app/source/Cplt/Utils/IO/UuidIntegration.hpp27
-rw-r--r--app/source/Cplt/Utils/IO/VectorIntegration.hpp42
-rw-r--r--app/source/Cplt/Utils/IO/fwd.hpp13
-rw-r--r--app/source/Cplt/Utils/Macros.hpp13
-rw-r--r--app/source/Cplt/Utils/Math.hpp11
-rw-r--r--app/source/Cplt/Utils/RTTI.hpp49
-rw-r--r--app/source/Cplt/Utils/ScopeGuard.hpp39
-rw-r--r--app/source/Cplt/Utils/Sigslot.cpp233
-rw-r--r--app/source/Cplt/Utils/Sigslot.hpp165
-rw-r--r--app/source/Cplt/Utils/Size.hpp65
-rw-r--r--app/source/Cplt/Utils/StandardDirectories.cpp78
-rw-r--r--app/source/Cplt/Utils/StandardDirectories.hpp10
-rw-r--r--app/source/Cplt/Utils/Time.cpp29
-rw-r--r--app/source/Cplt/Utils/Time.hpp11
-rw-r--r--app/source/Cplt/Utils/UUID.hpp5
-rw-r--r--app/source/Cplt/Utils/Variant.hpp33
-rw-r--r--app/source/Cplt/Utils/Vector.hpp144
-rw-r--r--app/source/Cplt/Utils/VectorHash.hpp46
-rw-r--r--app/source/Cplt/Utils/fwd.hpp17
-rw-r--r--app/source/Cplt/fwd.hpp5
104 files changed, 12979 insertions, 0 deletions
diff --git a/app/source/Cplt/Entrypoint/Backend.hpp b/app/source/Cplt/Entrypoint/Backend.hpp
new file mode 100644
index 0000000..ca391e6
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/Backend.hpp
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <memory>
+
+class RenderingBackend
+{
+public:
+ // Implemented in Backend_OpenGL2.cpp
+ static std::unique_ptr<RenderingBackend> CreateOpenGL2Backend();
+ // Implemented in Backend_OpenGL3.cpp
+ static std::unique_ptr<RenderingBackend> CreateOpenGL3Backend();
+ // Implemented in Backend_Vulkan.cpp
+ static std::unique_ptr<RenderingBackend> CreateVulkanBackend();
+ // Implemented in Backend_DirectX11.cpp
+ static std::unique_ptr<RenderingBackend> CreateDx11Backend();
+ // Implemented in Backend_DirectX12.cpp
+ static std::unique_ptr<RenderingBackend> CreateDx12Backend();
+ // Implemented in Backend_Metal.cpp
+ static std::unique_ptr<RenderingBackend> CreateMetalBackend();
+
+ virtual ~RenderingBackend() = default;
+ virtual void RunUntilWindowClose(void (*windowContent)()) = 0;
+};
diff --git a/app/source/Cplt/Entrypoint/Backend_DirectX11.cpp b/app/source/Cplt/Entrypoint/Backend_DirectX11.cpp
new file mode 100644
index 0000000..4dc33f7
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/Backend_DirectX11.cpp
@@ -0,0 +1,250 @@
+#include "Backend.hpp"
+
+#if BUILD_CORE_WITH_DX11_BACKEND
+# include <backend/imgui_impl_dx11.h>
+# include <backend/imgui_impl_dx11.cpp>
+# include <stdexcept>
+# include <d3d11.h>
+# include <tchar.h>
+# include <backend/imgui_impl_win32.h>
+
+// Forward declare message handler from imgui_impl_win32.cpp
+extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
+
+class DirectX11Backend : public RenderingBackend
+{
+private:
+ HWND hWnd;
+ WNDCLASSEX wc;
+
+ ID3D11Device* mD3dDevice = nullptr;
+ ID3D11DeviceContext* mD3dDeviceContext = nullptr;
+ IDXGISwapChain* mSwapChain = nullptr;
+ ID3D11RenderTargetView* mMainRenderTargetView = nullptr;
+
+public:
+ DirectX11Backend()
+ {
+ ImGui_ImplWin32_EnableDpiAwareness();
+
+ wc.cbSize = sizeof(WNDCLASSEX);
+ wc.style = CS_CLASSDC;
+ wc.lpfnWndProc = &StaticWndProc;
+ wc.cbClsExtra = 0L;
+ wc.cbWndExtra = 0L;
+ wc.hInstance = GetModuleHandle(nullptr);
+ wc.hIcon = nullptr;
+ wc.hCursor = nullptr;
+ wc.hbrBackground = nullptr;
+ wc.lpszMenuName = nullptr;
+ wc.lpszClassName = _T("Cplt");
+ wc.hIconSm = nullptr;
+ ::RegisterClassEx(&wc);
+
+ hWnd = ::CreateWindow(
+ wc.lpszClassName,
+ _T("Cplt main window"),
+ WS_OVERLAPPEDWINDOW,
+ /* x */ 100,
+ /* y */ 100,
+ /* window width */ 1280,
+ /* window height */ 800,
+ nullptr,
+ nullptr,
+ wc.hInstance,
+ this);
+
+ if (!CreateDeviceD3D()) {
+ CleanupDeviceD3D();
+ ::UnregisterClass(wc.lpszClassName, wc.hInstance);
+ throw std::runtime_error("Failed to create d3d device.");
+ }
+
+ ::ShowWindow(hWnd, SW_SHOWDEFAULT);
+ ::UpdateWindow(hWnd);
+
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+
+ ImGui_ImplWin32_Init(hWnd);
+ ImGui_ImplDX11_Init(mD3dDevice, mD3dDeviceContext);
+ }
+
+ virtual ~DirectX11Backend()
+ {
+ ImGui_ImplDX11_Shutdown();
+ ImGui_ImplWin32_Shutdown();
+ ImGui::DestroyContext();
+
+ CleanupDeviceD3D();
+ ::DestroyWindow(hWnd);
+ ::UnregisterClass(wc.lpszClassName, wc.hInstance);
+ }
+
+ virtual void RunUntilWindowClose(void (*windowContent)())
+ {
+ while (true) {
+ MSG msg;
+ bool done = false;
+ while (::PeekMessage(&msg, nullptr, 0U, 0U, PM_REMOVE)) {
+ ::TranslateMessage(&msg);
+ ::DispatchMessage(&msg);
+ if (msg.message == WM_QUIT) {
+ done = true;
+ }
+ }
+ if (done) break;
+
+ ImGui_ImplDX11_NewFrame();
+ ImGui_ImplWin32_NewFrame();
+ ImGui::NewFrame();
+
+ windowContent();
+
+ ImGui::Render();
+ const ImVec4 kClearColor = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
+ const float kClearColorWithAlpha[4] = { kClearColor.x * kClearColor.w, kClearColor.y * kClearColor.w, kClearColor.z * kClearColor.w, kClearColor.w };
+ mD3dDeviceContext->OMSetRenderTargets(1, &mMainRenderTargetView, nullptr);
+ mD3dDeviceContext->ClearRenderTargetView(mMainRenderTargetView, kClearColorWithAlpha);
+ ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
+
+ mSwapChain->Present(1, 0); // Present with vsync
+ }
+ }
+
+private:
+ bool CreateDeviceD3D()
+ {
+ // Setup swap chain
+ DXGI_SWAP_CHAIN_DESC sd;
+ ZeroMemory(&sd, sizeof(sd));
+ sd.BufferCount = 2;
+ sd.BufferDesc.Width = 0;
+ sd.BufferDesc.Height = 0;
+ sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
+ sd.BufferDesc.RefreshRate.Numerator = 60;
+ sd.BufferDesc.RefreshRate.Denominator = 1;
+ sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;
+ sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
+ sd.OutputWindow = hWnd;
+ sd.SampleDesc.Count = 1;
+ sd.SampleDesc.Quality = 0;
+ sd.Windowed = TRUE;
+ sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
+
+ UINT createDeviceFlags = 0;
+ //createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;
+ D3D_FEATURE_LEVEL featureLevel;
+ const D3D_FEATURE_LEVEL featureLevelArray[2] = {
+ D3D_FEATURE_LEVEL_11_0,
+ D3D_FEATURE_LEVEL_10_0,
+ };
+ if (D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, createDeviceFlags, featureLevelArray, 2, D3D11_SDK_VERSION, &sd, &mSwapChain, &mD3dDevice, &featureLevel, &mD3dDeviceContext) != S_OK) {
+ return false;
+ }
+
+ CreateRenderTarget();
+ return true;
+ }
+
+ void CleanupDeviceD3D()
+ {
+ CleanupRenderTarget();
+ if (mSwapChain) {
+ mSwapChain->Release();
+ mSwapChain = nullptr;
+ }
+ if (mD3dDeviceContext) {
+ mD3dDeviceContext->Release();
+ mD3dDeviceContext = nullptr;
+ }
+ if (mD3dDevice) {
+ mD3dDevice->Release();
+ mD3dDevice = nullptr;
+ }
+ }
+
+ void CreateRenderTarget()
+ {
+ ID3D11Texture2D* pBackBuffer;
+ mSwapChain->GetBuffer(0, IID_PPV_ARGS(&pBackBuffer));
+ mD3dDevice->CreateRenderTargetView(pBackBuffer, nullptr, &mMainRenderTargetView);
+ pBackBuffer->Release();
+ }
+
+ void CleanupRenderTarget()
+ {
+ if (mMainRenderTargetView) {
+ mMainRenderTargetView->Release();
+ mMainRenderTargetView = nullptr;
+ }
+ }
+
+ static LRESULT CALLBACK StaticWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
+ {
+ DirectX11Backend* self;
+ if (uMsg == WM_NCCREATE) {
+ auto lpcs = reinterpret_cast<LPCREATESTRUCT>(lParam);
+ self = static_cast<DirectX11Backend*>(lpcs->lpCreateParams);
+ self->hWnd = hWnd;
+ SetWindowLongPtr(hWnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
+ } else {
+ self = reinterpret_cast<DirectX11Backend*>(GetWindowLongPtr(hWnd, GWLP_USERDATA));
+ }
+
+ if (self) {
+ return self->WndProc(uMsg, wParam, lParam);
+ } else {
+ return DefWindowProc(hWnd, uMsg, wParam, lParam);
+ }
+ }
+
+ LRESULT WndProc(UINT msg, WPARAM wParam, LPARAM lParam)
+ {
+ if (ImGui_ImplWin32_WndProcHandler(hWnd, msg, wParam, lParam)) {
+ return true;
+ }
+
+ switch (msg) {
+ case WM_SIZE: {
+ if (mD3dDevice != nullptr && wParam != SIZE_MINIMIZED) {
+ CleanupRenderTarget();
+ mSwapChain->ResizeBuffers(0, (UINT)LOWORD(lParam), (UINT)HIWORD(lParam), DXGI_FORMAT_UNKNOWN, 0);
+ CreateRenderTarget();
+ }
+ return 0;
+ }
+
+ case WM_SYSCOMMAND: {
+ // Disable ALT application menu
+ if ((wParam & 0xfff0) == SC_KEYMENU) {
+ return 0;
+ }
+ } break;
+
+ case WM_DESTROY: {
+ ::PostQuitMessage(0);
+ return 0;
+ }
+ }
+ return ::DefWindowProc(hWnd, msg, wParam, lParam);
+ }
+};
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateDx11Backend()
+{
+ try {
+ return std::make_unique<DirectX11Backend>();
+ } catch (std::exception& e) {
+ return nullptr;
+ }
+}
+
+#else // ^^ BUILD_CORE_WITH_DX11_BACKEND | BUILD_CORE_WITH_DX11_BACKEND vv
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateDx11Backend()
+{
+ return nullptr;
+}
+
+#endif
diff --git a/app/source/Cplt/Entrypoint/Backend_DirectX12.cpp b/app/source/Cplt/Entrypoint/Backend_DirectX12.cpp
new file mode 100644
index 0000000..fd4a531
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/Backend_DirectX12.cpp
@@ -0,0 +1,470 @@
+#include "Backend.hpp"
+
+#if BUILD_CORE_WITH_DX12_BACKEND
+# include <backend/imgui_impl_dx12.h>
+# include <backend/imgui_impl_win32.h>
+# include <d3d12.h>
+# include <dxgi1_4.h>
+# include <tchar.h>
+# include <backend/imgui_impl_dx12.cpp>
+# include <stdexcept>
+
+constexpr int kNumFramesInFlight = 3;
+constexpr int kNumBackBuffers = 3;
+
+// Forward declare message handler from imgui_impl_win32.cpp
+extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
+
+class DirectX12Backend : public RenderingBackend
+{
+private:
+ struct FrameContext
+ {
+ ID3D12CommandAllocator* CommandAllocator;
+ UINT64 FenceValue;
+ };
+
+ HWND hWnd;
+ WNDCLASSEX wc;
+
+ FrameContext mFrameContext[kNumFramesInFlight] = {};
+ UINT mFrameIndex = 0;
+
+ ID3D12Device* mD3dDevice = nullptr;
+ ID3D12DescriptorHeap* mD3dRtvDescHeap = nullptr;
+ ID3D12DescriptorHeap* mD3dSrvDescHeap = nullptr;
+ ID3D12CommandQueue* mD3dCommandQueue = nullptr;
+ ID3D12GraphicsCommandList* mD3dCommandList = nullptr;
+ ID3D12Fence* mFence = nullptr;
+ HANDLE mFenceEvent = nullptr;
+ UINT64 mFenceLastSignaledValue = 0;
+ IDXGISwapChain3* mSwapChain = nullptr;
+ HANDLE mSwapChainWaitableObject = nullptr;
+ ID3D12Resource* mMainRenderTargetResource[kNumBackBuffers] = {};
+ D3D12_CPU_DESCRIPTOR_HANDLE mMainRenderTargetDescriptor[kNumBackBuffers] = {};
+
+public:
+ DirectX12Backend()
+ {
+ ImGui_ImplWin32_EnableDpiAwareness();
+
+ wc.cbSize = sizeof(WNDCLASSEX);
+ wc.style = CS_CLASSDC;
+ wc.lpfnWndProc = &StaticWndProc;
+ wc.cbClsExtra = 0L;
+ wc.cbWndExtra = 0L;
+ wc.hInstance = GetModuleHandle(nullptr);
+ wc.hIcon = nullptr;
+ wc.hCursor = nullptr;
+ wc.hbrBackground = nullptr;
+ wc.lpszMenuName = nullptr;
+ wc.lpszClassName = _T("Cplt");
+ wc.hIconSm = nullptr;
+ ::RegisterClassEx(&wc);
+
+ hWnd = ::CreateWindow(
+ wc.lpszClassName,
+ _T("Cplt main window"),
+ WS_OVERLAPPEDWINDOW,
+ /* x */ 100,
+ /* y */ 100,
+ /* window width */ 1280,
+ /* window height */ 800,
+ nullptr,
+ nullptr,
+ wc.hInstance,
+ this);
+
+ if (!CreateDeviceD3D()) {
+ CleanupDeviceD3D();
+ ::UnregisterClass(wc.lpszClassName, wc.hInstance);
+ throw std::runtime_error("Failed to create d3d device.");
+ }
+
+ ::ShowWindow(hWnd, SW_SHOWDEFAULT);
+ ::UpdateWindow(hWnd);
+
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+
+ ImGui_ImplWin32_Init(hWnd);
+ ImGui_ImplDX12_Init(mD3dDevice, kNumFramesInFlight, DXGI_FORMAT_R8G8B8A8_UNORM, mD3dSrvDescHeap, mD3dSrvDescHeap->GetCPUDescriptorHandleForHeapStart(), mD3dSrvDescHeap->GetGPUDescriptorHandleForHeapStart());
+ }
+
+ virtual ~DirectX12Backend()
+ {
+ WaitForLastSubmittedFrame();
+
+ // Cleanup
+ ImGui_ImplDX12_Shutdown();
+ ImGui_ImplWin32_Shutdown();
+ ImGui::DestroyContext();
+
+ CleanupDeviceD3D();
+ ::DestroyWindow(hWnd);
+ ::UnregisterClass(wc.lpszClassName, wc.hInstance);
+ }
+
+ virtual void RunUntilWindowClose(void (*windowContent)())
+ {
+ while (true) {
+ MSG msg;
+ bool done = false;
+ while (::PeekMessage(&msg, nullptr, 0U, 0U, PM_REMOVE))
+ {
+ ::TranslateMessage(&msg);
+ ::DispatchMessage(&msg);
+ if (msg.message == WM_QUIT) {
+ done = true;
+ }
+ }
+ if (done) break;
+
+ // Start the Dear ImGui frame
+ ImGui_ImplDX12_NewFrame();
+ ImGui_ImplWin32_NewFrame();
+ ImGui::NewFrame();
+
+ windowContent();
+
+ ImGui::Render();
+
+ FrameContext* frameCtx = WaitForNextFrameResources();
+ UINT backBufferIdx = mSwapChain->GetCurrentBackBufferIndex();
+ frameCtx->CommandAllocator->Reset();
+
+ D3D12_RESOURCE_BARRIER barrier = {};
+ barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
+ barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
+ barrier.Transition.pResource = mMainRenderTargetResource[backBufferIdx];
+ barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
+ barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
+ barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
+ mD3dCommandList->Reset(frameCtx->CommandAllocator, nullptr);
+ mD3dCommandList->ResourceBarrier(1, &barrier);
+
+ const ImVec4 kClearColor = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
+ const float kClearColorWithAlpha[4] = { kClearColor.x * kClearColor.w, kClearColor.y * kClearColor.w, kClearColor.z * kClearColor.w, kClearColor.w };
+ mD3dCommandList->ClearRenderTargetView(mMainRenderTargetDescriptor[backBufferIdx], kClearColorWithAlpha, 0, nullptr);
+ mD3dCommandList->OMSetRenderTargets(1, &mMainRenderTargetDescriptor[backBufferIdx], FALSE, nullptr);
+ mD3dCommandList->SetDescriptorHeaps(1, &mD3dSrvDescHeap);
+ ImGui_ImplDX12_RenderDrawData(ImGui::GetDrawData(), mD3dCommandList);
+ barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
+ barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;
+ mD3dCommandList->ResourceBarrier(1, &barrier);
+ mD3dCommandList->Close();
+
+ mD3dCommandQueue->ExecuteCommandLists(1, (ID3D12CommandList* const*)&mD3dCommandList);
+
+ mSwapChain->Present(1, 0); // Present with vsync
+
+ UINT64 fenceValue = mFenceLastSignaledValue + 1;
+ mD3dCommandQueue->Signal(mFence, fenceValue);
+ mFenceLastSignaledValue = fenceValue;
+ frameCtx->FenceValue = fenceValue;
+ }
+ }
+
+private:
+ bool CreateDeviceD3D()
+ {
+ // Setup swap chain
+ DXGI_SWAP_CHAIN_DESC1 sd;
+ {
+ ZeroMemory(&sd, sizeof(sd));
+ sd.BufferCount = kNumBackBuffers;
+ sd.Width = 0;
+ sd.Height = 0;
+ sd.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
+ sd.Flags = DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT;
+ sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
+ sd.SampleDesc.Count = 1;
+ sd.SampleDesc.Quality = 0;
+ sd.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
+ sd.AlphaMode = DXGI_ALPHA_MODE_UNSPECIFIED;
+ sd.Scaling = DXGI_SCALING_STRETCH;
+ sd.Stereo = FALSE;
+ }
+
+ // Create device
+ D3D_FEATURE_LEVEL featureLevel = D3D_FEATURE_LEVEL_11_0;
+ if (D3D12CreateDevice(nullptr, featureLevel, IID_PPV_ARGS(&mD3dDevice)) != S_OK) {
+ return false;
+ }
+
+ {
+ D3D12_DESCRIPTOR_HEAP_DESC desc = {};
+ desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
+ desc.NumDescriptors = kNumBackBuffers;
+ desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
+ desc.NodeMask = 1;
+ if (mD3dDevice->CreateDescriptorHeap(&desc, IID_PPV_ARGS(&mD3dRtvDescHeap)) != S_OK) {
+ return false;
+ }
+
+ SIZE_T rtvDescriptorSize = mD3dDevice->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
+ D3D12_CPU_DESCRIPTOR_HANDLE rtvHandle = mD3dRtvDescHeap->GetCPUDescriptorHandleForHeapStart();
+ for (UINT i = 0; i < kNumBackBuffers; i++) {
+ mMainRenderTargetDescriptor[i] = rtvHandle;
+ rtvHandle.ptr += rtvDescriptorSize;
+ }
+ }
+
+ {
+ D3D12_DESCRIPTOR_HEAP_DESC desc = {};
+ desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
+ desc.NumDescriptors = 1;
+ desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
+ if (mD3dDevice->CreateDescriptorHeap(&desc, IID_PPV_ARGS(&mD3dSrvDescHeap)) != S_OK) {
+ return false;
+ }
+ }
+
+ {
+ D3D12_COMMAND_QUEUE_DESC desc = {};
+ desc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
+ desc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;
+ desc.NodeMask = 1;
+ if (mD3dDevice->CreateCommandQueue(&desc, IID_PPV_ARGS(&mD3dCommandQueue)) != S_OK) {
+ return false;
+ }
+ }
+
+ for (UINT i = 0; i < kNumFramesInFlight; i++) {
+ if (mD3dDevice->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&mFrameContext[i].CommandAllocator)) != S_OK) {
+ return false;
+ }
+ }
+
+ if (mD3dDevice->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, mFrameContext[0].CommandAllocator, nullptr, IID_PPV_ARGS(&mD3dCommandList)) != S_OK ||
+ mD3dCommandList->Close() != S_OK)
+ {
+ return false;
+ }
+
+ if (mD3dDevice->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&mFence)) != S_OK) return false;
+
+ mFenceEvent = CreateEvent(nullptr, FALSE, FALSE, nullptr);
+ if (mFenceEvent == nullptr) return false;
+
+ {
+ IDXGIFactory4* dxgiFactory = nullptr;
+ IDXGISwapChain1* swapChain1 = nullptr;
+ if (CreateDXGIFactory1(IID_PPV_ARGS(&dxgiFactory)) != S_OK)
+ return false;
+ if (dxgiFactory->CreateSwapChainForHwnd(mD3dCommandQueue, hWnd, &sd, nullptr, nullptr, &swapChain1) != S_OK)
+ return false;
+ if (swapChain1->QueryInterface(IID_PPV_ARGS(&mSwapChain)) != S_OK)
+ return false;
+ swapChain1->Release();
+ dxgiFactory->Release();
+ mSwapChain->SetMaximumFrameLatency(kNumBackBuffers);
+ mSwapChainWaitableObject = mSwapChain->GetFrameLatencyWaitableObject();
+ }
+
+ CreateRenderTarget();
+ return true;
+ }
+
+ void CleanupDeviceD3D()
+ {
+ CleanupRenderTarget();
+ if (mSwapChain) {
+ mSwapChain->Release();
+ mSwapChain = nullptr;
+ }
+ if (mSwapChainWaitableObject != nullptr) {
+ CloseHandle(mSwapChainWaitableObject);
+ }
+ for (UINT i = 0; i < kNumFramesInFlight; i++)
+ if (mFrameContext[i].CommandAllocator) {
+ mFrameContext[i].CommandAllocator->Release();
+ mFrameContext[i].CommandAllocator = nullptr;
+ }
+ if (mD3dCommandQueue) {
+ mD3dCommandQueue->Release();
+ mD3dCommandQueue = nullptr;
+ }
+ if (mD3dCommandList) {
+ mD3dCommandList->Release();
+ mD3dCommandList = nullptr;
+ }
+ if (mD3dRtvDescHeap) {
+ mD3dRtvDescHeap->Release();
+ mD3dRtvDescHeap = nullptr;
+ }
+ if (mD3dSrvDescHeap) {
+ mD3dSrvDescHeap->Release();
+ mD3dSrvDescHeap = nullptr;
+ }
+ if (mFence) {
+ mFence->Release();
+ mFence = nullptr;
+ }
+ if (mFenceEvent) {
+ CloseHandle(mFenceEvent);
+ mFenceEvent = nullptr;
+ }
+ if (mD3dDevice) {
+ mD3dDevice->Release();
+ mD3dDevice = nullptr;
+ }
+ }
+
+ void CreateRenderTarget()
+ {
+ for (UINT i = 0; i < kNumBackBuffers; i++)
+ {
+ ID3D12Resource* pBackBuffer = nullptr;
+ mSwapChain->GetBuffer(i, IID_PPV_ARGS(&pBackBuffer));
+ mD3dDevice->CreateRenderTargetView(pBackBuffer, nullptr, mMainRenderTargetDescriptor[i]);
+ mMainRenderTargetResource[i] = pBackBuffer;
+ }
+ }
+
+ void CleanupRenderTarget()
+ {
+ WaitForLastSubmittedFrame();
+
+ for (UINT i = 0; i < kNumBackBuffers; i++)
+ if (mMainRenderTargetResource[i]) {
+ mMainRenderTargetResource[i]->Release();
+ mMainRenderTargetResource[i] = nullptr;
+ }
+ }
+
+ void WaitForLastSubmittedFrame()
+ {
+ FrameContext* frameCtx = &mFrameContext[mFrameIndex % kNumFramesInFlight];
+
+ UINT64 fenceValue = frameCtx->FenceValue;
+ if (fenceValue == 0)
+ return; // No fence was signaled
+
+ frameCtx->FenceValue = 0;
+ if (mFence->GetCompletedValue() >= fenceValue)
+ return;
+
+ mFence->SetEventOnCompletion(fenceValue, mFenceEvent);
+ WaitForSingleObject(mFenceEvent, INFINITE);
+ }
+
+ FrameContext* WaitForNextFrameResources()
+ {
+ UINT nextFrameIndex = mFrameIndex + 1;
+ mFrameIndex = nextFrameIndex;
+
+ HANDLE waitableObjects[] = { mSwapChainWaitableObject, nullptr };
+ DWORD numWaitableObjects = 1;
+
+ FrameContext* frameCtx = &mFrameContext[nextFrameIndex % kNumFramesInFlight];
+ UINT64 fenceValue = frameCtx->FenceValue;
+ if (fenceValue != 0) // means no fence was signaled
+ {
+ frameCtx->FenceValue = 0;
+ mFence->SetEventOnCompletion(fenceValue, mFenceEvent);
+ waitableObjects[1] = mFenceEvent;
+ numWaitableObjects = 2;
+ }
+
+ WaitForMultipleObjects(numWaitableObjects, waitableObjects, TRUE, INFINITE);
+
+ return frameCtx;
+ }
+
+ void ResizeSwapChain(int width, int height)
+ {
+ DXGI_SWAP_CHAIN_DESC1 sd;
+ mSwapChain->GetDesc1(&sd);
+ sd.Width = width;
+ sd.Height = height;
+
+ IDXGIFactory4* dxgiFactory = nullptr;
+ mSwapChain->GetParent(IID_PPV_ARGS(&dxgiFactory));
+
+ mSwapChain->Release();
+ CloseHandle(mSwapChainWaitableObject);
+
+ IDXGISwapChain1* swapChain1 = nullptr;
+ dxgiFactory->CreateSwapChainForHwnd(mD3dCommandQueue, hWnd, &sd, nullptr, nullptr, &swapChain1);
+ swapChain1->QueryInterface(IID_PPV_ARGS(&mSwapChain));
+ swapChain1->Release();
+ dxgiFactory->Release();
+
+ mSwapChain->SetMaximumFrameLatency(kNumBackBuffers);
+
+ mSwapChainWaitableObject = mSwapChain->GetFrameLatencyWaitableObject();
+ assert(mSwapChainWaitableObject != nullptr);
+ }
+
+ static LRESULT CALLBACK StaticWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
+ {
+ DirectX12Backend* self;
+ if (uMsg == WM_NCCREATE) {
+ auto lpcs = reinterpret_cast<LPCREATESTRUCT>(lParam);
+ self = static_cast<DirectX12Backend*>(lpcs->lpCreateParams);
+ self->hWnd = hWnd;
+ SetWindowLongPtr(hWnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
+ } else {
+ self = reinterpret_cast<DirectX12Backend*>(GetWindowLongPtr(hWnd, GWLP_USERDATA));
+ }
+
+ if (self) {
+ return self->WndProc(uMsg, wParam, lParam);
+ } else {
+ return DefWindowProc(hWnd, uMsg, wParam, lParam);
+ }
+ }
+
+ LRESULT WndProc(UINT msg, WPARAM wParam, LPARAM lParam)
+ {
+ if (ImGui_ImplWin32_WndProcHandler(hWnd, msg, wParam, lParam)) {
+ return true;
+ }
+
+ switch (msg) {
+ case WM_SIZE: {
+ if (mD3dDevice != nullptr && wParam != SIZE_MINIMIZED) {
+ WaitForLastSubmittedFrame();
+ ImGui_ImplDX12_InvalidateDeviceObjects();
+ CleanupRenderTarget();
+ ResizeSwapChain((UINT)LOWORD(lParam), (UINT)HIWORD(lParam));
+ CreateRenderTarget();
+ ImGui_ImplDX12_CreateDeviceObjects();
+ }
+ return 0;
+ }
+
+ case WM_SYSCOMMAND: {
+ // Disable ALT application menu
+ if ((wParam & 0xfff0) == SC_KEYMENU) {
+ return 0;
+ }
+ } break;
+
+ case WM_DESTROY: {
+ ::PostQuitMessage(0);
+ return 0;
+ }
+ }
+ return ::DefWindowProc(hWnd, msg, wParam, lParam);
+ }
+};
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateDx12Backend()
+{
+ try {
+ return std::make_unique<DirectX12Backend>();
+ } catch (std::exception& e) {
+ return nullptr;
+ }
+}
+
+#else // ^^ BUILD_CORE_WITH_DX12_BACKEND | BUILD_CORE_WITH_DX12_BACKEND vv
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateDx12Backend()
+{
+ return nullptr;
+}
+
+#endif
diff --git a/app/source/Cplt/Entrypoint/Backend_Metal.mm b/app/source/Cplt/Entrypoint/Backend_Metal.mm
new file mode 100644
index 0000000..276bef2
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/Backend_Metal.mm
@@ -0,0 +1,40 @@
+#include "Backend.hpp"
+
+#if BUILD_CORE_WITH_METAL_BACKEND
+
+class MetalBackend : public RenderingBackend
+{
+public:
+ MetalBackend()
+ {
+ // TODO
+ }
+
+ virtual ~MetalBackend()
+ {
+ // TODO
+ }
+
+ virtual void RunUntilWindowClose(void (*windowContent)())
+ {
+ // TODO
+ }
+};
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateMetalBackend()
+{
+ try {
+ return std::make_unique<MetalBackend>();
+ } catch (std::exception& e) {
+ return nullptr;
+ }
+}
+
+#else // ^^ BUILD_CORE_WITH_METAL_BACKEND | BUILD_CORE_WITH_METAL_BACKEND vv
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateMetalBackend()
+{
+ return nullptr;
+}
+
+#endif
diff --git a/app/source/Cplt/Entrypoint/Backend_OpenGL2.cpp b/app/source/Cplt/Entrypoint/Backend_OpenGL2.cpp
new file mode 100644
index 0000000..0f20997
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/Backend_OpenGL2.cpp
@@ -0,0 +1,106 @@
+#include <Cplt/Entrypoint/Backend.hpp>
+
+#if BUILD_CORE_WITH_OPENGL2_BACKEND
+# include <glad/glad.h>
+
+# include <GLFW/glfw3.h>
+# include <backend/imgui_impl_glfw.h>
+# include <backend/imgui_impl_opengl2.h>
+# include <imgui.h>
+# include <stdexcept>
+# include <iostream>
+
+# define IMGUI_IMPL_OPENGL_LOADER_CUSTOM
+# include <backend/imgui_impl_opengl2.cpp>
+
+class OpenGL2Backend : public RenderingBackend
+{
+private:
+ GLFWwindow* mWindow;
+
+public:
+ OpenGL2Backend()
+ {
+ glfwSetErrorCallback(&GlfwErrorCallback);
+ if (!glfwInit()) {
+ throw std::runtime_error("Failed to initialize GLFW.");
+ }
+
+ mWindow = glfwCreateWindow(1280, 720, "Cplt", nullptr, nullptr);
+ if (mWindow == nullptr) {
+ throw std::runtime_error("Failed to create GLFW window.");
+ }
+ glfwMakeContextCurrent(mWindow);
+ glfwSwapInterval(1); // Enable vsync
+
+ if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0) {
+ throw std::runtime_error("Failed to initialize OpenGL.");
+ }
+
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+
+ ImGui_ImplGlfw_InitForOpenGL(mWindow, true);
+ ImGui_ImplOpenGL2_Init();
+ }
+
+ virtual ~OpenGL2Backend()
+ {
+ ImGui_ImplOpenGL2_Shutdown();
+ ImGui_ImplGlfw_Shutdown();
+ ImGui::DestroyContext();
+
+ glfwDestroyWindow(mWindow);
+ glfwTerminate();
+ }
+
+ virtual void RunUntilWindowClose(void (*windowContent)())
+ {
+ while (!glfwWindowShouldClose(mWindow)) {
+ glfwPollEvents();
+
+ ImGui_ImplOpenGL2_NewFrame();
+ ImGui_ImplGlfw_NewFrame();
+ ImGui::NewFrame();
+
+ windowContent();
+
+ int displayWidth, displayHeight;
+ glfwGetFramebufferSize(mWindow, &displayWidth, &displayHeight);
+ glViewport(0, 0, displayWidth, displayHeight);
+
+ const ImVec4 kClearColor = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
+ glClearColor(kClearColor.x * kClearColor.w, kClearColor.y * kClearColor.w, kClearColor.z * kClearColor.w, kClearColor.w);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ ImGui::Render();
+ ImGui_ImplOpenGL2_RenderDrawData(ImGui::GetDrawData());
+
+ glfwMakeContextCurrent(mWindow);
+ glfwSwapBuffers(mWindow);
+ }
+ }
+
+ static void GlfwErrorCallback(int errorCode, const char* message)
+ {
+ std::cerr << "GLFW Error " << errorCode << ": " << message << "\n";
+ }
+};
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateOpenGL2Backend()
+{
+ try {
+ return std::make_unique<OpenGL2Backend>();
+ } catch (std::exception& e) {
+ return nullptr;
+ }
+}
+
+#else // ^^ BUILD_CORE_WITH_OPENGL2_BACKEND | !BUILD_CORE_WITH_OPENGL2_BACKEND vv
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateOpenGL2Backend()
+{
+ return nullptr;
+}
+
+#endif
diff --git a/app/source/Cplt/Entrypoint/Backend_OpenGL3.cpp b/app/source/Cplt/Entrypoint/Backend_OpenGL3.cpp
new file mode 100644
index 0000000..28a34ca
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/Backend_OpenGL3.cpp
@@ -0,0 +1,121 @@
+#include <Cplt/Entrypoint/Backend.hpp>
+
+#if BUILD_CORE_WITH_OPENGL3_BACKEND
+# include <glad/glad.h>
+
+# include <GLFW/glfw3.h>
+# include <iostream>
+# include <backend/imgui_impl_glfw.h>
+# include <backend/imgui_impl_opengl3.h>
+# include <imgui.h>
+# include <stdexcept>
+
+# define IMGUI_IMPL_OPENGL_LOADER_CUSTOM
+# include <backend/imgui_impl_opengl3.cpp>
+
+class OpenGL3Backend : public RenderingBackend
+{
+private:
+ GLFWwindow* mWindow;
+
+public:
+ OpenGL3Backend()
+ {
+ glfwSetErrorCallback(&GlfwErrorCallback);
+ if (!glfwInit()) {
+ throw std::runtime_error("Failed to initialize GLFW.");
+ }
+
+# if defined(__APPLE__)
+ // GL 3.2 + GLSL 150
+ const char* glslVersion = "#version 150";
+ glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
+ glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
+ glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); // 3.2+ only
+ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); // Required on Mac
+# else
+ // GL 3.0 + GLSL 130
+ const char* glslVersion = "#version 130";
+ glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
+ glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
+ // glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); // 3.2+ only
+ // glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); // 3.0+ only
+# endif
+
+ mWindow = glfwCreateWindow(1280, 720, "Cplt", nullptr, nullptr);
+ if (mWindow == nullptr) {
+ throw std::runtime_error("Failed to create GLFW window.");
+ }
+ glfwMakeContextCurrent(mWindow);
+ glfwSwapInterval(1); // Enable vsync
+
+ if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0) {
+ throw std::runtime_error("Failed to initialize OpenGL.");
+ }
+
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+
+ ImGui_ImplGlfw_InitForOpenGL(mWindow, true);
+ ImGui_ImplOpenGL3_Init(glslVersion);
+ }
+
+ virtual ~OpenGL3Backend()
+ {
+ ImGui_ImplOpenGL3_Shutdown();
+ ImGui_ImplGlfw_Shutdown();
+ ImGui::DestroyContext();
+
+ glfwDestroyWindow(mWindow);
+ glfwTerminate();
+ }
+
+ virtual void RunUntilWindowClose(void (*windowContent)())
+ {
+ while (!glfwWindowShouldClose(mWindow)) {
+ glfwPollEvents();
+
+ ImGui_ImplOpenGL3_NewFrame();
+ ImGui_ImplGlfw_NewFrame();
+ ImGui::NewFrame();
+
+ windowContent();
+
+ int displayWidth, displayHeight;
+ glfwGetFramebufferSize(mWindow, &displayWidth, &displayHeight);
+ glViewport(0, 0, displayWidth, displayHeight);
+
+ const ImVec4 kClearColor = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
+ glClearColor(kClearColor.x * kClearColor.w, kClearColor.y * kClearColor.w, kClearColor.z * kClearColor.w, kClearColor.w);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ ImGui::Render();
+ ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
+
+ glfwSwapBuffers(mWindow);
+ }
+ }
+
+ static void GlfwErrorCallback(int errorCode, const char* message)
+ {
+ std::cerr << "GLFW Error " << errorCode << ": " << message << "\n";
+ }
+};
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateOpenGL3Backend()
+{
+ try {
+ return std::make_unique<OpenGL3Backend>();
+ } catch (std::exception& e) {
+ return nullptr;
+ }
+}
+
+#else // ^^ BUILD_CORE_WITH_OPENGL3_BACKEND | !BUILD_CORE_WITH_OPENGL3_BACKEND vv
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateOpenGL3Backend()
+{
+ return nullptr;
+}
+
+#endif
diff --git a/app/source/Cplt/Entrypoint/Backend_Vulkan.cpp b/app/source/Cplt/Entrypoint/Backend_Vulkan.cpp
new file mode 100644
index 0000000..280a82b
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/Backend_Vulkan.cpp
@@ -0,0 +1,438 @@
+#include <Cplt/Entrypoint/Backend.hpp>
+
+#if BUILD_CORE_WITH_VULKAN_BACKEND
+# include <iostream>
+# include <stdexcept>
+
+# define GLFW_INCLUDE_NONE
+# define GLFW_INCLUDE_VULKAN
+# include <GLFW/glfw3.h>
+
+# include <backend/imgui_impl_glfw.h>
+# include <backend/imgui_impl_vulkan.h>
+# include <backend/imgui_impl_vulkan.cpp>
+
+class VulkanBackend : public RenderingBackend
+{
+private:
+ GLFWwindow* mWindow;
+
+ VkAllocationCallbacks* mAllocator = NULL;
+ VkInstance mInstance = VK_NULL_HANDLE;
+ VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE;
+ VkDevice mDevice = VK_NULL_HANDLE;
+ uint32_t mQueueFamily = (uint32_t)-1;
+ VkQueue mQueue = VK_NULL_HANDLE;
+ VkDebugReportCallbackEXT mDebugReport = VK_NULL_HANDLE;
+ VkPipelineCache mPipelineCache = VK_NULL_HANDLE;
+ VkDescriptorPool mDescriptorPool = VK_NULL_HANDLE;
+
+ ImGui_ImplVulkanH_Window mMainWindowData;
+ int mMinImageCount = 2;
+ bool mSwapChainRebuild = false;
+
+public:
+ VulkanBackend()
+ {
+ glfwSetErrorCallback(&GlfwErrorCallback);
+ if (!glfwInit()) {
+ throw std::runtime_error("Failed to initialize GLFW.");
+ }
+
+ glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
+ mWindow = glfwCreateWindow(1280, 720, "Cplt", nullptr, nullptr);
+ if (mWindow == nullptr) {
+ throw std::runtime_error("Failed to create GLFW window.");
+ }
+
+ if (!glfwVulkanSupported()) {
+ throw std::runtime_error("GLFW reports vulkan not supported.");
+ }
+
+ uint32_t extensionsCount = 0;
+ const char** extensions = glfwGetRequiredInstanceExtensions(&extensionsCount);
+ SetupVulkan(extensions, extensionsCount);
+
+ // Create window surface
+ VkSurfaceKHR surface;
+ VkResult err = glfwCreateWindowSurface(mInstance, mWindow, mAllocator, &surface);
+ CheckVkResults(err);
+
+ // Create framebuffers
+ int w, h;
+ glfwGetFramebufferSize(mWindow, &w, &h);
+ SetupVulkanWindow(&mMainWindowData, surface, w, h);
+
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+
+ ImGui_ImplGlfw_InitForVulkan(mWindow, true);
+ ImGui_ImplVulkan_InitInfo init_info = {};
+ init_info.Instance = mInstance;
+ init_info.PhysicalDevice = mPhysicalDevice;
+ init_info.Device = mDevice;
+ init_info.QueueFamily = mQueueFamily;
+ init_info.Queue = mQueue;
+ init_info.PipelineCache = mPipelineCache;
+ init_info.DescriptorPool = mDescriptorPool;
+ init_info.Allocator = mAllocator;
+ init_info.MinImageCount = mMinImageCount;
+ init_info.ImageCount = mMainWindowData.ImageCount;
+ init_info.CheckVkResultFn = CheckVkResults;
+ ImGui_ImplVulkan_Init(&init_info, mMainWindowData.RenderPass);
+ }
+
+ virtual ~VulkanBackend()
+ {
+ auto err = vkDeviceWaitIdle(mDevice);
+ CheckVkResults(err);
+ ImGui_ImplVulkan_Shutdown();
+ ImGui_ImplGlfw_Shutdown();
+ ImGui::DestroyContext();
+
+ CleanupVulkanWindow();
+ CleanupVulkan();
+
+ glfwDestroyWindow(mWindow);
+ glfwTerminate();
+ }
+
+ virtual void RunUntilWindowClose(void (*windowContent)()) override
+ {
+ // Upload Fonts
+ {
+ // Use any command queue
+ VkCommandPool commandPool = mMainWindowData.Frames[mMainWindowData.FrameIndex].CommandPool;
+ VkCommandBuffer commandBuffer = mMainWindowData.Frames[mMainWindowData.FrameIndex].CommandBuffer;
+
+ CheckVkResults(vkResetCommandPool(mDevice, commandPool, 0));
+ VkCommandBufferBeginInfo beginInfo = {};
+ beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
+ beginInfo.flags |= VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
+ CheckVkResults(vkBeginCommandBuffer(commandBuffer, &beginInfo));
+
+ ImGui_ImplVulkan_CreateFontsTexture(commandBuffer);
+
+ VkSubmitInfo endInfo = {};
+ endInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
+ endInfo.commandBufferCount = 1;
+ endInfo.pCommandBuffers = &commandBuffer;
+ CheckVkResults(vkEndCommandBuffer(commandBuffer));
+ CheckVkResults(vkQueueSubmit(mQueue, 1, &endInfo, VK_NULL_HANDLE));
+
+ CheckVkResults(vkDeviceWaitIdle(mDevice));
+ ImGui_ImplVulkan_DestroyFontUploadObjects();
+ }
+
+ while (!glfwWindowShouldClose(mWindow)) {
+ glfwPollEvents();
+
+ // Resize swap chain?
+ if (mSwapChainRebuild) {
+ int width, height;
+ glfwGetFramebufferSize(mWindow, &width, &height);
+ if (width > 0 && height > 0) {
+ ImGui_ImplVulkan_SetMinImageCount(mMinImageCount);
+ ImGui_ImplVulkanH_CreateOrResizeWindow(mInstance, mPhysicalDevice, mDevice, &mMainWindowData, mQueueFamily, mAllocator, width, height, mMinImageCount);
+ mMainWindowData.FrameIndex = 0;
+ mSwapChainRebuild = false;
+ }
+ }
+
+ // Start the Dear ImGui frame
+ ImGui_ImplVulkan_NewFrame();
+ ImGui_ImplGlfw_NewFrame();
+ ImGui::NewFrame();
+
+ windowContent();
+
+ ImGui::Render();
+ ImDrawData* drawData = ImGui::GetDrawData();
+ const bool isMinimized = (drawData->DisplaySize.x <= 0.0f || drawData->DisplaySize.y <= 0.0f);
+ if (!isMinimized) {
+ const ImVec4 kClearColor = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
+ mMainWindowData.ClearValue.color.float32[0] = kClearColor.x * kClearColor.w;
+ mMainWindowData.ClearValue.color.float32[1] = kClearColor.y * kClearColor.w;
+ mMainWindowData.ClearValue.color.float32[2] = kClearColor.z * kClearColor.w;
+ mMainWindowData.ClearValue.color.float32[3] = kClearColor.w;
+ FrameRender(&mMainWindowData, drawData);
+ FramePresent(&mMainWindowData);
+ }
+ }
+ }
+
+private:
+ void SetupVulkan(const char** extensions, uint32_t extensions_count)
+ {
+ VkResult err;
+
+ // Create Vulkan Instance
+ {
+ VkInstanceCreateInfo createInfo = {};
+ createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
+ createInfo.enabledExtensionCount = extensions_count;
+ createInfo.ppEnabledExtensionNames = extensions;
+ // Create Vulkan Instance without any debug feature
+ err = vkCreateInstance(&createInfo, mAllocator, &mInstance);
+ CheckVkResults(err);
+ }
+
+ // Select GPU
+ {
+ uint32_t gpuCount;
+ err = vkEnumeratePhysicalDevices(mInstance, &gpuCount, NULL);
+ CheckVkResults(err);
+ IM_ASSERT(gpuCount > 0);
+
+ VkPhysicalDevice* gpus = (VkPhysicalDevice*)malloc(sizeof(VkPhysicalDevice) * gpuCount);
+ err = vkEnumeratePhysicalDevices(mInstance, &gpuCount, gpus);
+ CheckVkResults(err);
+
+ // If a number >1 of GPUs got reported, find discrete GPU if present, or use first one available. This covers
+ // most common cases (multi-gpu/integrated+dedicated graphics). Handling more complicated setups (multiple
+ // dedicated GPUs) is out of scope of this sample.
+ int useGpu = 0;
+ for (int i = 0; i < (int)gpuCount; i++)
+ {
+ VkPhysicalDeviceProperties properties;
+ vkGetPhysicalDeviceProperties(gpus[i], &properties);
+ if (properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU)
+ {
+ useGpu = i;
+ break;
+ }
+ }
+
+ mPhysicalDevice = gpus[useGpu];
+ free(gpus);
+ }
+
+ // Select graphics queue family
+ {
+ uint32_t count;
+ vkGetPhysicalDeviceQueueFamilyProperties(mPhysicalDevice, &count, NULL);
+
+ auto queues = std::make_unique<VkQueueFamilyProperties[]>(count);
+ vkGetPhysicalDeviceQueueFamilyProperties(mPhysicalDevice, &count, queues.get());
+ for (uint32_t i = 0; i < count; i++) {
+ if (queues[i].queueFlags & VK_QUEUE_GRAPHICS_BIT)
+ {
+ mQueueFamily = i;
+ break;
+ }
+ }
+
+ IM_ASSERT(mQueueFamily != (uint32_t)-1);
+ }
+
+ // Create Logical Device (with 1 queue)
+ {
+ int deviceExtensionCount = 1;
+ const char* deviceExtensions[] = { "VK_KHR_swapchain" };
+ const float queuePriority[] = { 1.0f };
+ VkDeviceQueueCreateInfo queue_info[1] = {};
+ queue_info[0].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
+ queue_info[0].queueFamilyIndex = mQueueFamily;
+ queue_info[0].queueCount = 1;
+ queue_info[0].pQueuePriorities = queuePriority;
+ VkDeviceCreateInfo createInfo = {};
+ createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
+ createInfo.queueCreateInfoCount = sizeof(queue_info) / sizeof(queue_info[0]);
+ createInfo.pQueueCreateInfos = queue_info;
+ createInfo.enabledExtensionCount = deviceExtensionCount;
+ createInfo.ppEnabledExtensionNames = deviceExtensions;
+ err = vkCreateDevice(mPhysicalDevice, &createInfo, mAllocator, &mDevice);
+ CheckVkResults(err);
+ vkGetDeviceQueue(mDevice, mQueueFamily, 0, &mQueue);
+ }
+
+ // Create Descriptor Pool
+ {
+ VkDescriptorPoolSize poolSizes[] = {
+ { VK_DESCRIPTOR_TYPE_SAMPLER, 1000 },
+ { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1000 },
+ { VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1000 },
+ { VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1000 },
+ { VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER, 1000 },
+ { VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER, 1000 },
+ { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1000 },
+ { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1000 },
+ { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, 1000 },
+ { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, 1000 },
+ { VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT, 1000 }
+ };
+ VkDescriptorPoolCreateInfo poolInfo = {};
+ poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
+ poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
+ poolInfo.maxSets = 1000 * IM_ARRAYSIZE(poolSizes);
+ poolInfo.poolSizeCount = (uint32_t)IM_ARRAYSIZE(poolSizes);
+ poolInfo.pPoolSizes = poolSizes;
+ err = vkCreateDescriptorPool(mDevice, &poolInfo, mAllocator, &mDescriptorPool);
+ CheckVkResults(err);
+ }
+ }
+
+ void SetupVulkanWindow(ImGui_ImplVulkanH_Window* wd, VkSurfaceKHR surface, int width, int height)
+ {
+ wd->Surface = surface;
+
+ // Check for WSI support
+ VkBool32 res;
+ vkGetPhysicalDeviceSurfaceSupportKHR(mPhysicalDevice, mQueueFamily, wd->Surface, &res);
+ if (res != VK_TRUE) {
+ throw "Error no WSI support on physical device 0.";
+ }
+
+ // Select Surface Format
+ const VkFormat requestSurfaceImageFormat[] = { VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_B8G8R8_UNORM, VK_FORMAT_R8G8B8_UNORM };
+ const VkColorSpaceKHR requestSurfaceColorSpace = VK_COLORSPACE_SRGB_NONLINEAR_KHR;
+ wd->SurfaceFormat = ImGui_ImplVulkanH_SelectSurfaceFormat(mPhysicalDevice, wd->Surface, requestSurfaceImageFormat, (size_t)IM_ARRAYSIZE(requestSurfaceImageFormat), requestSurfaceColorSpace);
+
+ // Select Present Mode
+ VkPresentModeKHR present_modes[] = { VK_PRESENT_MODE_FIFO_KHR };
+ wd->PresentMode = ImGui_ImplVulkanH_SelectPresentMode(mPhysicalDevice, wd->Surface, &present_modes[0], IM_ARRAYSIZE(present_modes));
+
+ // Create SwapChain, RenderPass, Framebuffer, etc.
+ IM_ASSERT(mMinImageCount >= 2);
+ ImGui_ImplVulkanH_CreateOrResizeWindow(mInstance, mPhysicalDevice, mDevice, wd, mQueueFamily, mAllocator, width, height, mMinImageCount);
+ }
+
+ void FrameRender(ImGui_ImplVulkanH_Window* wd, ImDrawData* drawData)
+ {
+ VkResult err;
+
+ VkSemaphore imageAcquiredSemaphore = wd->FrameSemaphores[wd->SemaphoreIndex].ImageAcquiredSemaphore;
+ VkSemaphore renderCompleteSemaphore = wd->FrameSemaphores[wd->SemaphoreIndex].RenderCompleteSemaphore;
+ err = vkAcquireNextImageKHR(mDevice, wd->Swapchain, UINT64_MAX, imageAcquiredSemaphore, VK_NULL_HANDLE, &wd->FrameIndex);
+ if (err == VK_ERROR_OUT_OF_DATE_KHR || err == VK_SUBOPTIMAL_KHR) {
+ mSwapChainRebuild = true;
+ return;
+ }
+ CheckVkResults(err);
+
+ ImGui_ImplVulkanH_Frame* fd = &wd->Frames[wd->FrameIndex];
+ {
+ err = vkWaitForFences(mDevice, 1, &fd->Fence, VK_TRUE, UINT64_MAX); // wait indefinitely instead of periodically checking
+ CheckVkResults(err);
+
+ err = vkResetFences(mDevice, 1, &fd->Fence);
+ CheckVkResults(err);
+ }
+ {
+ err = vkResetCommandPool(mDevice, fd->CommandPool, 0);
+ CheckVkResults(err);
+ VkCommandBufferBeginInfo info = {};
+ info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
+ info.flags |= VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
+ err = vkBeginCommandBuffer(fd->CommandBuffer, &info);
+ CheckVkResults(err);
+ }
+ {
+ VkRenderPassBeginInfo info = {};
+ info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
+ info.renderPass = wd->RenderPass;
+ info.framebuffer = fd->Framebuffer;
+ info.renderArea.extent.width = wd->Width;
+ info.renderArea.extent.height = wd->Height;
+ info.clearValueCount = 1;
+ info.pClearValues = &wd->ClearValue;
+ vkCmdBeginRenderPass(fd->CommandBuffer, &info, VK_SUBPASS_CONTENTS_INLINE);
+ }
+
+ // Record dear imgui primitives into command buffer
+ ImGui_ImplVulkan_RenderDrawData(drawData, fd->CommandBuffer);
+
+ // Submit command buffer
+ vkCmdEndRenderPass(fd->CommandBuffer);
+ {
+ VkPipelineStageFlags wait_stage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
+ VkSubmitInfo info = {};
+ info.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
+ info.waitSemaphoreCount = 1;
+ info.pWaitSemaphores = &imageAcquiredSemaphore;
+ info.pWaitDstStageMask = &wait_stage;
+ info.commandBufferCount = 1;
+ info.pCommandBuffers = &fd->CommandBuffer;
+ info.signalSemaphoreCount = 1;
+ info.pSignalSemaphores = &renderCompleteSemaphore;
+
+ err = vkEndCommandBuffer(fd->CommandBuffer);
+ CheckVkResults(err);
+ err = vkQueueSubmit(mQueue, 1, &info, fd->Fence);
+ CheckVkResults(err);
+ }
+ }
+
+ void FramePresent(ImGui_ImplVulkanH_Window* wd)
+ {
+ if (mSwapChainRebuild) {
+ return;
+ }
+
+ VkSemaphore renderCompleteSemaphore = wd->FrameSemaphores[wd->SemaphoreIndex].RenderCompleteSemaphore;
+ VkPresentInfoKHR info = {};
+ info.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
+ info.waitSemaphoreCount = 1;
+ info.pWaitSemaphores = &renderCompleteSemaphore;
+ info.swapchainCount = 1;
+ info.pSwapchains = &wd->Swapchain;
+ info.pImageIndices = &wd->FrameIndex;
+ VkResult err = vkQueuePresentKHR(mQueue, &info);
+ if (err == VK_ERROR_OUT_OF_DATE_KHR || err == VK_SUBOPTIMAL_KHR) {
+ mSwapChainRebuild = true;
+ return;
+ }
+ CheckVkResults(err);
+ wd->SemaphoreIndex = (wd->SemaphoreIndex + 1) % wd->ImageCount; // Now we can use the next set of semaphores
+ }
+
+ void CleanupVulkan()
+ {
+ vkDestroyDescriptorPool(mDevice, mDescriptorPool, mAllocator);
+
+ vkDestroyDevice(mDevice, mAllocator);
+ vkDestroyInstance(mInstance, mAllocator);
+ }
+
+ void CleanupVulkanWindow()
+ {
+ ImGui_ImplVulkanH_DestroyWindow(mInstance, mDevice, &mMainWindowData, mAllocator);
+ }
+
+ static void CheckVkResults(VkResult err)
+ {
+ if (err == 0) return;
+
+ std::string message;
+ message += "Vulkan error: VkResult = ";
+ message += err;
+
+ if (err < 0) {
+ throw std::runtime_error(message);
+ } else {
+ std::cerr << message << '\n';
+ }
+ }
+ static void GlfwErrorCallback(int errorCode, const char* message)
+ {
+ std::cerr << "GLFW Error " << errorCode << ": " << message << "\n";
+ }
+};
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateVulkanBackend()
+{
+ try {
+ return std::make_unique<VulkanBackend>();
+ } catch (std::exception& e) {
+ return nullptr;
+ }
+}
+
+#else // ^^ BUILD_CORE_WITH_VULKAN_BACKEND | ~BUILD_CORE_WITH_VULKAN_BACKEND vv
+
+std::unique_ptr<RenderingBackend> RenderingBackend::CreateVulkanBackend()
+{
+ return nullptr;
+}
+
+#endif
diff --git a/app/source/Cplt/Entrypoint/main.cpp b/app/source/Cplt/Entrypoint/main.cpp
new file mode 100644
index 0000000..8f67d32
--- /dev/null
+++ b/app/source/Cplt/Entrypoint/main.cpp
@@ -0,0 +1,163 @@
+#include <Cplt/Entrypoint/Backend.hpp>
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/UI/UI.hpp>
+#include <Cplt/Utils/I18n.hpp>
+#include <Cplt/Utils/ScopeGuard.hpp>
+#include <Cplt/Utils/Sigslot.hpp>
+
+#include <IconsFontAwesome.h>
+#include <imgui.h>
+#include <argparse/argparse.hpp>
+#include <filesystem>
+#include <iostream>
+#include <memory>
+#include <stdexcept>
+#include <string>
+#include <string_view>
+
+namespace fs = std::filesystem;
+using namespace std::literals::string_literals;
+using namespace std::literals::string_view_literals;
+
+static std::unique_ptr<RenderingBackend> CreateDefaultBackend()
+{
+#if defined(_WIN32)
+# if BUILD_CORE_WITH_DX12_BACKEND
+ if (auto backend = RenderingBackend::CreateDx12Backend()) {
+ return backend;
+ }
+# endif
+# if BUILD_CORE_WITH_DX11_BACKEND
+ if (auto backend = RenderingBackend::CreateDx11Backend()) {
+ return backend;
+ }
+# endif
+# if BUILD_CORE_WITH_VULKAN_BACKEND
+ if (auto backend = RenderingBackend::CreateVulkanBackend()) {
+ return backend;
+ }
+# endif
+# if BUILD_CORE_WITH_OPENGL3_BACKEND
+ if (auto backend = RenderingBackend::CreateOpenGL3Backend()) {
+ return backend;
+ }
+# endif
+# if BUILD_CORE_WITH_OPENGL2_BACKEND
+ if (auto backend = RenderingBackend::CreateOpenGL2Backend()) {
+ return backend;
+ }
+# endif
+#elif defined(__APPLE__)
+ // We currently only support using metal on macos
+ return RenderingBackend::CreateMetalBackend();
+#elif defined(__linux__)
+# if BUILD_CORE_WITH_VULKAN_BACKEND
+ if (auto backend = RenderingBackend::CreateVulkanBackend()) {
+ return backend;
+ }
+# endif
+# if BUILD_CORE_WITH_OPENGL3_BACKEND
+ if (auto backend = RenderingBackend::CreateOpenGL3Backend()) {
+ return backend;
+ }
+# endif
+# if BUILD_CORE_WITH_OPENGL2_BACKEND
+ if (auto backend = RenderingBackend::CreateOpenGL2Backend()) {
+ return backend;
+ }
+# endif
+#endif
+
+ return nullptr;
+}
+
+static std::unique_ptr<RenderingBackend> CreateBackend(std::string_view option)
+{
+ if (option == "default") {
+ return CreateDefaultBackend();
+ } else if (option == "opengl2") {
+ return RenderingBackend::CreateOpenGL2Backend();
+ } else if (option == "opengl3") {
+ return RenderingBackend::CreateOpenGL3Backend();
+ } else if (option == "vulkan") {
+ return RenderingBackend::CreateVulkanBackend();
+ } else if (option == "dx11") {
+ return RenderingBackend::CreateDx11Backend();
+ } else if (option == "dx12") {
+ return RenderingBackend::CreateDx12Backend();
+ } else if (option == "metal") {
+ return RenderingBackend::CreateMetalBackend();
+ } else {
+ std::string message;
+ message += "Unknown backend '";
+ message += option;
+ message += "'.\n";
+ throw std::runtime_error(message);
+ }
+}
+
+#ifdef DOCTEST_CONFIG_DISABLE
+int main(int argc, char* argv[])
+{
+ argparse::ArgumentParser parser;
+ parser.add_argument("--global-data-directory")
+ .help("Directory in which global data (such as recently used projects) are saved to. Use 'default' to use the default directory on each platform.")
+ .default_value("default"s);
+ parser.add_argument("--rendering-backend")
+ .help("Which rendering backend to use. If equals 'default', the preferred API for each platform will be used")
+ .default_value("default"s);
+
+ try {
+ parser.parse_args(argc, argv);
+ } catch (const std::runtime_error& error) {
+ std::cout << error.what() << '\n';
+ std::cout << parser;
+ return -1;
+ }
+
+ auto backendOption = parser.get<std::string>("--rendering-backend");
+ auto backend = CreateBackend(backendOption);
+
+ auto& io = ImGui::GetIO();
+
+ // Disable saving window positions
+ io.IniFilename = nullptr;
+ // Disable log (dump widget tree) file, we don't trigger it but just to be safe
+ io.LogFilename = nullptr;
+
+ // Light mode because all major OS's default theme is white
+ // TODO follow system theme
+ ImGui::StyleColorsLight();
+
+ // Configure default fonts
+ {
+ // Includes latin alphabet, although for some reason smaller than if rendered using 18 point NotoSans regular
+ io.Fonts->AddFontFromFileTTF("fonts/NotoSansSC-Regular.otf", 18, nullptr, io.Fonts->GetGlyphRangesChineseFull());
+
+ ImWchar iconRanges[] = { ICON_MIN_FA, ICON_MAX_FA };
+ ImFontConfig config;
+ config.MergeMode = true;
+ io.Fonts->AddFontFromFileTTF("fonts/FontAwesome5-Solid.otf", 14, &config, iconRanges);
+ }
+
+ auto dataDirOption = parser.get<std::string>("--global-data-directory");
+ if (dataDirOption == "default") {
+ GlobalStates::Init();
+ } else {
+ fs::path path(dataDirOption);
+ GlobalStates::Init(std::move(path));
+ }
+ DEFER
+ {
+ GlobalStates::Shutdown();
+ };
+
+ // Main loop
+ backend->RunUntilWindowClose(&UI::MainWindow);
+
+ return 0;
+}
+#else
+# define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+# include <doctest/doctest.h>
+#endif
diff --git a/app/source/Cplt/Locale/zh_CN.h b/app/source/Cplt/Locale/zh_CN.h
new file mode 100644
index 0000000..f654464
--- /dev/null
+++ b/app/source/Cplt/Locale/zh_CN.h
@@ -0,0 +1,159 @@
+#pragma once
+
+#define L10N_ERROR "错误"
+#define L10N_ADD "新建"
+#define L10N_EDIT "编辑"
+#define L10N_DELETE "删除"
+#define L10N_RENAME "重命名"
+#define L10N_DISCONNECT "断开连接"
+#define L10N_OPEN "打开"
+#define L10N_CLOSE "关闭"
+#define L10N_CONFIRM "确定"
+#define L10N_CANCEL "取消"
+#define L10N_NAME "名称"
+#define L10N_TYPE "类型"
+
+#define L10N_INVALID_PATH_ERROR "无效路径"
+#define L10N_EMPTY_NAME_ERROR "名称不能为空"
+#define L10N_DUPLICATE_NAME_ERROR "名称已被占用"
+
+#define L10N_MAIN_TAB_SETTINGS "设置"
+#define L10N_MAIN_WINDOW_TAB_PROJECT "项目"
+#define L10N_MAIN_WINDOW_TAB_DATABASE_VIEW "数据"
+#define L10N_MAIN_WINDOW_TAB_ITEMS "物品"
+#define L10N_MAIN_WINDOW_TAB_WORKFLOWS "工作流"
+#define L10N_MAIN_WINDOW_TAB_TEMPLATES "模板"
+
+#define L10N_PROJECT_NEW "新建项目..."
+#define L10N_PROJECT_NEW_DIALOG_TITLE "新建项目向导"
+#define L10N_PROJECT_NAME "项目名称"
+#define L10N_PROJECT_PATH "项目路径"
+#define L10N_PROJECT_NEW_PATH_DIALOG_TITLE "项目路径"
+#define L10N_PROJECT_OPEN "打开项目..."
+#define L10N_PROJECT_OPEN_DIALOG_TITLE "打开项目"
+#define L10N_PROJECT_RECENTS "最近使用"
+#define L10N_PROJECT_RECENTS_CLEAR "清空"
+#define L10N_PROJECT_RECENTS_NONE_PRESENT "(暂无最近使用的项目)"
+#define L10N_PROJECT_RECENTS_OPEN_TOOLTIP "打开该项目"
+#define L10N_PROJECT_RECENTS_DELETE_TOOLTIP "将该项目从最近使用列表中删除,项目本身将不受影响。"
+#define L10N_PROJECT_INVALID_PROJECT_FORMAT "无效的项目文件"
+
+#define L10N_PROJECT_OPEN_IN_FILESYSTEM "在文件系统中打开"
+
+#define L10N_DATABASE_SALES_VIEW_TAB_NAME "销售"
+#define L10N_DATABASE_SALES_VIEW_EDIT_DIALOG_TITLE "编辑销售记录"
+#define L10N_DATABASE_PURCHASES_VIEW_TAB_NAME "采购"
+#define L10N_DATABASE_PURCHASES_VIEW_EDIT_DIALOG_TITLE "编辑采购记录"
+#define L10N_DATABASE_COLUMN_ITEMS "项目"
+#define L10N_DATABASE_COLUMN_CUSTOMER "客户"
+#define L10N_DATABASE_COLUMN_FACTORY "工厂"
+/// 销售订单的交货期限
+#define L10N_DATABASE_COLUMN_DEADLINE "交货期限"
+/// 采购订单的下单时间
+#define L10N_DATABASE_COLUMN_ORDER_TIME "下单时间"
+/// 所有订单的“完成”时间。对于销售来说是实际交货时间,对于采购来说是收货时间。
+#define L10N_DATABASE_COLUMN_DELIVERY_TIME "交货时间"
+/// 运输批次的发货时间,适用于采购和销售批次。
+#define L10N_DATABASE_COLUMN_SHIPMENT_TIME "发货时间"
+/// 运输批次的收获时间,适用于采购和销售批次。
+#define L10N_DATABASE_COLUMN_ARRIVAL_TIME "实际到达时间"
+#define L10N_DATABASE_MESSAGE_NO_ORDER_SELECTED "选择任意一个订单以查看与其相关的批次"
+#define L10N_DATABASE_MESSAGE_NOT_DELIVERED "N/A"
+
+#define L10N_ITEM_ADD_DIALOG_TITLE "新建物品项"
+#define L10N_ITEM_EDIT_DIALOG_TITLE "编辑物品项"
+#define L10N_ITEM_DELETE_DIALOG_TITLE "删除物品项"
+#define L10N_ITEM_DELETE_DIALOG_MESSAGE "确定删除该物品项吗?"
+#define L10N_ITEM_CATEGORY_PRODUCT "产品"
+#define L10N_ITEM_CATEGORY_FACTORY "工厂"
+#define L10N_ITEM_CATEGORY_CUSTOMER "客户"
+#define L10N_ITEM_COLUMN_NAME "名称"
+#define L10N_ITEM_COLUMN_DESCRIPTION "描述"
+#define L10N_ITEM_COLUMN_EMAIL "邮箱"
+#define L10N_ITEM_COLUMN_STOCK "库存"
+#define L10N_ITEM_COLUMN_PRICE "价格"
+
+#define L10N_ASSET_OPEN "打开资源..."
+#define L10N_ASSET_OPEN_DIALOG_TITLE "打开资源"
+#define L10N_ASSET_MANAGE "管理资源..."
+#define L10N_ASSET_MANAGE_DIALOG_TITLE "管理资源"
+#define L10N_ADD_ASSET_DIALOG_TITLE "新建资源向导"
+#define L10N_DELETE_ASSET_DIALOG_TITLE "确认删除资源"
+#define L10N_RENAME_ASSET_DIALOG_TITLE "重命名资源"
+#define L10N_TEMPLATE_INVALID_TYPE_ERROR "无效的模板类型"
+
+#define L10N_VALUE_NUMERIC "数值"
+#define L10N_VALUE_TEXT "文本"
+#define L10N_VALUE_DATE_TIME "时间戳"
+#define L10N_VALUE_ROW_ID "数据库表格行"
+#define L10N_VALUE_LIST "列表"
+#define L10N_VALUE_DICT "字典"
+#define L10N_VALUE_OBJECT "对象"
+#define L10N_VALUE_SALE_RECORD "销售记录"
+#define L10N_VALUE_PURCHASE_RECORD "采购记录"
+
+#define L10N_VALUE_PROPERTY_CUSTOMER "客户"
+#define L10N_VALUE_PROPERTY_DEADLINE "交货期限"
+#define L10N_VALUE_PROPERTY_FACTORY "工厂"
+#define L10N_VALUE_PROPERTY_ORDER_TIME "下单时间"
+#define L10N_VALUE_PROPERTY_DELIVERY_TIME "交货时间"
+
+#define L10N_WORKFLOW_KIND_INPUT "输入节点"
+#define L10N_WORKFLOW_KIND_TRANSFORM "计算节点"
+#define L10N_WORKFLOW_KIND_OUTPUT "输出节点"
+
+#define L10N_WORKFLOW_ADD "加法"
+#define L10N_WORKFLOW_SUB "减法"
+#define L10N_WORKFLOW_MUL "乘法"
+#define L10N_WORKFLOW_DIV "除法"
+#define L10N_WORKFLOW_EVAL "对表达式求值"
+#define L10N_WORKFLOW_FMT "格式化文本"
+#define L10N_WORKFLOW_INSTANTIATE_TEMPLATE "实例化文档"
+#define L10N_WORKFLOW_FORM_INPUT "表单输入"
+#define L10N_WORKFLOW_DB_INPUT "数据库输入"
+
+#define L10N_WORKFLOW_CATEGORY_NUMERIC "数字"
+#define L10N_WORKFLOW_CATEGORY_TEXT "文本"
+#define L10N_WORKFLOW_CATEGORY_DOCUMENT "文档"
+#define L10N_WORKFLOW_CATEGORY_USER_INPUT "用户输入"
+#define L10N_WORKFLOW_CATEGORY_SYS_INPUT "环境输入"
+#define L10N_WORKFLOW_CATEGORY_OUTPUT "输出"
+
+#define L10N_WORKFLOW_RTERROR_DIV_BY_0 "错误:除数为0"
+
+#define L10N_TEMPLATE_TABLE "表格模板"
+
+#define L10N_TABLE "表格"
+#define L10N_TABLE_CONFIGURE_PROPERTIES "编辑表格属性..."
+#define L10N_TABLE_PROPERTIES "表格属性"
+#define L10N_TABLE_EDIT_TABLE "编辑表格"
+#define L10N_TABLE_EDIT_RESIZE_COLS "调整列宽度"
+#define L10N_TABLE_EDIT_RESIZE_ROWS "调整行高度"
+#define L10N_TABLE_WIDTH "宽度"
+#define L10N_TABLE_HEIGHT "长度"
+#define L10N_TABLE_SINGLE_PARAMS "参数"
+#define L10N_TABLE_ARRAY_GROUPS "列表参数组"
+#define L10N_TABLE_CELL "单元格"
+#define L10N_TABLE_CELL_POS "位置:%s%s"
+#define L10N_TABLE_CELL_TYPE_CONST "类型:普通单元格"
+#define L10N_TABLE_CELL_TYPE_PARAM "类型:参数单元格"
+#define L10N_TABLE_CELL_TYPE_CREATE_AG "类型:列表参数组"
+#define L10N_TABLE_CELL_CONV_CONST "转换为普通单元格"
+#define L10N_TABLE_CELL_CONV_PARAM "转换为参数单元格"
+#define L10N_TABLE_CELL_CONV_CREATE_AG "创建列表参数组"
+#define L10N_TABLE_CELL_CONV_ADD_AG_LEFT "添加至左侧的列表参数组"
+#define L10N_TABLE_CELL_CONV_ADD_AG_RIGHT "添加至右侧的列表参数组"
+#define L10N_TABLE_CELL_SELECT_MSG "请单击选择一个单元格以编辑其属性"
+#define L10N_TABLE_CELL_HORIZONTAL_ALIGNMENT "水平对齐"
+#define L10N_TABLE_CELL_CONTENT "内容"
+#define L10N_TABLE_CELL_VAR_NAME "变量名"
+#define L10N_TABLE_CELL_VAR_TOOLTIP "参数单元格的唯一名称(表格内不得重复)。"
+#define L10N_TABLE_CELL_ARRAY_VAR_TOOLTIP "列表参数组内单个参数单元格的名称,在组内唯一(不同的参数组可以包含相同的参数名)。"
+#define L10N_TABLE_CELL_VAR_NAME_DUP "参数名已重复"
+#define L10N_TABLE_CELL_VERTICAL_ALIGNMENT "垂直对齐"
+#define L10N_TABLE_CELL_ALIGN_LEFT "左对齐"
+#define L10N_TABLE_CELL_ALIGN_CENTER "居中"
+#define L10N_TABLE_CELL_ALIGN_RIGHT "右对齐"
+#define L10N_TABLE_CELL_ALIGN_TOP "顶部对齐"
+#define L10N_TABLE_CELL_ALIGN_MIDDLE "居中"
+#define L10N_TABLE_CELL_ALIGN_BOTTOM "底部对齐"
diff --git a/app/source/Cplt/Model/Assets.cpp b/app/source/Cplt/Model/Assets.cpp
new file mode 100644
index 0000000..0dfe847
--- /dev/null
+++ b/app/source/Cplt/Model/Assets.cpp
@@ -0,0 +1,306 @@
+#include "Assets.hpp"
+
+#include <Cplt/UI/UI.hpp>
+#include <Cplt/Utils/I18n.hpp>
+#include <Cplt/Utils/IO/DataStream.hpp>
+#include <Cplt/Utils/IO/StringIntegration.hpp>
+#include <Cplt/Utils/IO/UuidIntegration.hpp>
+
+#include <IconsFontAwesome.h>
+#include <imgui.h>
+#include <imgui_stdlib.h>
+#include <tsl/array_map.h>
+#include <string>
+#include <utility>
+
+using namespace std::literals::string_view_literals;
+namespace fs = std::filesystem;
+
+template <class TSavedAsset, class TStream>
+void OperateStreamForSavedAsset(TSavedAsset& cell, TStream& proxy)
+{
+ proxy.template ObjectAdapted<DataStreamAdapters::String>(cell.Name);
+ proxy.template ObjectAdapted<DataStreamAdapters::Uuid>(cell.Uuid);
+ proxy.Value(cell.Payload);
+}
+
+void SavedAsset::ReadFromDataStream(InputDataStream& stream)
+{
+ ::OperateStreamForSavedAsset(*this, stream);
+}
+
+void SavedAsset::WriteToDataStream(OutputDataStream& stream) const
+{
+ ::OperateStreamForSavedAsset(*this, stream);
+}
+
+Asset::Asset() = default;
+
+class AssetList::Private
+{
+public:
+ Project* ConnectedProject;
+ tsl::array_map<char, SavedAsset> Assets;
+ tsl::array_map<char, std::unique_ptr<Asset>> Cache;
+ int CacheSizeLimit = 0;
+
+ struct
+ {
+ std::string NewName;
+ NameSelectionError NewNameError = NameSelectionError::Empty;
+
+ void ShowErrors() const
+ {
+ switch (NewNameError) {
+ case NameSelectionError::None: break;
+ case NameSelectionError::Duplicated:
+ ImGui::ErrorMessage(I18N_TEXT("Duplicate name", L10N_DUPLICATE_NAME_ERROR));
+ break;
+ case NameSelectionError::Empty:
+ ImGui::ErrorMessage(I18N_TEXT("Name cannot be empty", L10N_EMPTY_NAME_ERROR));
+ break;
+ }
+ }
+
+ bool HasErrors() const
+ {
+ return NewNameError != NameSelectionError::None;
+ }
+
+ void Validate(const AssetList& self)
+ {
+ if (NewName.empty()) {
+ NewNameError = NameSelectionError::Empty;
+ return;
+ }
+
+ if (self.FindByName(NewName)) {
+ NewNameError = NameSelectionError::Duplicated;
+ return;
+ }
+
+ NewNameError = NameSelectionError::None;
+ }
+ } PopupPrivateState;
+};
+
+AssetList::AssetList(Project& project)
+ : mPrivate{ std::make_unique<Private>() }
+{
+ mPrivate->ConnectedProject = &project;
+}
+
+// Write an empty destructor here so std::unique_ptr's destructor can see AssetList::Private's implementation
+AssetList::~AssetList()
+{
+}
+
+Project& AssetList::GetConnectedProject() const
+{
+ return *mPrivate->ConnectedProject;
+}
+
+void AssetList::Reload()
+{
+ // TODO fix asset dicovery loading
+ mPrivate->Assets.clear();
+ mPrivate->Cache.clear();
+ DiscoverFiles([this](SavedAsset asset) -> void {
+ mPrivate->Assets.insert(asset.Name, std::move(asset));
+ });
+}
+
+int AssetList::GetCount() const
+{
+ return mPrivate->Assets.size();
+}
+
+const tsl::array_map<char, SavedAsset>& AssetList::GetAssets() const
+{
+ return mPrivate->Assets;
+}
+
+const SavedAsset* AssetList::FindByName(std::string_view name) const
+{
+ auto iter = mPrivate->Assets.find(name);
+ if (iter != mPrivate->Assets.end()) {
+ return &iter.value();
+ } else {
+ return nullptr;
+ }
+}
+
+const SavedAsset& AssetList::Create(SavedAsset asset)
+{
+ auto [iter, DISCARD] = mPrivate->Assets.insert(asset.Name, SavedAsset{});
+ auto& savedAsset = iter.value();
+
+ savedAsset = std::move(asset);
+ if (savedAsset.Uuid.is_nil()) {
+ savedAsset.Uuid = uuids::uuid_random_generator{}();
+ }
+
+ SaveInstance(savedAsset, nullptr);
+
+ return savedAsset;
+}
+
+std::unique_ptr<Asset> AssetList::CreateAndLoad(SavedAsset assetIn)
+{
+ auto& savedAsset = Create(std::move(assetIn));
+ auto asset = std::unique_ptr<Asset>(CreateInstance(savedAsset));
+ return asset;
+}
+
+std::unique_ptr<Asset> AssetList::Load(std::string_view name) const
+{
+ if (auto savedAsset = FindByName(name)) {
+ auto asset = Load(*savedAsset);
+ return asset;
+ } else {
+ return nullptr;
+ }
+}
+
+std::unique_ptr<Asset> AssetList::Load(const SavedAsset& asset) const
+{
+ return std::unique_ptr<Asset>(LoadInstance(asset));
+}
+
+const SavedAsset* AssetList::Rename(std::string_view oldName, std::string_view newName)
+{
+ auto iter = mPrivate->Assets.find(oldName);
+ if (iter == mPrivate->Assets.end()) return nullptr;
+
+ auto info = std::move(iter.value());
+ info.Name = newName;
+
+ RenameInstanceOnDisk(info, oldName);
+
+ mPrivate->Assets.erase(iter);
+ auto [newIter, DISCARD] = mPrivate->Assets.insert(newName, std::move(info));
+
+ return &newIter.value();
+}
+
+bool AssetList::Remove(std::string_view name)
+{
+ auto iter = mPrivate->Assets.find(name);
+ if (iter == mPrivate->Assets.end()) {
+ return false;
+ }
+ auto& asset = iter.value();
+
+ fs::remove(RetrievePathFromAsset(asset));
+ mPrivate->Assets.erase(iter);
+
+ return true;
+}
+
+int AssetList::GetCacheSizeLimit() const
+{
+ return mPrivate->CacheSizeLimit;
+}
+
+void AssetList::SetCacheSizeLimit(int limit)
+{
+ mPrivate->CacheSizeLimit = limit;
+}
+
+void AssetList::DisplayIconsList(ListState& state)
+{
+ // TODO
+}
+
+void AssetList::DisplayDetailsList(ListState& state)
+{
+ // Note: stub function remained here in case any state processing needs to be done before issuing to implementers
+ DisplayDetailsTable(state);
+}
+
+void AssetList::DisplayControls(ListState& state)
+{
+ auto& ps = mPrivate->PopupPrivateState;
+ bool openedDummy = true;
+
+ if (ImGui::Button(ICON_FA_PLUS " " I18N_TEXT("Add", L10N_ADD))) {
+ ImGui::OpenPopup(I18N_TEXT("Add asset wizard", L10N_ADD_ASSET_DIALOG_TITLE));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Add asset wizard", L10N_ADD_ASSET_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ DisplayAssetCreator(state);
+ ImGui::EndPopup();
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_I_CURSOR " " I18N_TEXT("Rename", L10N_RENAME), state.SelectedAsset == nullptr)) {
+ ImGui::OpenPopup(I18N_TEXT("Rename asset wizard", L10N_RENAME_ASSET_DIALOG_TITLE));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Rename asset wizard", L10N_RENAME_ASSET_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ if (ImGui::InputText(I18N_TEXT("Name", L10N_NAME), &ps.NewName)) {
+ ps.Validate(*this);
+ }
+
+ if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM), ps.HasErrors())) {
+ ImGui::CloseCurrentPopup();
+
+ auto movedAsset = Rename(state.SelectedAsset->Name, ps.NewName);
+ // Update the selected pointer to the new location (we mutated the map, the pointer may be invalid now)
+ state.SelectedAsset = movedAsset;
+
+ ps = {};
+ }
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ }
+
+ ps.ShowErrors();
+
+ ImGui::EndPopup();
+ }
+
+ ImGui::SameLine();
+ if (ImGui::Button(ICON_FA_TRASH " " I18N_TEXT("Delete", L10N_DELETE), state.SelectedAsset == nullptr)) {
+ ImGui::OpenPopup(I18N_TEXT("Delete asset", L10N_DELETE_ASSET_DIALOG_TITLE));
+ }
+ if (ImGui::BeginPopupModal(I18N_TEXT("Delete asset", L10N_DELETE_ASSET_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) {
+ if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM))) {
+ ImGui::CloseCurrentPopup();
+
+ auto& assetName = state.SelectedAsset->Name;
+ Remove(assetName);
+
+ state.SelectedAsset = nullptr;
+ }
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ }
+ ImGui::EndPopup();
+ }
+}
+
+void AssetList::DiscoverFilesByExtension(const std::function<void(SavedAsset)>& callback, const fs::path& containerDir, std::string_view extension) const
+{
+ for (auto entry : fs::directory_iterator(containerDir)) {
+ if (!entry.is_regular_file()) continue;
+
+ // If the caller provided an extension to match against, and it doesn't equal to current file extension, skip
+ if (!extension.empty() &&
+ entry.path().extension() != extension)
+ {
+ continue;
+ }
+
+ callback(SavedAsset{
+ .Name = RetrieveNameFromFile(entry.path()),
+ .Uuid = RetrieveUuidFromFile(entry.path()),
+ // TODO load payload
+ });
+ }
+}
+
+void AssetList::DiscoverFilesByHeader(const std::function<void(SavedAsset)>& callback, const fs::path& containerDir, const std::function<bool(std::istream&)>& validater) const
+{
+ // TODO
+}
diff --git a/app/source/Cplt/Model/Assets.hpp b/app/source/Cplt/Model/Assets.hpp
new file mode 100644
index 0000000..d2f8570
--- /dev/null
+++ b/app/source/Cplt/Model/Assets.hpp
@@ -0,0 +1,130 @@
+#pragma once
+
+#include <Cplt/Utils/UUID.hpp>
+#include <Cplt/fwd.hpp>
+
+#include <tsl/array_map.h>
+#include <filesystem>
+#include <iosfwd>
+#include <memory>
+#include <string_view>
+
+/// A structure representing a ready-to-be-loaded asset, locating on the disk.
+/// Each asset should be identified by a unique uuid within the asset category (i.e. a workflow and a template can share the same uuid),
+/// generated on insertion to an asset list if not given by the caller.
+struct SavedAsset
+{
+ std::string Name;
+ /// UUID of this asset. This field is generated as a random UUID v4 upon insertion into an AssetList, if not already provided by the caller (indicated by !is_nil()).
+ uuids::uuid Uuid;
+ /// Extra data to be used by the AssetList/Asset implementation.
+ uint64_t Payload;
+
+ void ReadFromDataStream(InputDataStream& stream);
+ void WriteToDataStream(OutputDataStream& stream) const;
+};
+
+class Asset
+{
+public:
+ Asset();
+ virtual ~Asset() = default;
+};
+
+enum class NameSelectionError
+{
+ None,
+ Duplicated,
+ Empty,
+};
+
+class AssetList
+{
+private:
+ class Private;
+ std::unique_ptr<Private> mPrivate;
+
+public:
+ AssetList(Project& project);
+ virtual ~AssetList();
+
+ Project& GetConnectedProject() const;
+
+ // TODO support file watches
+ void Reload();
+
+ int GetCount() const;
+ // TODO convert to custom iterable
+ const tsl::array_map<char, SavedAsset>& GetAssets() const;
+
+ const SavedAsset* FindByName(std::string_view name) const;
+ const SavedAsset& Create(SavedAsset asset);
+ std::unique_ptr<Asset> CreateAndLoad(SavedAsset asset);
+ /// Load the asset on disk by its name.
+ std::unique_ptr<Asset> Load(std::string_view name) const;
+ /// Load the asset on disk by a reference to its SavedAsset instance. This function assumes that the given SavedAsset
+ /// is stored in AssetList, otherwise the behavior is undefined.
+ std::unique_ptr<Asset> Load(const SavedAsset& asset) const;
+ const SavedAsset* Rename(std::string_view oldName, std::string_view newName);
+ bool Remove(std::string_view name);
+
+ int GetCacheSizeLimit() const;
+ void SetCacheSizeLimit(int limit);
+
+ struct ListState
+ {
+ const SavedAsset* SelectedAsset = nullptr;
+ };
+ void DisplayIconsList(ListState& state);
+ void DisplayDetailsList(ListState& state);
+ void DisplayControls(ListState& state);
+
+protected:
+ virtual void DiscoverFiles(const std::function<void(SavedAsset)>& callback) const = 0;
+
+ // Helper
+ void DiscoverFilesByExtension(const std::function<void(SavedAsset)>& callback, const std::filesystem::path& containerDir, std::string_view extension) const;
+ void DiscoverFilesByHeader(const std::function<void(SavedAsset)>& callback, const std::filesystem::path& containerDir, const std::function<bool(std::istream&)>& validater) const;
+
+ /// Create an empty/default instance of this asset type on disk, potentially qualified by SavedAsset::Payload.
+ /// Return `true` on success and `false` on failure.
+ virtual bool SaveInstance(const SavedAsset& assetInfo, const Asset* asset) const = 0;
+ /// The returned pointer indicate ownership to the object.
+ virtual Asset* LoadInstance(const SavedAsset& assetInfo) const = 0;
+ /// Create an empty/default instance of this asset type, potentially qualified by SavedAsset::Payload.
+ /// The returned pointer indicate ownership to the object.
+ virtual Asset* CreateInstance(const SavedAsset& assetInfo) const = 0;
+ virtual bool RenameInstanceOnDisk(const SavedAsset& assetInfo, std::string_view oldName) const = 0;
+
+ virtual std::string RetrieveNameFromFile(const std::filesystem::path& file) const = 0;
+ virtual uuids::uuid RetrieveUuidFromFile(const std::filesystem::path& file) const = 0;
+ virtual std::filesystem::path RetrievePathFromAsset(const SavedAsset& asset) const = 0;
+
+ virtual void DisplayAssetCreator(ListState& state) = 0;
+ virtual void DisplayDetailsTable(ListState& state) const = 0;
+};
+
+template <class T>
+class AssetListTyped : public AssetList
+{
+public:
+ using AssetList::AssetList;
+
+#pragma clang diagnostic push
+#pragma ide diagnostic ignored "HidingNonVirtualFunction"
+ std::unique_ptr<T> CreateAndLoad(SavedAsset asset)
+ {
+ return std::unique_ptr<T>(static_cast<T*>(AssetList::CreateAndLoad(asset).release()));
+ }
+
+ std::unique_ptr<T> Load(std::string_view name) const
+ {
+ return std::unique_ptr<T>(static_cast<T*>(AssetList::Load(name).release()));
+ }
+
+ std::unique_ptr<T> Load(const SavedAsset& asset) const
+ {
+ return std::unique_ptr<T>(static_cast<T*>(AssetList::Load(asset).release()));
+ }
+#pragma clang diagnostic pop
+};
diff --git a/app/source/Cplt/Model/Database.cpp b/app/source/Cplt/Model/Database.cpp
new file mode 100644
index 0000000..07c6e36
--- /dev/null
+++ b/app/source/Cplt/Model/Database.cpp
@@ -0,0 +1,163 @@
+#include "Database.hpp"
+
+#include <Cplt/Model/Project.hpp>
+
+#include <filesystem>
+#include <stdexcept>
+
+namespace fs = std::filesystem;
+
+SalesTable::SalesTable(MainDatabase& db)
+ // language=SQLite
+ : GetRowCount(db.GetSQLite(), "SELECT Count(*) FROM Sales")
+ // language=SQLite
+ , GetRows(db.GetSQLite(), "SELECT * FROM Sales LIMIT ? OFFSET ?")
+ // language=SQLite
+ , GetItems(db.GetSQLite(), "SELECT * FROM SalesItems WHERE SaleId == ?")
+{
+}
+
+PurchasesTable::PurchasesTable(MainDatabase& db)
+ // language=SQLite
+ : GetRowCount(db.GetSQLite(), "SELECT Count(*) FROM Purchases")
+ // language=SQLite
+ , GetRows(db.GetSQLite(), "SELECT * FROM Purchases LIMIT ? OFFSET ?")
+ // language=SQLite
+ , GetItems(db.GetSQLite(), "SELECT * FROM PurchasesItems WHERE PurchaseId == ?")
+{
+}
+
+DeliveryTable::DeliveryTable(MainDatabase& db)
+ // language=SQLite
+ : FilterByTypeAndId(db.GetSQLite(), "SELECT * FROM Deliveries WHERE AssociatedOrder == ? AND Outgoing = ?")
+ // language=SQLite
+ , GetItems(db.GetSQLite(), "SELECT * FROM DeliveriesItems WHERE DeliveryId == ?")
+{
+}
+
+static std::string GetDatabaseFilePath(const Project& project)
+{
+ auto dbsDir = project.GetPath() / "databases";
+ fs::create_directories(dbsDir);
+
+ auto dbFile = dbsDir / "transactions.sqlite3";
+ return dbFile.string();
+}
+
+/// Wrapper for SQLite::Database that creates the default tables
+MainDatabase::DatabaseWrapper::DatabaseWrapper(MainDatabase& self)
+ : mSqlite(GetDatabaseFilePath(*self.mProject), SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE)
+{
+ // If this table doesn't exist, the database probably just got initialized
+ if (mSqlite.tableExists("Sales")) {
+ return;
+ }
+
+ // 'Sales' schema
+ // - Customer: the customer item ID
+ // - Deadline: unix epoch time of order deadline
+ // - DeliveryTime: the time this order was completed (through a set of deliveries)
+
+ // 'Purchases' schema
+ // - Factory: the factory id,
+ // - OrderTime: the time this order was made
+ // - DeliveryTime: the time this order was completed (through a set of deliveries)
+
+ // 'Deliveries' schema
+ // - ShipmentTime: unix epoch time stamp of sending to delivery
+ // - ArrivalTime: unix epoch time stamp of delivery arrived at warehouse; 0 if not arrived yet
+ // - AssociatedOrder: Id of the order that this delivery is completing (which table: Outgoing=true -> Sales, Outgoing=false -> Purchases)
+ // - Outgoing: true if the delivery is from warehouse to customer; false if the delivery is from factory to warehouse
+
+ // Note: the 'Id' key would be unique (not recycled after row deletion) because it's explicit
+ // https://www.sqlite.org/rowidtable.html
+
+ // language=SQLite
+ mSqlite.exec(R"""(
+CREATE TABLE IF NOT EXISTS Sales(
+ Id INT PRIMARY KEY,
+ Customer INT,
+ Deadline DATETIME,
+ DeliveryTime DATETIME
+);
+CREATE TABLE IF NOT EXISTS SalesItems(
+ SaleId INT,
+ ItemId INT,
+ Count INT
+);
+
+CREATE TABLE IF NOT EXISTS Purchases(
+ Id INT PRIMARY KEY,
+ Factory INT,
+ OrderTime DATETIME,
+ DeliveryTime DATETIME
+);
+CREATE TABLE IF NOT EXISTS PurchasesItems(
+ PurchaseId INT,
+ ItemId INT,
+ Count INT
+);
+
+CREATE TABLE IF NOT EXISTS Deliveries(
+ Id INT PRIMARY KEY,
+ ShipmentTime DATETIME,
+ ArrivalTime DATETIME,
+ AssociatedOrder INT,
+ Outgoing BOOLEAN
+);
+CREATE TABLE IF NOT EXISTS DeliveriesItems(
+ DeliveryId INT,
+ ItemId INT,
+ Count INT
+);
+)""");
+}
+
+MainDatabase::MainDatabase(Project& project)
+ : mProject{ &project }
+ , mDbWrapper(*this)
+ , mSales(*this)
+ , mPurchases(*this)
+ , mDeliveries(*this)
+{
+}
+
+const SQLite::Database& MainDatabase::GetSQLite() const
+{
+ return mDbWrapper.mSqlite;
+}
+
+SQLite::Database& MainDatabase::GetSQLite()
+{
+ return mDbWrapper.mSqlite;
+}
+
+const SalesTable& MainDatabase::GetSales() const
+{
+ return mSales;
+}
+
+SalesTable& MainDatabase::GetSales()
+{
+ return mSales;
+}
+
+const PurchasesTable& MainDatabase::GetPurchases() const
+{
+ return mPurchases;
+}
+
+PurchasesTable& MainDatabase::GetPurchases()
+{
+ return mPurchases;
+}
+
+const DeliveryTable& MainDatabase::GetDeliveries() const
+{
+ return mDeliveries;
+}
+
+DeliveryTable& MainDatabase::GetDeliveries()
+{
+ return mDeliveries;
+}
diff --git a/app/source/Cplt/Model/Database.hpp b/app/source/Cplt/Model/Database.hpp
new file mode 100644
index 0000000..222e43d
--- /dev/null
+++ b/app/source/Cplt/Model/Database.hpp
@@ -0,0 +1,79 @@
+#pragma once
+
+#include <Cplt/fwd.hpp>
+
+#include <SQLiteCpp/Database.h>
+#include <SQLiteCpp/Statement.h>
+#include <cstdint>
+
+enum class TableKind
+{
+ Sales,
+ SalesItems,
+ Purchases,
+ PurchasesItems,
+ Deliveries,
+ DeliveriesItems,
+};
+
+class SalesTable
+{
+public:
+ SQLite::Statement GetRowCount;
+ SQLite::Statement GetRows;
+ SQLite::Statement GetItems;
+
+public:
+ SalesTable(MainDatabase& db);
+};
+
+class PurchasesTable
+{
+public:
+ SQLite::Statement GetRowCount;
+ SQLite::Statement GetRows;
+ SQLite::Statement GetItems;
+
+public:
+ PurchasesTable(MainDatabase& db);
+};
+
+class DeliveryTable
+{
+public:
+ SQLite::Statement FilterByTypeAndId;
+ SQLite::Statement GetItems;
+
+public:
+ DeliveryTable(MainDatabase& db);
+};
+
+class MainDatabase
+{
+private:
+ class DatabaseWrapper
+ {
+ public:
+ SQLite::Database mSqlite;
+ DatabaseWrapper(MainDatabase& self);
+ };
+
+ Project* mProject;
+ DatabaseWrapper mDbWrapper;
+ SalesTable mSales;
+ PurchasesTable mPurchases;
+ DeliveryTable mDeliveries;
+
+public:
+ MainDatabase(Project& project);
+
+ const SQLite::Database& GetSQLite() const;
+ SQLite::Database& GetSQLite();
+
+ const SalesTable& GetSales() const;
+ SalesTable& GetSales();
+ const PurchasesTable& GetPurchases() const;
+ PurchasesTable& GetPurchases();
+ const DeliveryTable& GetDeliveries() const;
+ DeliveryTable& GetDeliveries();
+};
diff --git a/app/source/Cplt/Model/Filter.cpp b/app/source/Cplt/Model/Filter.cpp
new file mode 100644
index 0000000..1e4b31b
--- /dev/null
+++ b/app/source/Cplt/Model/Filter.cpp
@@ -0,0 +1 @@
+#include "Filter.hpp"
diff --git a/app/source/Cplt/Model/Filter.hpp b/app/source/Cplt/Model/Filter.hpp
new file mode 100644
index 0000000..1b923e1
--- /dev/null
+++ b/app/source/Cplt/Model/Filter.hpp
@@ -0,0 +1,6 @@
+#pragma once
+
+class TableRowsFilter
+{
+ // TODO
+};
diff --git a/app/source/Cplt/Model/GlobalStates.cpp b/app/source/Cplt/Model/GlobalStates.cpp
new file mode 100644
index 0000000..417514f
--- /dev/null
+++ b/app/source/Cplt/Model/GlobalStates.cpp
@@ -0,0 +1,163 @@
+#include "GlobalStates.hpp"
+
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/Utils/StandardDirectories.hpp>
+
+#include <json/reader.h>
+#include <json/value.h>
+#include <json/writer.h>
+#include <cassert>
+#include <filesystem>
+#include <fstream>
+#include <memory>
+
+namespace fs = std::filesystem;
+
+static std::unique_ptr<GlobalStates> globalStateInstance;
+static fs::path globalDataPath;
+
+void GlobalStates::Init()
+{
+ Init(StandardDirectories::UserData() / "cplt");
+}
+
+void GlobalStates::Init(std::filesystem::path userDataDir)
+{
+ globalStateInstance = std::make_unique<GlobalStates>();
+ globalDataPath = userDataDir;
+ fs::create_directories(globalDataPath);
+
+ // Reading recent projects
+ {
+ std::ifstream ifs(globalDataPath / "recents.json");
+ if (!ifs) return;
+
+ Json::Value root;
+ ifs >> root;
+
+ if (!root.isObject()) return;
+ if (auto& recents = root["RecentProjects"]; recents.isArray()) {
+ for (auto& elm : recents) {
+ if (!elm.isString()) continue;
+
+ fs::path path(elm.asCString());
+ if (!fs::exists(path)) continue;
+
+ auto utf8String = path.string();
+ globalStateInstance->mRecentProjects.push_back(RecentProject{
+ .Path = std::move(path),
+ .CachedUtf8String = std::move(utf8String),
+ });
+ }
+ }
+ }
+}
+
+void GlobalStates::Shutdown()
+{
+ if (!globalStateInstance) return;
+
+ globalStateInstance->SetCurrentProject(nullptr);
+
+ if (globalStateInstance->mDirty) {
+ globalStateInstance->WriteToDisk();
+ }
+}
+
+GlobalStates& GlobalStates::GetInstance()
+{
+ return *globalStateInstance;
+}
+
+const std::filesystem::path& GlobalStates::GetUserDataPath()
+{
+ return globalDataPath;
+}
+
+const std::vector<GlobalStates::RecentProject>& GlobalStates::GetRecentProjects() const
+{
+ return mRecentProjects;
+}
+
+void GlobalStates::ClearRecentProjects()
+{
+ mRecentProjects.clear();
+ MarkDirty();
+}
+
+void GlobalStates::AddRecentProject(const Project& project)
+{
+ mRecentProjects.push_back(RecentProject{
+ .Path = project.GetPath(),
+ .CachedUtf8String = project.GetPath().string(),
+ });
+ MarkDirty();
+}
+
+void GlobalStates::MoveProjectToTop(const Project& project)
+{
+ for (auto it = mRecentProjects.begin(); it != mRecentProjects.end(); ++it) {
+ if (it->Path == project.GetPath()) {
+ std::rotate(it, it + 1, mRecentProjects.end());
+ MarkDirty();
+ return;
+ }
+ }
+ AddRecentProject(project);
+}
+
+void GlobalStates::RemoveRecentProject(int idx)
+{
+ assert(idx >= 0 && idx < mRecentProjects.size());
+
+ mRecentProjects.erase(mRecentProjects.begin() + idx);
+ MarkDirty();
+}
+
+bool GlobalStates::HasCurrentProject() const
+{
+ return mCurrentProject != nullptr;
+}
+
+Project* GlobalStates::GetCurrentProject() const
+{
+ return mCurrentProject.get();
+}
+
+void GlobalStates::SetCurrentProject(std::unique_ptr<Project> project)
+{
+ if (mCurrentProject) {
+ mCurrentProject->WriteToDisk();
+ mCurrentProject = nullptr;
+ }
+ if (project) {
+ MoveProjectToTop(*project);
+ }
+ mCurrentProject = std::move(project);
+}
+
+void GlobalStates::WriteToDisk() const
+{
+ Json::Value root;
+
+ auto& recentProjects = root["RecentProjects"] = Json::Value(Json::arrayValue);
+ for (auto& [path, _] : mRecentProjects) {
+ recentProjects.append(Json::Value(path.string()));
+ }
+
+ std::ofstream ofs(globalDataPath / "recents.json");
+ ofs << root;
+
+ mDirty = false;
+}
+
+bool GlobalStates::IsDirty() const
+{
+ return mDirty;
+}
+
+void GlobalStates::MarkDirty()
+{
+ mDirty = true;
+ OnModified();
+}
diff --git a/app/source/Cplt/Model/GlobalStates.hpp b/app/source/Cplt/Model/GlobalStates.hpp
new file mode 100644
index 0000000..1eb47fb
--- /dev/null
+++ b/app/source/Cplt/Model/GlobalStates.hpp
@@ -0,0 +1,55 @@
+#pragma once
+
+#include <Cplt/Utils/Sigslot.hpp>
+#include <Cplt/fwd.hpp>
+
+#include <filesystem>
+#include <string>
+#include <vector>
+
+class GlobalStates
+{
+public:
+ static void Init();
+ static void Init(std::filesystem::path userDataDir);
+ static void Shutdown();
+
+ static GlobalStates& GetInstance();
+ static const std::filesystem::path& GetUserDataPath();
+
+ struct RecentProject
+ {
+ std::filesystem::path Path;
+ std::string CachedUtf8String;
+ };
+
+public:
+ Signal<> OnModified;
+
+private:
+ std::vector<RecentProject> mRecentProjects;
+ std::unique_ptr<Project> mCurrentProject;
+ mutable bool mDirty = false;
+
+public:
+ const std::vector<RecentProject>& GetRecentProjects() const;
+ void ClearRecentProjects();
+ void AddRecentProject(const Project& project);
+ /// Move or add the project to end of the recent projects list.
+ /// If the project is not in the list of recently used projects, it will be appended, otherwise
+ /// it will be moved to the end.
+ void MoveProjectToTop(const Project& project);
+ void RemoveRecentProject(int idx);
+
+ bool HasCurrentProject() const;
+ Project* GetCurrentProject() const;
+ void SetCurrentProject(std::unique_ptr<Project> project);
+
+ // TODO async autosaving to prevent data loss on crash
+ void WriteToDisk() const;
+
+ bool IsDirty() const;
+
+private:
+ void MarkDirty();
+};
diff --git a/app/source/Cplt/Model/Items.cpp b/app/source/Cplt/Model/Items.cpp
new file mode 100644
index 0000000..9d2abc6
--- /dev/null
+++ b/app/source/Cplt/Model/Items.cpp
@@ -0,0 +1,114 @@
+#include "Items.hpp"
+
+const std::string& ProductItem::GetDescription() const
+{
+ return mDescription;
+}
+
+void ProductItem::SetDescription(std::string description)
+{
+ mDescription = std::move(description);
+}
+
+int ProductItem::GetPrice() const
+{
+ return mPrice;
+}
+void ProductItem::SetPrice(int price)
+{
+ mPrice = price;
+}
+
+int ProductItem::GetStock() const
+{
+ return mStock;
+}
+
+void ProductItem::SetStock(int stock)
+{
+ mStock = stock;
+}
+
+Json::Value ProductItem::Serialize() const
+{
+ Json::Value elm;
+ elm["Description"] = mDescription;
+ elm["Price"] = mPrice;
+ elm["Stock"] = mStock;
+ return elm;
+}
+
+void ProductItem::Deserialize(const Json::Value& elm)
+{
+ mDescription = elm["Description"].asString();
+ mPrice = elm["Price"].asInt();
+ mStock = elm["Stock"].asInt();
+}
+
+const std::string& FactoryItem::GetDescription() const
+{
+ return mDescription;
+}
+
+void FactoryItem::SetDescription(std::string description)
+{
+ mDescription = std::move(description);
+}
+
+const std::string& FactoryItem::GetEmail() const
+{
+ return mEmail;
+}
+
+void FactoryItem::SetEmail(std::string email)
+{
+ mEmail = std::move(email);
+}
+
+Json::Value FactoryItem::Serialize() const
+{
+ Json::Value elm;
+ elm["Description"] = mDescription;
+ elm["Email"] = mEmail;
+ return elm;
+}
+
+void FactoryItem::Deserialize(const Json::Value& elm)
+{
+ mDescription = elm["Description"].asString();
+ mEmail = elm["Email"].asString();
+}
+
+const std::string& CustomerItem::GetDescription() const
+{
+ return mDescription;
+}
+
+void CustomerItem::SetDescription(std::string description)
+{
+ mDescription = std::move(description);
+}
+
+const std::string& CustomerItem::GetEmail() const
+{
+ return mEmail;
+}
+
+void CustomerItem::SetEmail(std::string email)
+{
+ mEmail = std::move(email);
+}
+
+Json::Value CustomerItem::Serialize() const
+{
+ Json::Value elm;
+ elm["Description"] = mDescription;
+ elm["Email"] = mEmail;
+ return elm;
+}
+
+void CustomerItem::Deserialize(const Json::Value& elm)
+{
+ mDescription = elm["Description"].asString();
+ mEmail = elm["Email"].asString();
+}
diff --git a/app/source/Cplt/Model/Items.hpp b/app/source/Cplt/Model/Items.hpp
new file mode 100644
index 0000000..c00ee59
--- /dev/null
+++ b/app/source/Cplt/Model/Items.hpp
@@ -0,0 +1,253 @@
+#pragma once
+
+#include <Cplt/fwd.hpp>
+
+#include <json/reader.h>
+#include <json/value.h>
+#include <json/writer.h>
+#include <tsl/array_map.h>
+#include <cstddef>
+#include <limits>
+#include <stdexcept>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+template <class T>
+class ItemList
+{
+private:
+ std::vector<T> mStorage;
+ tsl::array_map<char, size_t> mNameLookup;
+
+public:
+ template <class... Args>
+ T& Insert(std::string name, Args... args)
+ {
+ auto iter = mNameLookup.find(name);
+ if (iter != mNameLookup.end()) {
+ throw std::runtime_error("Duplicate key.");
+ }
+
+ for (size_t i = 0; i < mStorage.size(); ++i) {
+ if (mStorage[i].IsInvalid()) {
+ mStorage[i] = T(*this, i, std::move(name), std::forward<Args>(args)...);
+ mNameLookup.insert(name, i);
+ return mStorage[i];
+ }
+ }
+
+ size_t id = mStorage.size();
+ mNameLookup.insert(name, id);
+ mStorage.emplace_back(*this, id, std::move(name), std::forward<Args>(args)...);
+ return mStorage[id];
+ }
+
+ void Remove(size_t index)
+ {
+ auto& item = mStorage[index];
+ mNameLookup.erase(item.GetName());
+ mStorage[index] = T(*this);
+ }
+
+ T* Find(size_t id)
+ {
+ return &mStorage[id];
+ }
+
+ const T* Find(size_t id) const
+ {
+ return &mStorage[id];
+ }
+
+ const T* Find(std::string_view name) const
+ {
+ auto iter = mNameLookup.find(name);
+ if (iter != mNameLookup.end()) {
+ return &mStorage[iter.value()];
+ } else {
+ return nullptr;
+ }
+ }
+
+ Json::Value Serialize() const
+ {
+ Json::Value items(Json::arrayValue);
+ for (auto& item : mStorage) {
+ if (!item.IsInvalid()) {
+ auto elm = item.Serialize();
+ elm["Id"] = item.GetId();
+ elm["Name"] = item.GetName();
+ items.append(elm);
+ }
+ }
+
+ Json::Value root;
+ root["MaxItemId"] = mStorage.size();
+ root["Items"] = std::move(items);
+
+ return root;
+ }
+
+ ItemList() = default;
+
+ ItemList(const Json::Value& root)
+ {
+ constexpr const char* kMessage = "Failed to load item list from JSON.";
+
+ auto& itemCount = root["MaxItemId"];
+ if (!itemCount.isIntegral()) throw std::runtime_error(kMessage);
+
+ mStorage.resize(itemCount.asInt64(), T(*this));
+
+ auto& items = root["Items"];
+ if (!items.isArray()) throw std::runtime_error(kMessage);
+
+ for (auto& elm : items) {
+ if (!elm.isObject()) throw std::runtime_error(kMessage);
+
+ auto& id = elm["Id"];
+ if (!id.isIntegral()) throw std::runtime_error(kMessage);
+ auto& name = elm["Name"];
+ if (!name.isString()) throw std::runtime_error(kMessage);
+
+ size_t iid = id.asInt64();
+ mStorage[iid] = T(*this, iid, name.asString());
+ mStorage[iid].Deserialize(elm);
+ }
+ }
+
+ typename decltype(mStorage)::iterator begin()
+ {
+ return mStorage.begin();
+ }
+
+ typename decltype(mStorage)::iterator end()
+ {
+ return mStorage.end();
+ }
+
+ typename decltype(mStorage)::const_iterator begin() const
+ {
+ return mStorage.begin();
+ }
+
+ typename decltype(mStorage)::const_iterator end() const
+ {
+ return mStorage.end();
+ }
+
+private:
+ template <class TSelf>
+ friend class ItemBase;
+
+ void UpdateItemName(const T& item, const std::string& newName)
+ {
+ mNameLookup.erase(item.GetName());
+ mNameLookup.insert(newName, item.GetId());
+ }
+};
+
+template <class TSelf>
+class ItemBase
+{
+private:
+ ItemList<TSelf>* mList;
+ size_t mId;
+ std::string mName;
+
+public:
+ ItemBase(ItemList<TSelf>& list, size_t id = std::numeric_limits<size_t>::max(), std::string name = "")
+ : mList{ &list }
+ , mId{ id }
+ , mName{ std::move(name) }
+ {
+ }
+
+ bool IsInvalid() const
+ {
+ return mId == std::numeric_limits<size_t>::max();
+ }
+
+ ItemList<TSelf>& GetList() const
+ {
+ return *mList;
+ }
+
+ size_t GetId() const
+ {
+ return mId;
+ }
+
+ const std::string& GetName() const
+ {
+ return mName;
+ }
+
+ void SetName(std::string name)
+ {
+ mList->UpdateItemName(static_cast<TSelf&>(*this), name);
+ mName = std::move(name);
+ }
+};
+
+class ProductItem : public ItemBase<ProductItem>
+{
+private:
+ std::string mDescription;
+ int mPrice = 0;
+ int mStock = 0;
+
+public:
+ using ItemBase::ItemBase;
+
+ const std::string& GetDescription() const;
+ void SetDescription(std::string description);
+ /// Get the price of this item in US cents.
+ int GetPrice() const;
+ void SetPrice(int price);
+ /// Get the current number of this product in warehouse.
+ /// This is a housekeeping field and shouldn't be editable by the user from the UI.
+ int GetStock() const;
+ void SetStock(int stock);
+
+ Json::Value Serialize() const;
+ void Deserialize(const Json::Value& elm);
+};
+
+class FactoryItem : public ItemBase<FactoryItem>
+{
+private:
+ std::string mDescription;
+ std::string mEmail;
+
+public:
+ using ItemBase::ItemBase;
+
+ const std::string& GetDescription() const;
+ void SetDescription(std::string description);
+ const std::string& GetEmail() const;
+ void SetEmail(std::string email);
+
+ Json::Value Serialize() const;
+ void Deserialize(const Json::Value& elm);
+};
+
+class CustomerItem : public ItemBase<CustomerItem>
+{
+private:
+ std::string mDescription;
+ std::string mEmail;
+
+public:
+ using ItemBase::ItemBase;
+
+ const std::string& GetDescription() const;
+ void SetDescription(std::string description);
+ const std::string& GetEmail() const;
+ void SetEmail(std::string email);
+
+ Json::Value Serialize() const;
+ void Deserialize(const Json::Value& elm);
+};
diff --git a/app/source/Cplt/Model/Project.cpp b/app/source/Cplt/Model/Project.cpp
new file mode 100644
index 0000000..a1e9bab
--- /dev/null
+++ b/app/source/Cplt/Model/Project.cpp
@@ -0,0 +1,168 @@
+#include "Project.hpp"
+
+#include <Cplt/Model/Workflow/Workflow.hpp>
+#include <Cplt/Utils/Macros.hpp>
+
+#include <json/reader.h>
+#include <json/value.h>
+#include <json/writer.h>
+#include <filesystem>
+#include <fstream>
+#include <stdexcept>
+#include <utility>
+
+namespace fs = std::filesystem;
+
+template <class T>
+static void ReadItemList(ItemList<T>& list, const fs::path& filePath)
+{
+ std::ifstream ifs(filePath);
+ if (ifs) {
+ Json::Value root;
+ ifs >> root;
+
+ list = ItemList<T>(root);
+ }
+}
+
+static void CreateProjectSubfolders(const Project& project)
+{
+ fs::create_directory(project.GetDatabasesDirectory());
+ fs::create_directory(project.GetItemsDirectory());
+ fs::create_directory(project.GetWorkflowsDirectory());
+ fs::create_directory(project.GetTemplatesDirectory());
+}
+
+Project::Project(fs::path rootPath)
+ : mRootPath{ std::move(rootPath) }
+ , mRootPathString{ mRootPath.string() }
+ , Workflows(*this)
+ , Templates(*this)
+ , Database(*this)
+{
+ // TODO better diagnostic
+ const char* kInvalidFormatErr = "Failed to load project: invalid format.";
+
+ std::ifstream ifs(mRootPath / "cplt_project.json");
+ if (!ifs) {
+ std::string message;
+ message += "Failed to load project file at '";
+ message += mRootPath.string();
+ message += "'.";
+ throw std::runtime_error(message);
+ }
+
+ {
+ Json::Value root;
+ ifs >> root;
+
+ const auto& croot = root; // Use const reference so that accessors default to returning a null if not found, instead of silently creating new elements
+ if (!croot.isObject()) {
+ throw std::runtime_error(kInvalidFormatErr);
+ }
+
+ if (auto& name = croot["Name"]; name.isString()) {
+ mName = name.asString();
+ } else {
+ throw std::runtime_error(kInvalidFormatErr);
+ }
+ }
+
+ CreateProjectSubfolders(*this);
+
+ auto itemsDir = mRootPath / "items";
+ ReadItemList(Products, itemsDir / "products.json");
+ ReadItemList(Factories, itemsDir / "factories.json");
+ ReadItemList(Customers, itemsDir / "customers.json");
+
+ Workflows.Reload();
+ Templates.Reload();
+}
+
+Project::Project(fs::path rootPath, std::string name)
+ : mRootPath{ std::move(rootPath) }
+ , mRootPathString{ mRootPath.string() }
+ , mName{ std::move(name) }
+ , Workflows(*this)
+ , Templates(*this)
+ , Database(*this)
+{
+ CreateProjectSubfolders(*this);
+}
+
+const fs::path& Project::GetPath() const
+{
+ return mRootPath;
+}
+
+const std::string& Project::GetPathString() const
+{
+ return mRootPathString;
+}
+
+fs::path Project::GetDatabasesDirectory() const
+{
+ return mRootPath / "databases";
+}
+
+fs::path Project::GetItemsDirectory() const
+{
+ return mRootPath / "items";
+}
+
+fs::path Project::GetWorkflowsDirectory() const
+{
+ return mRootPath / "workflows";
+}
+
+fs::path Project::GetWorkflowPath(std::string_view name) const
+{
+ return (mRootPath / "workflows" / name).concat(".cplt-workflow");
+}
+
+fs::path Project::GetTemplatesDirectory() const
+{
+ return mRootPath / "templates";
+}
+
+fs::path Project::GetTemplatePath(std::string_view name) const
+{
+ return (mRootPath / "templates" / name).concat(".cplt-template");
+}
+
+const std::string& Project::GetName() const
+{
+ return mName;
+}
+
+void Project::SetName(std::string name)
+{
+ mName = std::move(name);
+}
+
+Json::Value Project::Serialize()
+{
+ Json::Value root(Json::objectValue);
+
+ root["Name"] = mName;
+
+ return root;
+}
+
+template <class T>
+static void WriteItemList(ItemList<T>& list, const fs::path& filePath)
+{
+ std::ofstream ofs(filePath);
+ ofs << list.Serialize();
+}
+
+void Project::WriteToDisk()
+{
+ std::ofstream ofs(mRootPath / "cplt_project.json");
+ ofs << this->Serialize();
+
+ auto itemsDir = GetItemsDirectory();
+ WriteItemList(Products, itemsDir / "products.json");
+ WriteItemList(Factories, itemsDir / "factories.json");
+ WriteItemList(Customers, itemsDir / "customers.json");
+}
diff --git a/app/source/Cplt/Model/Project.hpp b/app/source/Cplt/Model/Project.hpp
new file mode 100644
index 0000000..8119a97
--- /dev/null
+++ b/app/source/Cplt/Model/Project.hpp
@@ -0,0 +1,57 @@
+#pragma once
+
+#include <Cplt/Model/Assets.hpp>
+#include <Cplt/Model/Database.hpp>
+#include <Cplt/Model/Items.hpp>
+#include <Cplt/Model/Template/Template.hpp>
+#include <Cplt/Model/Workflow/Workflow.hpp>
+
+#include <json/forwards.h>
+#include <tsl/array_map.h>
+#include <filesystem>
+#include <string>
+#include <string_view>
+
+class Project
+{
+private:
+ std::filesystem::path mRootPath;
+ std::string mRootPathString;
+ std::string mName;
+
+ // (Exception to style guidelines)
+ // This is put after the private fields, so that when XxxDatabase's constructor runs, all of them will be initialized
+public:
+ WorkflowAssetList Workflows;
+ TemplateAssetList Templates;
+ ItemList<ProductItem> Products;
+ ItemList<FactoryItem> Factories;
+ ItemList<CustomerItem> Customers;
+ MainDatabase Database;
+
+public:
+ /// Load the project from a directory containing the cplt_project.json file.
+ /// This only loads the main project file, the caller needs to
+ Project(std::filesystem::path rootPath);
+
+ /// Create a project with the given name in the given path. Note that the path should be a directory that will contain the project files once created.
+ /// This function assumes the given directory will exist and is empty.
+ Project(std::filesystem::path rootPath, std::string name);
+
+ /// Path to a *directory* that contains the project file.
+ const std::filesystem::path& GetPath() const;
+ const std::string& GetPathString() const;
+
+ std::filesystem::path GetDatabasesDirectory() const;
+ std::filesystem::path GetItemsDirectory() const;
+ std::filesystem::path GetWorkflowsDirectory() const;
+ std::filesystem::path GetWorkflowPath(std::string_view name) const;
+ std::filesystem::path GetTemplatesDirectory() const;
+ std::filesystem::path GetTemplatePath(std::string_view name) const;
+
+ const std::string& GetName() const;
+ void SetName(std::string name);
+
+ Json::Value Serialize();
+ void WriteToDisk();
+};
diff --git a/app/source/Cplt/Model/Template/TableTemplate.cpp b/app/source/Cplt/Model/Template/TableTemplate.cpp
new file mode 100644
index 0000000..5cd9ed8
--- /dev/null
+++ b/app/source/Cplt/Model/Template/TableTemplate.cpp
@@ -0,0 +1,591 @@
+#include "TableTemplate.hpp"
+
+#include <Cplt/Utils/IO/StringIntegration.hpp>
+#include <Cplt/Utils/IO/TslArrayIntegration.hpp>
+#include <Cplt/Utils/IO/VectorIntegration.hpp>
+
+#include <xlsxwriter.h>
+#include <algorithm>
+#include <charconv>
+#include <cstddef>
+#include <cstdint>
+#include <iostream>
+#include <map>
+
+bool TableCell::IsDataHoldingCell() const
+{
+ return IsPrimaryCell() || !IsMergedCell();
+}
+
+bool TableCell::IsPrimaryCell() const
+{
+ return PrimaryCellLocation == Location;
+}
+
+bool TableCell::IsMergedCell() const
+{
+ return PrimaryCellLocation.x == -1 || PrimaryCellLocation.y == -1;
+}
+
+template <class TTableCell, class TStream>
+void OperateStreamForTableCell(TTableCell& cell, TStream& proxy)
+{
+ proxy.template ObjectAdapted<DataStreamAdapters::String>(cell.Content);
+ proxy.Object(cell.Location);
+ proxy.Object(cell.PrimaryCellLocation);
+ proxy.Value(cell.SpanX);
+ proxy.Value(cell.SpanY);
+ proxy.Enum(cell.HorizontalAlignment);
+ proxy.Enum(cell.VerticalAlignment);
+ proxy.Enum(cell.Type);
+ proxy.Value(cell.DataId);
+}
+
+void TableCell::ReadFromDataStream(InputDataStream& stream)
+{
+ ::OperateStreamForTableCell(*this, stream);
+}
+
+void TableCell::WriteToDataStream(OutputDataStream& stream) const
+{
+ ::OperateStreamForTableCell(*this, stream);
+}
+
+Vec2i TableArrayGroup::GetLeftCell() const
+{
+ return { Row, LeftCell };
+}
+
+Vec2i TableArrayGroup::GetRightCell() const
+{
+ return { Row, RightCell };
+}
+
+int TableArrayGroup::GetCount() const
+{
+ return RightCell - LeftCell + 1;
+}
+
+Vec2i TableArrayGroup::FindCell(std::string_view name)
+{
+ // TODO
+ return Vec2i{};
+}
+
+template <class TMap>
+static bool UpdateElementName(TMap& map, std::string_view oldName, std::string_view newName)
+{
+ auto iter = map.find(oldName);
+ if (iter == map.end()) {
+ return false;
+ }
+
+ auto elm = iter.value();
+ auto [DISCARD, inserted] = map.insert(newName, elm);
+ if (!inserted) {
+ return false;
+ }
+
+ map.erase(iter);
+ return true;
+}
+
+bool TableArrayGroup::UpdateCellName(std::string_view oldName, std::string_view newName)
+{
+ return ::UpdateElementName(mName2Cell, oldName, newName);
+}
+
+template <class TTableArrayGroup, class TStream>
+void OperateStreamForTableArrayGroup(TTableArrayGroup& group, TStream& stream)
+{
+ stream.Value(group.Row);
+ stream.Value(group.LeftCell);
+ stream.Value(group.RightCell);
+}
+
+void TableArrayGroup::ReadFromDataStream(InputDataStream& stream)
+{
+ ::OperateStreamForTableArrayGroup(*this, stream);
+}
+
+void TableArrayGroup::WriteToDataStream(OutputDataStream& stream) const
+{
+ ::OperateStreamForTableArrayGroup(*this, stream);
+}
+
+TableInstantiationParameters::TableInstantiationParameters(const TableTemplate& table)
+ : mTable{ &table }
+{
+}
+
+TableInstantiationParameters& TableInstantiationParameters::ResetTable(const TableTemplate& newTable)
+{
+ mTable = &newTable;
+ return *this;
+}
+
+TableInstantiationParameters TableInstantiationParameters::RebindTable(const TableTemplate& newTable) const
+{
+ TableInstantiationParameters result(newTable);
+ result.SingularCells = this->SingularCells;
+ result.ArrayGroups = this->ArrayGroups;
+ return result;
+}
+
+const TableTemplate& TableInstantiationParameters::GetTable() const
+{
+ return *mTable;
+}
+
+bool TableTemplate::IsInstance(const Template* tmpl)
+{
+ return tmpl->GetKind() == KD_Table;
+}
+
+TableTemplate::TableTemplate()
+ : Template(KD_Table)
+{
+}
+
+int TableTemplate::GetTableWidth() const
+{
+ return mColumnWidths.size();
+}
+
+int TableTemplate::GetTableHeight() const
+{
+ return mRowHeights.size();
+}
+
+void TableTemplate::Resize(int newWidth, int newHeight)
+{
+ // TODO this doesn't gracefully handle resizing to a smaller size which trims some merged cells
+
+ std::vector<TableCell> cells;
+ cells.reserve(newWidth * newHeight);
+
+ int tableWidth = GetTableWidth();
+ int tableHeight = GetTableHeight();
+
+ for (int y = 0; y < newHeight; ++y) {
+ if (y >= tableHeight) {
+ for (int x = 0; x < newWidth; ++x) {
+ cells.push_back(TableCell{});
+ }
+ continue;
+ }
+
+ for (int x = 0; x < newWidth; ++x) {
+ if (x >= tableWidth) {
+ cells.push_back(TableCell{});
+ } else {
+ auto& cell = GetCell({ x, y });
+ cells.push_back(std::move(cell));
+ }
+ }
+ }
+
+ mCells = std::move(cells);
+ mColumnWidths.resize(newWidth, 80);
+ mRowHeights.resize(newHeight, 20);
+}
+
+int TableTemplate::GetRowHeight(int row) const
+{
+ return mRowHeights[row];
+}
+
+void TableTemplate::SetRowHeight(int row, int height)
+{
+ mRowHeights[row] = height;
+}
+
+int TableTemplate::GetColumnWidth(int column) const
+{
+ return mColumnWidths[column];
+}
+
+void TableTemplate::SetColumnWidth(int column, int width)
+{
+ mColumnWidths[column] = width;
+}
+
+const TableCell& TableTemplate::GetCell(Vec2i pos) const
+{
+ int tableWidth = GetTableWidth();
+ return mCells[pos.y * tableWidth + pos.x];
+}
+
+TableCell& TableTemplate::GetCell(Vec2i pos)
+{
+ return const_cast<TableCell&>(const_cast<const TableTemplate*>(this)->GetCell(pos));
+}
+
+void TableTemplate::SetCellType(Vec2i pos, TableCell::CellType type)
+{
+ auto& cell = GetCell(pos);
+ if (cell.Type == type) {
+ return;
+ }
+
+ switch (cell.Type) {
+ // Nothing to change
+ case TableCell::ConstantCell: break;
+
+ case TableCell::SingularParametricCell:
+ mName2Parameters.erase(cell.Content);
+ break;
+
+ case TableCell::ArrayParametricCell: {
+ auto& ag = mArrayGroups[cell.DataId];
+ if (pos.x == ag.LeftCell) {
+ ag.LeftCell++;
+ } else if (pos.x == ag.RightCell) {
+ ag.RightCell--;
+ } else {
+ }
+ } break;
+ }
+
+ switch (type) {
+ // Nothing to do
+ case TableCell::ConstantCell: break;
+
+ case TableCell::SingularParametricCell: {
+ int idx = pos.y * GetTableWidth() + pos.x;
+ auto [DISCARD, inserted] = mName2Parameters.insert(cell.Content, idx);
+
+ // Duplicate name
+ if (!inserted) {
+ return;
+ }
+ } break;
+
+ case TableCell::ArrayParametricCell: {
+ auto ptr = AddArrayGroup(pos.y, pos.x, pos.x);
+
+ // Duplicate name
+ if (ptr == nullptr) {
+ return;
+ }
+ } break;
+ }
+
+ cell.Type = type;
+}
+
+bool TableTemplate::UpdateParameterName(std::string_view oldName, std::string_view newName)
+{
+ return ::UpdateElementName(mName2Parameters, oldName, newName);
+}
+
+int TableTemplate::GetArrayGroupCount() const
+{
+ return mArrayGroups.size();
+}
+
+const TableArrayGroup& TableTemplate::GetArrayGroup(int id) const
+{
+ return mArrayGroups[id];
+}
+
+TableArrayGroup& TableTemplate::GetArrayGroup(int id)
+{
+ return mArrayGroups[id];
+}
+
+TableArrayGroup* TableTemplate::AddArrayGroup(int row, int left, int right)
+{
+ // size_t max value: 18446744073709551615
+ // ^~~~~~~~~~~~~~~~~~~~ 20 chars
+ char name[20];
+ auto res = std::to_chars(std::begin(name), std::end(name), mArrayGroups.size());
+ std::string_view nameStr(name, res.ptr - name);
+
+ return AddArrayGroup(nameStr, row, left, right);
+}
+
+TableArrayGroup* TableTemplate::AddArrayGroup(std::string_view name, int row, int left, int right)
+{
+ assert(row >= 0 && row < GetTableHeight());
+ assert(left >= 0 && left < GetTableWidth());
+ assert(right >= 0 && right < GetTableWidth());
+
+ // TODO check for overlap
+
+ if (left > right) {
+ std::swap(left, right);
+ }
+
+ auto [DISCARD, inserted] = mName2ArrayGroups.insert(name, (int)mArrayGroups.size());
+ if (!inserted) {
+ return nullptr;
+ }
+
+ mArrayGroups.push_back(TableArrayGroup{
+ .Row = row,
+ .LeftCell = left,
+ .RightCell = right,
+ });
+ auto& ag = mArrayGroups.back();
+
+ for (int x = left; x <= right; x++) {
+ auto& cell = GetCell({ x, row });
+
+ // Update type
+ cell.Type = TableCell::ArrayParametricCell;
+
+ // Insert parameter name lookup
+ while (true) {
+ auto [DISCARD, inserted] = ag.mName2Cell.insert(cell.Content, x);
+ if (inserted) {
+ break;
+ }
+
+ cell.Content += "-";
+ }
+ }
+
+ return &ag;
+}
+
+bool TableTemplate::UpdateArrayGroupName(std::string_view oldName, std::string_view newName)
+{
+ return ::UpdateElementName(mName2ArrayGroups, oldName, newName);
+}
+
+bool TableTemplate::ExtendArrayGroupLeft(int id, int n)
+{
+ assert(n > 0);
+
+ auto& ag = mArrayGroups[id];
+ ag.LeftCell -= n;
+
+ return false;
+}
+
+bool TableTemplate::ExtendArrayGroupRight(int id, int n)
+{
+ assert(n > 0);
+
+ auto& ag = mArrayGroups[id];
+ ag.RightCell += n;
+
+ return false;
+}
+
+TableCell* TableTemplate::FindCell(std::string_view name)
+{
+ auto iter = mName2Parameters.find(name);
+ if (iter != mName2Parameters.end()) {
+ return &mCells[iter.value()];
+ } else {
+ return nullptr;
+ }
+}
+
+TableArrayGroup* TableTemplate::FindArrayGroup(std::string_view name)
+{
+ auto iter = mName2ArrayGroups.find(name);
+ if (iter != mName2ArrayGroups.end()) {
+ return &mArrayGroups[iter.value()];
+ } else {
+ return nullptr;
+ }
+}
+
+TableTemplate::MergeCellsResult TableTemplate::MergeCells(Vec2i topLeft, Vec2i bottomRight)
+{
+ auto SortTwo = [](int& a, int& b) {
+ if (a > b) {
+ std::swap(a, b);
+ }
+ };
+ SortTwo(topLeft.x, bottomRight.x);
+ SortTwo(topLeft.y, bottomRight.y);
+
+ auto ResetProgress = [&]() {
+ for (int y = topLeft.y; y < bottomRight.y; ++y) {
+ for (int x = topLeft.x; x < bottomRight.x; ++x) {
+ auto& cell = GetCell({ x, y });
+ cell.PrimaryCellLocation = { -1, -1 };
+ }
+ }
+ };
+
+ for (int y = topLeft.y; y < bottomRight.y; ++y) {
+ for (int x = topLeft.x; x < bottomRight.x; ++x) {
+ auto& cell = GetCell({ x, y });
+ if (cell.IsMergedCell()) {
+ ResetProgress();
+ return MCR_CellAlreadyMerged;
+ }
+
+ cell.PrimaryCellLocation = topLeft;
+ }
+ }
+
+ auto& primaryCell = GetCell(topLeft);
+ primaryCell.SpanX = bottomRight.x - topLeft.x;
+ primaryCell.SpanY = bottomRight.y - topLeft.y;
+
+ return MCR_Success;
+}
+
+TableTemplate::BreakCellsResult TableTemplate::BreakCells(Vec2i topLeft)
+{
+ auto& primaryCell = GetCell(topLeft);
+ if (!primaryCell.IsMergedCell()) {
+ return BCR_CellNotMerged;
+ }
+
+ for (int dy = 0; dy < primaryCell.SpanY; ++dy) {
+ for (int dx = 0; dx < primaryCell.SpanX; ++dx) {
+ auto& cell = GetCell({ topLeft.x + dx, topLeft.y + dy });
+ cell.PrimaryCellLocation = { -1, -1 };
+ }
+ }
+
+ primaryCell.SpanX = 1;
+ primaryCell.SpanY = 1;
+
+ return BCR_Success;
+}
+
+lxw_workbook* TableTemplate::InstantiateToExcelWorkbook(const TableInstantiationParameters& params) const
+{
+ auto workbook = workbook_new("Table.xlsx");
+ InstantiateToExcelWorksheet(workbook, params);
+ return workbook;
+}
+
+lxw_worksheet* TableTemplate::InstantiateToExcelWorksheet(lxw_workbook* workbook, const TableInstantiationParameters& params) const
+{
+ auto worksheet = workbook_add_worksheet(workbook, "CpltExport.xlsx");
+
+ // Map: row number -> length of generated ranges
+ std::map<int, int> generatedRanges;
+
+ for (size_t i = 0; i < mArrayGroups.size(); ++i) {
+ auto& info = mArrayGroups[i];
+ auto& param = params.ArrayGroups[i];
+
+ auto iter = generatedRanges.find(i);
+ if (iter != generatedRanges.end()) {
+ int available = iter->second;
+ if (available >= param.size()) {
+ // Current space is enough to fit in this array group, skip
+ continue;
+ }
+ }
+
+ // Not enough space to fit in this array group, update (or insert) the appropriate amount of generated rows
+ int row = i;
+ int count = param.size();
+ generatedRanges.try_emplace(row, count);
+ }
+
+ auto GetOffset = [&](int y) -> int {
+ // std::find_if <values less than y>
+ int verticalOffset = 0;
+ for (auto it = generatedRanges.begin(); it != generatedRanges.end() && it->first < y; ++it) {
+ verticalOffset += it->second;
+ }
+ return verticalOffset;
+ };
+
+ auto WriteCell = [&](int row, int col, const TableCell& cell, const char* text) -> void {
+ if (cell.IsPrimaryCell()) {
+ int lastRow = row + cell.SpanY - 1;
+ int lastCol = col + cell.SpanX - 1;
+ // When both `string` and `format` are null, the top-left cell contents are untouched (what we just wrote in the above switch)
+ worksheet_merge_range(worksheet, row, col, lastRow, lastCol, text, nullptr);
+ } else {
+ worksheet_write_string(worksheet, row, col, text, nullptr);
+ }
+ };
+
+ // Write/instantiate all array groups
+ for (size_t i = 0; i < mArrayGroups.size(); ++i) {
+ auto& groupInfo = mArrayGroups[i];
+ auto& groupParams = params.ArrayGroups[i];
+
+ int rowCellCount = groupInfo.GetCount();
+ int rowCount = groupParams.size();
+ int baseRowIdx = groupInfo.Row + GetOffset(groupInfo.Row);
+
+ // For each row that would be generated
+ for (int rowIdx = 0; rowIdx < rowCount; ++rowIdx) {
+ auto& row = groupParams[rowIdx];
+
+ // For each cell in the row
+ for (int rowCellIdx = 0; rowCellIdx < rowCellCount; ++rowCellIdx) {
+ // TODO support merged cells in array groups
+ worksheet_write_string(worksheet, baseRowIdx + rowIdx, rowCellIdx, row[rowCellIdx].c_str(), nullptr);
+ }
+ }
+ }
+
+ int tableWidth = GetTableWidth();
+ int tableHeight = GetTableHeight();
+
+ // Write all regular and singular parameter cells
+ for (int y = 0; y < tableHeight; ++y) {
+ for (int x = 0; x < tableWidth; ++x) {
+ auto& cell = GetCell({ x, y });
+
+ if (!cell.IsDataHoldingCell()) {
+ continue;
+ }
+
+ switch (cell.Type) {
+ case TableCell::ConstantCell: {
+ int row = y + GetOffset(y);
+ int col = x;
+
+ WriteCell(row, col, cell, cell.Content.c_str());
+ } break;
+
+ case TableCell::SingularParametricCell: {
+ int row = y + GetOffset(y);
+ int col = x;
+
+ auto iter = params.SingularCells.find({ x, y });
+ if (iter != params.SingularCells.end()) {
+ WriteCell(row, col, cell, iter.value().c_str());
+ }
+ } break;
+
+ // See loop above that processes whole array groups at the same time
+ case TableCell::ArrayParametricCell: break;
+ }
+ }
+ }
+
+ return worksheet;
+}
+
+class TableTemplate::Private
+{
+public:
+ template <class TTableTemplate, class TProxy>
+ static void OperateStream(TTableTemplate& table, TProxy& proxy)
+ {
+ proxy.template ObjectAdapted<DataStreamAdapters::Vector<>>(table.mColumnWidths);
+ proxy.template ObjectAdapted<DataStreamAdapters::Vector<>>(table.mRowHeights);
+ proxy.template ObjectAdapted<DataStreamAdapters::Vector<>>(table.mCells);
+ proxy.template ObjectAdapted<DataStreamAdapters::Vector<>>(table.mArrayGroups);
+ proxy.template ObjectAdapted<DataStreamAdapters::TslArrayMap<>>(table.mName2Parameters);
+ proxy.template ObjectAdapted<DataStreamAdapters::TslArrayMap<>>(table.mName2ArrayGroups);
+ }
+};
+
+void TableTemplate::ReadFromDataStream(InputDataStream& stream)
+{
+ Private::OperateStream(*this, stream);
+}
+
+void TableTemplate::WriteToDataStream(OutputDataStream& stream) const
+{
+ Private::OperateStream(*this, stream);
+}
diff --git a/app/source/Cplt/Model/Template/TableTemplate.hpp b/app/source/Cplt/Model/Template/TableTemplate.hpp
new file mode 100644
index 0000000..3e931d4
--- /dev/null
+++ b/app/source/Cplt/Model/Template/TableTemplate.hpp
@@ -0,0 +1,223 @@
+#pragma once
+
+#include <Cplt/Model/Template/Template.hpp>
+#include <Cplt/Utils/Vector.hpp>
+#include <Cplt/Utils/VectorHash.hpp>
+#include <Cplt/fwd.hpp>
+
+#include <tsl/array_map.h>
+#include <tsl/robin_map.h>
+#include <string>
+#include <string_view>
+#include <vector>
+
+class TableCell
+{
+public:
+ enum TextAlignment
+ {
+ /// For horizontal alignment, this means align left. For vertical alignment, this means align top.
+ AlignAxisMin,
+ /// Align middle of the text to the middle of the axis.
+ AlignCenter,
+ /// For horizontal alignment, this means align right. For vertical alignment, this means align bottom.
+ AlignAxisMax,
+ };
+
+ enum CellType
+ {
+ ConstantCell,
+ SingularParametricCell,
+ ArrayParametricCell,
+ };
+
+public:
+ /// Display content of this cell. This doesn't necessarily have to line up with the parameter name (if this cell is one).
+ std::string Content;
+ Vec2i Location;
+ /// Location of the primary (top left) cell, if this cell is a part of a merged group.
+ /// Otherwise, either component of this field shall be -1.
+ Vec2i PrimaryCellLocation{ -1, -1 };
+ int SpanX = 0;
+ int SpanY = 0;
+ TextAlignment HorizontalAlignment = AlignCenter;
+ TextAlignment VerticalAlignment = AlignCenter;
+ CellType Type = ConstantCell;
+ /// The id of the group description object, if this cell isn't a constant or singular parameter cell. Otherwise, this value is -1.
+ int DataId = -1;
+
+public:
+ /// Return whether this cell holds meaningful data, i.e. true when this cell is either unmerged or the primary cell of a merged range.
+ bool IsDataHoldingCell() const;
+ /// Return whether this cell is the primary (i.e. top left) cell of a merged range or not.
+ bool IsPrimaryCell() const;
+ /// Return whether this cell is a part of a merged range or not. Includes the primary cell.
+ bool IsMergedCell() const;
+
+ void ReadFromDataStream(InputDataStream& stream);
+ void WriteToDataStream(OutputDataStream& stream) const;
+};
+
+// TODO support reverse (bottom to top) filling order
+// TODO support horizontal filling order
+
+/// Parameter group information for a grouped array of cells. When instantiated, an array of 0 or more
+/// elements shall be provided by the user, which will replace the group of templated cells with a list
+/// of rows, each instantiated with the n-th element in the provided array.
+/// \code
+/// [["foo", "bar", "foobar"],
+/// ["a", "b", c"],
+/// ["1", "2", "3"],
+/// ["x", "y", "z"]]
+/// // ... may be more
+/// \endcode
+/// This would create 4 rows of data in the place of the original parameter group.
+///
+/// If more than one array parameter groups are on the same row, they would share space between each other:
+/// \code
+/// | 2 elements was fed to it
+/// | | 1 element was fed to it
+/// V V
+/// {~~~~~~~~~~~~~~~~}{~~~~~~~~~~~~~~}
+/// +------+---------+---------------+
+/// | Foo | Example | Another group |
+/// +------+---------+---------------+
+/// | Cool | Example | |
+/// +------+---------+---------------+
+/// \endcode
+///
+/// \see TableCell
+/// \see TableInstantiationParameters
+/// \see TableTemplate
+class TableArrayGroup
+{
+public:
+ /// Parameter name mapped to cell location (index from LeftCell).
+ tsl::array_map<char, int> mName2Cell;
+ int Row;
+ /// Leftmost cell in this group
+ int LeftCell;
+ /// Rightmost cell in this group
+ int RightCell;
+
+public:
+ Vec2i GetLeftCell() const;
+ Vec2i GetRightCell() const;
+ int GetCount() const;
+
+ /// Find the location of the cell within this array group that has the given name.
+ Vec2i FindCell(std::string_view name);
+ bool UpdateCellName(std::string_view oldName, std::string_view newName);
+
+ void ReadFromDataStream(InputDataStream& stream);
+ void WriteToDataStream(OutputDataStream& stream) const;
+};
+
+// Forward declaration of libxlsxwriter structs
+struct lxw_workbook;
+struct lxw_worksheet;
+
+/// An object containing the necessary information to instantiate a table template.
+/// \see TableTemplate
+class TableInstantiationParameters
+{
+private:
+ const TableTemplate* mTable;
+
+public:
+ tsl::robin_map<Vec2i, std::string> SingularCells;
+
+ using ArrayGroupRow = std::vector<std::string>;
+ using ArrayGroupData = std::vector<ArrayGroupRow>;
+ std::vector<ArrayGroupData> ArrayGroups;
+
+public:
+ TableInstantiationParameters(const TableTemplate& table);
+
+ TableInstantiationParameters& ResetTable(const TableTemplate& newTable);
+ TableInstantiationParameters RebindTable(const TableTemplate& newTable) const;
+
+ const TableTemplate& GetTable() const;
+};
+
+/// A table template, where individual cells can be filled by workflows instantiating this template. Merged cells,
+/// parametric rows/columns, and grids are also supported.
+///
+/// This current supports exporting to xlsx files.
+class TableTemplate : public Template
+{
+ friend class TableSingleParamsIter;
+ friend class TableArrayGroupsIter;
+ class Private;
+
+private:
+ /// Map from parameter name to index of the parameter cell (stored in mCells).
+ tsl::array_map<char, int> mName2Parameters;
+ /// Map from array group name to the index of the array group (stored in mArrayGroups).
+ tsl::array_map<char, int> mName2ArrayGroups;
+ std::vector<TableCell> mCells;
+ std::vector<TableArrayGroup> mArrayGroups;
+ std::vector<int> mRowHeights;
+ std::vector<int> mColumnWidths;
+
+public:
+ static bool IsInstance(const Template* tmpl);
+ TableTemplate();
+
+ int GetTableWidth() const;
+ int GetTableHeight() const;
+ void Resize(int newWidth, int newHeight);
+
+ int GetRowHeight(int row) const;
+ void SetRowHeight(int row, int height);
+ int GetColumnWidth(int column) const;
+ void SetColumnWidth(int column, int width);
+
+ const TableCell& GetCell(Vec2i pos) const;
+ TableCell& GetCell(Vec2i pos);
+ /// <ul>
+ /// <li> In case of becoming a SingularParametricCell: the parameter name is filled with TableCell::Content.
+ /// <li> In case of becoming a ArrayGroupParametricCell: the array group name is automatically generated as the nth group it would be come.
+ /// i.e., if there aRe currently 3 groups, the newly created group would be named "4".
+ /// If this name collides with an existing group, hyphens \c - will be append to the name until no collision happens.
+ /// </ul>
+ void SetCellType(Vec2i pos, TableCell::CellType type);
+
+ /// Updates the parameter cell to a new name. Returns true on success and false on failure (param not found or name duplicates).
+ bool UpdateParameterName(std::string_view oldName, std::string_view newName);
+
+ int GetArrayGroupCount() const;
+ const TableArrayGroup& GetArrayGroup(int id) const;
+ TableArrayGroup& GetArrayGroup(int id);
+ TableArrayGroup* AddArrayGroup(int row, int left, int right);
+ TableArrayGroup* AddArrayGroup(std::string_view name, int row, int left, int right);
+ bool UpdateArrayGroupName(std::string_view oldName, std::string_view newName);
+ bool ExtendArrayGroupLeft(int id, int n);
+ bool ExtendArrayGroupRight(int id, int n);
+
+ /// Find a singular parameter cell by its name. This does not include cells within an array group.
+ TableCell* FindCell(std::string_view name);
+
+ /// Find an array group by its name.
+ TableArrayGroup* FindArrayGroup(std::string_view name);
+
+ enum MergeCellsResult
+ {
+ MCR_CellAlreadyMerged,
+ MCR_Success,
+ };
+ MergeCellsResult MergeCells(Vec2i topLeft, Vec2i bottomRight);
+
+ enum BreakCellsResult
+ {
+ BCR_CellNotMerged,
+ BCR_Success,
+ };
+ BreakCellsResult BreakCells(Vec2i topLeft);
+
+ lxw_workbook* InstantiateToExcelWorkbook(const TableInstantiationParameters& params) const;
+ lxw_worksheet* InstantiateToExcelWorksheet(lxw_workbook* workbook, const TableInstantiationParameters& params) const;
+
+ void ReadFromDataStream(InputDataStream& stream) override;
+ void WriteToDataStream(OutputDataStream& stream) const override;
+};
diff --git a/app/source/Cplt/Model/Template/TableTemplateIterator.cpp b/app/source/Cplt/Model/Template/TableTemplateIterator.cpp
new file mode 100644
index 0000000..19e30b9
--- /dev/null
+++ b/app/source/Cplt/Model/Template/TableTemplateIterator.cpp
@@ -0,0 +1,52 @@
+#include "TableTemplateIterator.hpp"
+
+TableSingleParamsIter::TableSingleParamsIter(TableTemplate& tmpl)
+ : mTemplate{ &tmpl }
+ , mIter{ tmpl.mName2Parameters.begin() }
+{
+}
+
+bool TableSingleParamsIter::HasNext() const
+{
+ return mIter != mTemplate->mName2Parameters.end();
+}
+
+TableCell& TableSingleParamsIter::Next()
+{
+ int id = mIter.value();
+ ++mIter;
+
+ return mTemplate->mCells[id];
+}
+
+TableArrayGroupsIter::TableArrayGroupsIter(TableTemplate& tmpl)
+ : mTemplate{ &tmpl }
+ , mIter{ tmpl.mName2ArrayGroups.begin() }
+{
+}
+
+bool TableArrayGroupsIter::HasNext() const
+{
+ return mIter != mTemplate->mName2ArrayGroups.end();
+}
+
+TableArrayGroup& TableArrayGroupsIter::Peek() const
+{
+ int id = mIter.value();
+ return mTemplate->mArrayGroups[id];
+}
+
+std::string_view TableArrayGroupsIter::PeekName() const
+{
+ return mIter.key_sv();
+}
+
+const char* TableArrayGroupsIter::PeekNameCStr() const
+{
+ return mIter.key();
+}
+
+void TableArrayGroupsIter::Next()
+{
+ ++mIter;
+}
diff --git a/app/source/Cplt/Model/Template/TableTemplateIterator.hpp b/app/source/Cplt/Model/Template/TableTemplateIterator.hpp
new file mode 100644
index 0000000..c4b5bf9
--- /dev/null
+++ b/app/source/Cplt/Model/Template/TableTemplateIterator.hpp
@@ -0,0 +1,35 @@
+#pragma once
+
+#include <Cplt/Model/Template/TableTemplate.hpp>
+#include <Cplt/Model/Template/Template.hpp>
+
+#include <string_view>
+
+class TableSingleParamsIter
+{
+private:
+ TableTemplate* mTemplate;
+ tsl::array_map<char, int>::iterator mIter;
+
+public:
+ TableSingleParamsIter(TableTemplate& tmpl);
+
+ bool HasNext() const;
+ TableCell& Next();
+};
+
+class TableArrayGroupsIter
+{
+private:
+ TableTemplate* mTemplate;
+ tsl::array_map<char, int>::iterator mIter;
+
+public:
+ TableArrayGroupsIter(TableTemplate& tmpl);
+
+ bool HasNext() const;
+ TableArrayGroup& Peek() const;
+ std::string_view PeekName() const;
+ const char* PeekNameCStr() const;
+ void Next();
+};
diff --git a/app/source/Cplt/Model/Template/Template.hpp b/app/source/Cplt/Model/Template/Template.hpp
new file mode 100644
index 0000000..cf926d0
--- /dev/null
+++ b/app/source/Cplt/Model/Template/Template.hpp
@@ -0,0 +1,68 @@
+#pragma once
+
+#include <Cplt/Model/Assets.hpp>
+#include <Cplt/fwd.hpp>
+
+#include <filesystem>
+#include <iosfwd>
+#include <memory>
+#include <string>
+
+class Template : public Asset
+{
+public:
+ enum Kind
+ {
+ KD_Table,
+
+ InvalidKind,
+ KindCount = InvalidKind,
+ };
+
+ using CategoryType = TemplateAssetList;
+
+private:
+ Kind mKind;
+
+public:
+ static const char* FormatKind(Kind kind);
+ static std::unique_ptr<Template> CreateByKind(Kind kind);
+
+ static bool IsInstance(const Template* tmpl);
+
+ Template(Kind kind);
+ ~Template() override = default;
+
+ Kind GetKind() const;
+
+ virtual void ReadFromDataStream(InputDataStream& stream) = 0;
+ virtual void WriteToDataStream(OutputDataStream& stream) const = 0;
+};
+
+class TemplateAssetList final : public AssetListTyped<Template>
+{
+private:
+ // AC = Asset Creator
+ std::string mACNewName;
+ NameSelectionError mACNewNameError = NameSelectionError::Empty;
+ Template::Kind mACNewKind = Template::InvalidKind;
+
+public:
+ // Inherit constructors
+ using AssetListTyped::AssetListTyped;
+
+protected:
+ void DiscoverFiles(const std::function<void(SavedAsset)>& callback) const override;
+
+ std::string RetrieveNameFromFile(const std::filesystem::path& file) const override;
+ uuids::uuid RetrieveUuidFromFile(const std::filesystem::path& file) const override;
+ std::filesystem::path RetrievePathFromAsset(const SavedAsset& asset) const override;
+
+ bool SaveInstance(const SavedAsset& assetInfo, const Asset* asset) const override;
+ Template* LoadInstance(const SavedAsset& assetInfo) const override;
+ Template* CreateInstance(const SavedAsset& assetInfo) const override;
+ bool RenameInstanceOnDisk(const SavedAsset& assetInfo, std::string_view oldName) const override;
+
+ void DisplayAssetCreator(ListState& state) override;
+ void DisplayDetailsTable(ListState& state) const override;
+};
diff --git a/app/source/Cplt/Model/Template/Template_Main.cpp b/app/source/Cplt/Model/Template/Template_Main.cpp
new file mode 100644
index 0000000..d658231
--- /dev/null
+++ b/app/source/Cplt/Model/Template/Template_Main.cpp
@@ -0,0 +1,214 @@
+#include "Template.hpp"
+
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/UI/UI.hpp>
+#include <Cplt/Utils/I18n.hpp>
+#include <Cplt/Utils/IO/Archive.hpp>
+#include <Cplt/Utils/UUID.hpp>
+
+#include <imgui.h>
+#include <imgui_stdlib.h>
+#include <algorithm>
+#include <cstdint>
+#include <fstream>
+
+using namespace std::literals::string_view_literals;
+namespace fs = std::filesystem;
+
+Template::Template(Kind kind)
+ : mKind{ kind }
+{
+}
+
+Template::Kind Template::GetKind() const
+{
+ return mKind;
+}
+
+void TemplateAssetList::DiscoverFiles(const std::function<void(SavedAsset)>& callback) const
+{
+ auto dir = GetConnectedProject().GetTemplatesDirectory();
+ DiscoverFilesByExtension(callback, dir, ".cplt-template"sv);
+}
+
+std::string TemplateAssetList::RetrieveNameFromFile(const fs::path& file) const
+{
+ auto res = DataArchive::LoadFile(file);
+ if (!res) return "";
+ auto& stream = res.value();
+
+ SavedAsset assetInfo;
+ stream.ReadObject(assetInfo);
+
+ return assetInfo.Name;
+}
+
+uuids::uuid TemplateAssetList::RetrieveUuidFromFile(const fs::path& file) const
+{
+ return uuids::uuid::from_string(file.stem().string());
+}
+
+fs::path TemplateAssetList::RetrievePathFromAsset(const SavedAsset& asset) const
+{
+ auto fileName = uuids::to_string(asset.Uuid);
+ return GetConnectedProject().GetTemplatePath(fileName);
+}
+
+bool TemplateAssetList::SaveInstance(const SavedAsset& assetInfo, const Asset* asset) const
+{
+ auto path = RetrievePathFromAsset(assetInfo);
+ auto res = DataArchive::SaveFile(path);
+ if (!res) return false;
+ auto& stream = res.value();
+
+ stream.WriteObject(assetInfo);
+ // This cast is fine: calls to this class will always be wrapped in TypedAssetList<T>, which will ensure `asset` points to some Template
+ if (auto tmpl = static_cast<const Template*>(asset)) { // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)
+ stream.WriteObject(*tmpl);
+ }
+
+ return true;
+}
+
+static std::unique_ptr<Template> LoadTemplateFromFile(const fs::path& path)
+{
+ auto res = DataArchive::LoadFile(path);
+ if (!res) return nullptr;
+ auto& stream = res.value();
+
+ SavedAsset assetInfo;
+ stream.ReadObject(assetInfo);
+
+ auto kind = static_cast<Template::Kind>(assetInfo.Payload);
+ auto tmpl = Template::CreateByKind(kind);
+ stream.ReadObject(*tmpl);
+
+ return tmpl;
+}
+
+Template* TemplateAssetList::LoadInstance(const SavedAsset& assetInfo) const
+{
+ return ::LoadTemplateFromFile(RetrievePathFromAsset(assetInfo)).release();
+}
+
+Template* TemplateAssetList::CreateInstance(const SavedAsset& assetInfo) const
+{
+ auto kind = static_cast<Template::Kind>(assetInfo.Payload);
+ return Template::CreateByKind(kind).release();
+}
+
+bool TemplateAssetList::RenameInstanceOnDisk(const SavedAsset& assetInfo, std::string_view oldName) const
+{
+ // Get asset path, which is only dependent on UUID
+ auto path = RetrievePathFromAsset(assetInfo);
+
+ auto tmpl = ::LoadTemplateFromFile(path);
+ if (!tmpl) return false;
+
+ // Rewrite the asset with the updated name (note the given assetInfo already has the update name)
+ SaveInstance(assetInfo, tmpl.get());
+
+ return true;
+}
+
+void TemplateAssetList::DisplayAssetCreator(ListState& state)
+{
+ auto ValidateNewName = [&]() -> void {
+ if (mACNewName.empty()) {
+ mACNewNameError = NameSelectionError::Empty;
+ return;
+ }
+
+ if (FindByName(mACNewName)) {
+ mACNewNameError = NameSelectionError::Duplicated;
+ return;
+ }
+
+ mACNewNameError = NameSelectionError::None;
+ };
+
+ auto ShowNewNameErrors = [&]() -> void {
+ switch (mACNewNameError) {
+ case NameSelectionError::None: break;
+ case NameSelectionError::Duplicated:
+ ImGui::ErrorMessage(I18N_TEXT("Duplicate name", L10N_DUPLICATE_NAME_ERROR));
+ break;
+ case NameSelectionError::Empty:
+ ImGui::ErrorMessage(I18N_TEXT("Name cannot be empty", L10N_EMPTY_NAME_ERROR));
+ break;
+ }
+ };
+
+ auto ShowNewKindErrors = [&]() -> void {
+ if (mACNewKind == Template::InvalidKind) {
+ ImGui::ErrorMessage(I18N_TEXT("Invalid template type", L10N_TEMPLATE_INVALID_TYPE_ERROR));
+ }
+ };
+
+ auto IsInputValid = [&]() -> bool {
+ return mACNewNameError == NameSelectionError::None &&
+ mACNewKind != Template::InvalidKind;
+ };
+
+ auto ResetState = [&]() -> void {
+ mACNewName.clear();
+ mACNewKind = Template::InvalidKind;
+ ValidateNewName();
+ };
+
+ if (ImGui::InputText(I18N_TEXT("Name", L10N_NAME), &mACNewName)) {
+ ValidateNewName();
+ }
+
+ if (ImGui::BeginCombo(I18N_TEXT("Type", L10N_TYPE), Template::FormatKind(mACNewKind))) {
+ for (int i = 0; i < Template::KindCount; ++i) {
+ auto kind = static_cast<Template::Kind>(i);
+ if (ImGui::Selectable(Template::FormatKind(kind), mACNewKind == kind)) {
+ mACNewKind = kind;
+ }
+ }
+ ImGui::EndCombo();
+ }
+
+ ShowNewNameErrors();
+ ShowNewKindErrors();
+
+ if (ImGui::Button(I18N_TEXT("OK", L10N_CONFIRM), !IsInputValid())) {
+ ImGui::CloseCurrentPopup();
+
+ Create(SavedAsset{
+ .Name = mACNewName,
+ .Payload = static_cast<uint64_t>(mACNewKind),
+ });
+ ResetState();
+ }
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ }
+}
+
+void TemplateAssetList::DisplayDetailsTable(ListState& state) const
+{
+ ImGui::BeginTable("AssetDetailsTable", 2, ImGuiTableFlags_Borders);
+
+ ImGui::TableSetupColumn(I18N_TEXT("Name", L10N_NAME));
+ ImGui::TableSetupColumn(I18N_TEXT("Type", L10N_TYPE));
+ ImGui::TableHeadersRow();
+
+ for (auto& asset : this->GetAssets()) {
+ ImGui::TableNextRow();
+
+ ImGui::TableNextColumn();
+ if (ImGui::Selectable(asset.Name.c_str(), state.SelectedAsset == &asset, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_DontClosePopups)) {
+ state.SelectedAsset = &asset;
+ }
+
+ ImGui::TableNextColumn();
+ auto kind = static_cast<Template::Kind>(asset.Payload);
+ ImGui::TextUnformatted(Template::FormatKind(kind));
+ }
+
+ ImGui::EndTable();
+}
diff --git a/app/source/Cplt/Model/Template/Template_RTTI.cpp b/app/source/Cplt/Model/Template/Template_RTTI.cpp
new file mode 100644
index 0000000..a96680b
--- /dev/null
+++ b/app/source/Cplt/Model/Template/Template_RTTI.cpp
@@ -0,0 +1,29 @@
+#include "Template.hpp"
+
+#include <Cplt/Model/Template/TableTemplate.hpp>
+#include <Cplt/Utils/I18n.hpp>
+
+const char* Template::FormatKind(Kind kind)
+{
+ switch (kind) {
+ case KD_Table: return I18N_TEXT("Table template", L10N_TEMPLATE_TABLE);
+
+ case InvalidKind: break;
+ }
+ return "";
+}
+
+std::unique_ptr<Template> Template::CreateByKind(Kind kind)
+{
+ switch (kind) {
+ case KD_Table: return std::make_unique<TableTemplate>();
+
+ case InvalidKind: break;
+ }
+ return nullptr;
+}
+
+bool Template::IsInstance(const Template* tmpl)
+{
+ return true;
+}
diff --git a/app/source/Cplt/Model/Template/fwd.hpp b/app/source/Cplt/Model/Template/fwd.hpp
new file mode 100644
index 0000000..8378871
--- /dev/null
+++ b/app/source/Cplt/Model/Template/fwd.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+// TableTemplate.hpp
+class TableCell;
+class TableArrayGroup;
+class TableInstantiationParameters;
+class TableTemplate;
+
+// Template.hpp
+class Template;
+class TemplateAssetList;
diff --git a/app/source/Cplt/Model/Workflow/Evaluation.cpp b/app/source/Cplt/Model/Workflow/Evaluation.cpp
new file mode 100644
index 0000000..7035bf9
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Evaluation.cpp
@@ -0,0 +1,174 @@
+#include "Evaluation.hpp"
+
+#include <queue>
+
+const char* WorkflowEvaluationError::FormatMessageType(enum MessageType messageType)
+{
+ switch (messageType) {
+ case Error: return "Error";
+ case Warning: return "Warning";
+ }
+}
+
+const char* WorkflowEvaluationError::FormatPinType(enum PinType pinType)
+{
+ switch (pinType) {
+ case NoPin: return nullptr;
+ case InputPin: return "Input pin";
+ case OutputPin: return "Output pin";
+ }
+}
+
+std::string WorkflowEvaluationError::Format() const
+{
+ // TODO convert to std::format
+
+ std::string result;
+ result += FormatMessageType(this->Type);
+ result += " at ";
+ result += NodeId;
+ if (auto pinText = FormatPinType(this->PinType)) {
+ result += "/";
+ result += pinText;
+ result += " ";
+ result += PinId;
+ }
+ result += ": ";
+ result += this->Message;
+
+ return result;
+}
+
+struct WorkflowEvaluationContext::RuntimeNode
+{
+ enum EvaluationStatus
+ {
+ ST_Unevaluated,
+ ST_Success,
+ ST_Failed,
+ };
+
+ EvaluationStatus Status = ST_Unevaluated;
+};
+
+struct WorkflowEvaluationContext::RuntimeConnection
+{
+ std::unique_ptr<BaseValue> Value;
+
+ bool IsAvailableValue() const
+ {
+ return Value != nullptr;
+ }
+};
+
+WorkflowEvaluationContext::WorkflowEvaluationContext(Workflow& workflow)
+ : mWorkflow{ &workflow }
+{
+ mRuntimeNodes.resize(workflow.mNodes.size());
+ mRuntimeConnections.resize(workflow.mConnections.size());
+}
+
+BaseValue* WorkflowEvaluationContext::GetConnectionValue(size_t id, bool constant)
+{
+ if (constant) {
+ return mWorkflow->GetConstantById(id);
+ } else {
+ return mRuntimeConnections[id].Value.get();
+ }
+}
+
+BaseValue* WorkflowEvaluationContext::GetConnectionValue(const WorkflowNode::InputPin& inputPin)
+{
+ if (inputPin.IsConnected()) {
+ return GetConnectionValue(inputPin.Connection, inputPin.IsConstantConnection());
+ } else {
+ return nullptr;
+ }
+}
+
+void WorkflowEvaluationContext::SetConnectionValue(size_t id, std::unique_ptr<BaseValue> value)
+{
+ mRuntimeConnections[id].Value = std::move(value);
+}
+
+void WorkflowEvaluationContext::SetConnectionValue(const WorkflowNode::OutputPin& outputPin, std::unique_ptr<BaseValue> value)
+{
+ if (outputPin.IsConnected()) {
+ SetConnectionValue(outputPin.Connection, std::move(value));
+ }
+}
+
+void WorkflowEvaluationContext::Run()
+{
+ int evaluatedCount = 0;
+ int erroredCount = 0;
+
+ for (auto& depthGroup : mWorkflow->GetDepthGroups()) {
+ for (size_t idx : depthGroup) {
+ auto& rn = mRuntimeNodes[idx];
+ auto& n = *mWorkflow->mNodes[idx];
+
+ // TODO
+
+ int preEvalErrors = mErrors.size();
+ n.Evaluate(*this);
+ if (preEvalErrors != mErrors.size()) {
+ erroredCount++;
+ } else {
+ evaluatedCount++;
+ }
+ }
+ }
+
+ for (size_t i = 0; i < mRuntimeNodes.size(); ++i) {
+ auto& rn = mRuntimeNodes[i];
+ auto& n = *mWorkflow->mNodes[i];
+ if (n.GetType() == WorkflowNode::OutputType) {
+ // TODO record outputs
+ }
+ }
+}
+
+void WorkflowEvaluationContext::ReportError(std::string message, const WorkflowNode& node, int pinId, bool inputPin)
+{
+ mErrors.push_back(WorkflowEvaluationError{
+ .Message = std::move(message),
+ .NodeId = node.GetId(),
+ .PinId = pinId,
+ .PinType = inputPin ? WorkflowEvaluationError::InputPin : WorkflowEvaluationError::OutputPin,
+ .Type = WorkflowEvaluationError::Error,
+ });
+}
+
+void WorkflowEvaluationContext::ReportError(std::string message, const WorkflowNode& node)
+{
+ mErrors.push_back(WorkflowEvaluationError{
+ .Message = std::move(message),
+ .NodeId = node.GetId(),
+ .PinId = -1,
+ .PinType = WorkflowEvaluationError::NoPin,
+ .Type = WorkflowEvaluationError::Error,
+ });
+}
+
+void WorkflowEvaluationContext::ReportWarning(std::string message, const WorkflowNode& node, int pinId, bool inputPin)
+{
+ mErrors.push_back(WorkflowEvaluationError{
+ .Message = std::move(message),
+ .NodeId = node.GetId(),
+ .PinId = pinId,
+ .PinType = inputPin ? WorkflowEvaluationError::InputPin : WorkflowEvaluationError::OutputPin,
+ .Type = WorkflowEvaluationError::Warning,
+ });
+}
+
+void WorkflowEvaluationContext::ReportWarning(std::string message, const WorkflowNode& node)
+{
+ mErrors.push_back(WorkflowEvaluationError{
+ .Message = std::move(message),
+ .NodeId = node.GetId(),
+ .PinId = -1,
+ .PinType = WorkflowEvaluationError::NoPin,
+ .Type = WorkflowEvaluationError::Warning,
+ });
+}
diff --git a/app/source/Cplt/Model/Workflow/Evaluation.hpp b/app/source/Cplt/Model/Workflow/Evaluation.hpp
new file mode 100644
index 0000000..5b8c6cc
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Evaluation.hpp
@@ -0,0 +1,67 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Workflow.hpp>
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <vector>
+
+class WorkflowEvaluationError
+{
+public:
+ enum MessageType : int16_t
+ {
+ Error,
+ Warning,
+ };
+
+ enum PinType : int16_t
+ {
+ NoPin,
+ InputPin,
+ OutputPin,
+ };
+
+public:
+ std::string Message;
+ size_t NodeId;
+ int PinId;
+ PinType PinType;
+ MessageType Type;
+
+public:
+ static const char* FormatMessageType(enum MessageType messageType);
+ static const char* FormatPinType(enum PinType pinType);
+
+ std::string Format() const;
+};
+
+class WorkflowEvaluationContext
+{
+private:
+ struct RuntimeNode;
+ struct RuntimeConnection;
+
+ Workflow* mWorkflow;
+ std::vector<RuntimeNode> mRuntimeNodes;
+ std::vector<RuntimeConnection> mRuntimeConnections;
+ std::vector<WorkflowEvaluationError> mErrors;
+ std::vector<WorkflowEvaluationError> mWarnings;
+
+public:
+ WorkflowEvaluationContext(Workflow& workflow);
+
+ BaseValue* GetConnectionValue(size_t id, bool constant);
+ BaseValue* GetConnectionValue(const WorkflowNode::InputPin& inputPin);
+ void SetConnectionValue(size_t id, std::unique_ptr<BaseValue> value);
+ void SetConnectionValue(const WorkflowNode::OutputPin& outputPin, std::unique_ptr<BaseValue> value);
+
+ void ReportError(std::string message, const WorkflowNode& node, int pinId, bool inputPin);
+ void ReportError(std::string message, const WorkflowNode& node);
+ void ReportWarning(std::string message, const WorkflowNode& node, int pinId, bool inputPin);
+ void ReportWarning(std::string message, const WorkflowNode& node);
+
+ /// Run until all possible paths have been evaluated.
+ void Run();
+};
diff --git a/app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.cpp b/app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.cpp
new file mode 100644
index 0000000..df4a8bb
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.cpp
@@ -0,0 +1,18 @@
+#include "DocumentNodes.hpp"
+
+#include <Cplt/Model/Workflow/Evaluation.hpp>
+#include <Cplt/Model/Workflow/Values/Basic.hpp>
+
+bool DocumentTemplateExpansionNode::IsInstance(const WorkflowNode* node)
+{
+ return node->GetKind() == KD_DocumentTemplateExpansion;
+}
+
+DocumentTemplateExpansionNode::DocumentTemplateExpansionNode()
+ : WorkflowNode(KD_DocumentTemplateExpansion, false)
+{
+}
+
+void DocumentTemplateExpansionNode::Evaluate(WorkflowEvaluationContext& ctx)
+{
+}
diff --git a/app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.hpp b/app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.hpp
new file mode 100644
index 0000000..a266b2c
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.hpp
@@ -0,0 +1,13 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Workflow.hpp>
+
+class DocumentTemplateExpansionNode : public WorkflowNode
+{
+public:
+ static bool IsInstance(const WorkflowNode* node);
+ DocumentTemplateExpansionNode();
+
+ // TODO
+ virtual void Evaluate(WorkflowEvaluationContext& ctx) override;
+};
diff --git a/app/source/Cplt/Model/Workflow/Nodes/NumericNodes.cpp b/app/source/Cplt/Model/Workflow/Nodes/NumericNodes.cpp
new file mode 100644
index 0000000..f8b29bb
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/NumericNodes.cpp
@@ -0,0 +1,94 @@
+#include "NumericNodes.hpp"
+
+#include <Cplt/Model/Workflow/Evaluation.hpp>
+#include <Cplt/Model/Workflow/Values/Basic.hpp>
+#include <Cplt/Utils/I18n.hpp>
+#include <Cplt/Utils/Macros.hpp>
+#include <Cplt/Utils/RTTI.hpp>
+
+#include <cassert>
+#include <utility>
+
+WorkflowNode::Kind NumericOperationNode::OperationTypeToNodeKind(OperationType type)
+{
+ switch (type) {
+ case Addition: return KD_NumericAddition;
+ case Subtraction: return KD_NumericSubtraction;
+ case Multiplication: return KD_NumericMultiplication;
+ case Division: return KD_NumericDivision;
+ default: return InvalidKind;
+ }
+}
+
+NumericOperationNode::OperationType NumericOperationNode::NodeKindToOperationType(Kind kind)
+{
+ switch (kind) {
+ case KD_NumericAddition: return Addition;
+ case KD_NumericSubtraction: return Subtraction;
+ case KD_NumericMultiplication: return Multiplication;
+ case KD_NumericDivision: return Division;
+ default: return InvalidType;
+ }
+}
+
+bool NumericOperationNode::IsInstance(const WorkflowNode* node)
+{
+ return node->GetKind() >= KD_NumericAddition && node->GetKind() <= KD_NumericDivision;
+}
+
+NumericOperationNode::NumericOperationNode(OperationType type)
+ : WorkflowNode(OperationTypeToNodeKind(type), false)
+ , mType{ type }
+{
+ mInputs.resize(2);
+ mInputs[0].MatchingType = BaseValue::KD_Numeric;
+ mInputs[1].MatchingType = BaseValue::KD_Numeric;
+
+ mOutputs.resize(1);
+ mOutputs[0].MatchingType = BaseValue::KD_Numeric;
+}
+
+void NumericOperationNode::Evaluate(WorkflowEvaluationContext& ctx)
+{
+ auto lhsVal = dyn_cast<NumericValue>(ctx.GetConnectionValue(mInputs[0]));
+ if (!lhsVal) return;
+ double lhs = lhsVal->GetValue();
+
+ auto rhsVal = dyn_cast<NumericValue>(ctx.GetConnectionValue(mInputs[1]));
+ if (!rhsVal) return;
+ double rhs = rhsVal->GetValue();
+
+ double res;
+ switch (mType) {
+ case Addition: res = lhs + rhs; break;
+ case Subtraction: res = lhs - rhs; break;
+ case Multiplication: res = lhs * rhs; break;
+ case Division: {
+ if (rhs == 0.0) {
+ ctx.ReportError(I18N_TEXT("Error: division by 0", L10N_WORKFLOW_RTERROR_DIV_BY_0), *this);
+ return;
+ }
+ res = lhs / rhs;
+ } break;
+
+ default: return;
+ }
+
+ auto value = std::make_unique<NumericValue>();
+ value->SetValue(res);
+ ctx.SetConnectionValue(mOutputs[0], std::move(value));
+}
+
+bool NumericExpressionNode::IsInstance(const WorkflowNode* node)
+{
+ return node->GetKind() == KD_NumericExpression;
+}
+
+NumericExpressionNode::NumericExpressionNode()
+ : WorkflowNode(KD_NumericExpression, false)
+{
+}
+
+void NumericExpressionNode::Evaluate(WorkflowEvaluationContext& ctx)
+{
+}
diff --git a/app/source/Cplt/Model/Workflow/Nodes/NumericNodes.hpp b/app/source/Cplt/Model/Workflow/Nodes/NumericNodes.hpp
new file mode 100644
index 0000000..3c89708
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/NumericNodes.hpp
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Workflow.hpp>
+
+#include <cstddef>
+#include <memory>
+#include <variant>
+#include <vector>
+
+class NumericOperationNode : public WorkflowNode
+{
+public:
+ enum OperationType
+ {
+ Addition,
+ Subtraction,
+ Multiplication,
+ Division,
+
+ InvalidType,
+ TypeCount = InvalidType,
+ };
+
+private:
+ OperationType mType;
+
+public:
+ static Kind OperationTypeToNodeKind(OperationType type);
+ static OperationType NodeKindToOperationType(Kind kind);
+ static bool IsInstance(const WorkflowNode* node);
+ NumericOperationNode(OperationType type);
+
+ virtual void Evaluate(WorkflowEvaluationContext& ctx) override;
+};
+
+class NumericExpressionNode : public WorkflowNode
+{
+public:
+ static bool IsInstance(const WorkflowNode* node);
+ NumericExpressionNode();
+
+ // TODO
+ virtual void Evaluate(WorkflowEvaluationContext& ctx) override;
+}; \ No newline at end of file
diff --git a/app/source/Cplt/Model/Workflow/Nodes/TextNodes.cpp b/app/source/Cplt/Model/Workflow/Nodes/TextNodes.cpp
new file mode 100644
index 0000000..9b31f7a
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/TextNodes.cpp
@@ -0,0 +1,231 @@
+#include "TextNodes.hpp"
+
+#include <Cplt/Model/Workflow/Evaluation.hpp>
+#include <Cplt/Model/Workflow/Values/Basic.hpp>
+#include <Cplt/Utils/Macros.hpp>
+#include <Cplt/Utils/RTTI.hpp>
+#include <Cplt/Utils/Variant.hpp>
+
+#include <cassert>
+#include <utility>
+#include <variant>
+#include <vector>
+
+class TextFormatterNode::Impl
+{
+public:
+ template <class TFunction>
+ static void ForArguments(std::vector<Element>::iterator begin, std::vector<Element>::iterator end, const TFunction& func)
+ {
+ for (auto it = begin; it != end; ++it) {
+ auto& elm = *it;
+ if (auto arg = std::get_if<Argument>(&elm)) {
+ func(*arg);
+ }
+ }
+ }
+
+ /// Find the pin index that the \c elmIdx -th element should have, based on the elements coming before it.
+ static int FindPinForElement(const std::vector<Element>& vec, int elmIdx)
+ {
+ for (int i = elmIdx; i >= 0; --i) {
+ auto& elm = vec[i];
+ if (auto arg = std::get_if<Argument>(&elm)) {
+ return arg->PinIdx + 1;
+ }
+ }
+ return 0;
+ }
+};
+
+BaseValue::Kind TextFormatterNode::ArgumentTypeToValueKind(TextFormatterNode::ArgumentType arg)
+{
+ switch (arg) {
+ case NumericArgument: return BaseValue::KD_Numeric;
+ case TextArgument: return BaseValue::KD_Text;
+ case DateTimeArgument: return BaseValue::KD_DateTime;
+ }
+}
+
+bool TextFormatterNode::IsInstance(const WorkflowNode* node)
+{
+ return node->GetKind() == KD_TextFormatting;
+}
+
+TextFormatterNode::TextFormatterNode()
+ : WorkflowNode(KD_TextFormatting, false)
+{
+}
+
+int TextFormatterNode::GetElementCount() const
+{
+ return mElements.size();
+}
+
+const TextFormatterNode::Element& TextFormatterNode::GetElement(int idx) const
+{
+ return mElements[idx];
+}
+
+void TextFormatterNode::SetElement(int idx, std::string text)
+{
+ assert(idx >= 0 && idx < mElements.size());
+
+ std::visit(
+ Overloaded{
+ [&](const std::string& original) { mMinOutputChars -= original.size(); },
+ [&](const Argument& original) { PreRemoveElement(idx); },
+ },
+ mElements[idx]);
+
+ mMinOutputChars += text.size();
+ mElements[idx] = std::move(text);
+}
+
+void TextFormatterNode::SetElement(int idx, ArgumentType argument)
+{
+ assert(idx >= 0 && idx < mElements.size());
+
+ std::visit(
+ Overloaded{
+ [&](const std::string& original) {
+ mMinOutputChars -= original.size();
+
+ mElements[idx] = Argument{
+ .Type = argument,
+ .PinIdx = Impl::FindPinForElement(mElements, idx),
+ };
+ /* `original` is invalid from this point */
+ },
+ [&](const Argument& original) {
+ int pinIdx = original.PinIdx;
+
+ // Create pin
+ auto& pin = mInputs[pinIdx];
+ pin.MatchingType = ArgumentTypeToValueKind(argument);
+
+ // Create element
+ mElements[idx] = Argument{
+ .Type = argument,
+ .PinIdx = pinIdx,
+ };
+ /* `original` is invalid from this point */
+ },
+ },
+ mElements[idx]);
+}
+
+void TextFormatterNode::InsertElement(int idx, std::string text)
+{
+ assert(idx >= 0);
+ if (idx >= mElements.size()) AppendElement(std::move(text));
+
+ mMinOutputChars += text.size();
+ mElements.insert(mElements.begin() + idx, std::move(text));
+}
+
+void TextFormatterNode::InsertElement(int idx, ArgumentType argument)
+{
+ assert(idx >= 0);
+ if (idx >= mElements.size()) AppendElement(argument);
+
+ int pinIdx = Impl::FindPinForElement(mElements, idx);
+
+ // Create pin
+ auto& pin = InsertInputPin(pinIdx);
+ pin.MatchingType = ArgumentTypeToValueKind(argument);
+
+ // Create element
+ mElements.insert(
+ mElements.begin() + idx,
+ Argument{
+ .Type = argument,
+ .PinIdx = pinIdx,
+ });
+}
+
+void TextFormatterNode::AppendElement(std::string text)
+{
+ mMinOutputChars += text.size();
+ mElements.push_back(std::move(text));
+}
+
+void TextFormatterNode::AppendElement(ArgumentType argument)
+{
+ int pinIdx = mInputs.size();
+ // Create pin
+ mInputs.push_back(InputPin{});
+ mInputs.back().MatchingType = ArgumentTypeToValueKind(argument);
+ // Creat eelement
+ mElements.push_back(Argument{
+ .Type = argument,
+ .PinIdx = pinIdx,
+ });
+}
+
+void TextFormatterNode::RemoveElement(int idx)
+{
+ assert(idx >= 0 && idx < mElements.size());
+
+ PreRemoveElement(idx);
+ if (auto arg = std::get_if<Argument>(&mElements[idx])) {
+ RemoveInputPin(arg->PinIdx);
+ }
+ mElements.erase(mElements.begin() + idx);
+}
+
+void TextFormatterNode::Evaluate(WorkflowEvaluationContext& ctx)
+{
+ std::string result;
+ result.reserve((size_t)(mMinOutputChars * 1.5f));
+
+ auto HandleText = [&](const std::string& str) {
+ result += str;
+ };
+ auto HandleArgument = [&](const Argument& arg) {
+ switch (arg.Type) {
+ case NumericArgument: {
+ if (auto val = dyn_cast<NumericValue>(ctx.GetConnectionValue(mInputs[arg.PinIdx]))) {
+ result += val->GetString();
+ } else {
+ // TODO localize
+ ctx.ReportError("Non-numeric value connected to a numeric text format parameter.", *this);
+ }
+ } break;
+ case TextArgument: {
+ if (auto val = dyn_cast<TextValue>(ctx.GetConnectionValue(mInputs[arg.PinIdx]))) {
+ result += val->GetValue();
+ } else {
+ // TODO localize
+ ctx.ReportError("Non-text value connected to a textual text format parameter.", *this);
+ }
+ } break;
+ case DateTimeArgument: {
+ if (auto val = dyn_cast<DateTimeValue>(ctx.GetConnectionValue(mInputs[arg.PinIdx]))) {
+ result += val->GetString();
+ } else {
+ // TODO localize
+ ctx.ReportError("Non-date/time value connected to a date/time text format parameter.", *this);
+ }
+ } break;
+ }
+ };
+
+ for (auto& elm : mElements) {
+ std::visit(Overloaded{ HandleText, HandleArgument }, elm);
+ }
+}
+
+void TextFormatterNode::PreRemoveElement(int idx)
+{
+ auto& elm = mElements[idx];
+ if (auto arg = std::get_if<Argument>(&elm)) {
+ RemoveInputPin(arg->PinIdx);
+ Impl::ForArguments(
+ mElements.begin() + idx + 1,
+ mElements.end(),
+ [&](Argument& arg) {
+ arg.PinIdx--;
+ });
+ }
+}
diff --git a/app/source/Cplt/Model/Workflow/Nodes/TextNodes.hpp b/app/source/Cplt/Model/Workflow/Nodes/TextNodes.hpp
new file mode 100644
index 0000000..4689931
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/TextNodes.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Workflow.hpp>
+
+#include <cstddef>
+#include <memory>
+#include <variant>
+#include <vector>
+
+class TextFormatterNode : public WorkflowNode
+{
+public:
+ enum ArgumentType
+ {
+ NumericArgument,
+ TextArgument,
+ DateTimeArgument,
+ };
+
+private:
+ class Impl;
+
+ struct Argument
+ {
+ ArgumentType Type;
+ int PinIdx;
+ };
+ using Element = std::variant<std::string, Argument>;
+
+ std::vector<Element> mElements;
+ int mMinOutputChars;
+
+public:
+ static BaseValue::Kind ArgumentTypeToValueKind(ArgumentType arg);
+ static bool IsInstance(const WorkflowNode* node);
+ TextFormatterNode();
+
+ int GetElementCount() const;
+ const Element& GetElement(int idx) const;
+
+ void SetElement(int idx, std::string text);
+ void SetElement(int idx, ArgumentType argument);
+ void InsertElement(int idx, std::string text);
+ void InsertElement(int idx, ArgumentType argument);
+ void AppendElement(std::string text);
+ void AppendElement(ArgumentType argument);
+ void RemoveElement(int idx);
+
+ virtual void Evaluate(WorkflowEvaluationContext& ctx) override;
+
+private:
+ void PreRemoveElement(int idx);
+};
diff --git a/app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.cpp b/app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.cpp
new file mode 100644
index 0000000..93d458c
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.cpp
@@ -0,0 +1,32 @@
+#include "UserInputNodes.hpp"
+
+#include <Cplt/Model/Workflow/Evaluation.hpp>
+#include <Cplt/Model/Workflow/Values/Basic.hpp>
+
+bool FormInputNode::IsInstance(const WorkflowNode* node)
+{
+ return node->GetKind() == KD_FormInput;
+}
+
+FormInputNode::FormInputNode()
+ : WorkflowNode(KD_FormInput, false)
+{
+}
+
+void FormInputNode::Evaluate(WorkflowEvaluationContext& ctx)
+{
+}
+
+bool DatabaseRowsInputNode::IsInstance(const WorkflowNode* node)
+{
+ return node->GetKind() == KD_DatabaseRowsInput;
+}
+
+DatabaseRowsInputNode::DatabaseRowsInputNode()
+ : WorkflowNode(KD_DatabaseRowsInput, false)
+{
+}
+
+void DatabaseRowsInputNode::Evaluate(WorkflowEvaluationContext& ctx)
+{
+}
diff --git a/app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.hpp b/app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.hpp
new file mode 100644
index 0000000..f0b923c
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.hpp
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Workflow.hpp>
+
+class FormInputNode : public WorkflowNode
+{
+public:
+ static bool IsInstance(const WorkflowNode* node);
+ FormInputNode();
+
+ // TODO
+ virtual void Evaluate(WorkflowEvaluationContext& ctx) override;
+};
+
+class DatabaseRowsInputNode : public WorkflowNode
+{
+public:
+ static bool IsInstance(const WorkflowNode* node);
+ DatabaseRowsInputNode();
+
+ // TODO
+ virtual void Evaluate(WorkflowEvaluationContext& ctx) override;
+};
diff --git a/app/source/Cplt/Model/Workflow/Nodes/fwd.hpp b/app/source/Cplt/Model/Workflow/Nodes/fwd.hpp
new file mode 100644
index 0000000..4153825
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Nodes/fwd.hpp
@@ -0,0 +1,15 @@
+#pragma once
+
+// DocumentNodes.hpp
+class DocumentTemplateExpansionNode;
+
+// InputNodes.hpp
+class FormInputNode;
+class DatabaseRowsInputNode;
+
+// NumericNodes.hpp
+class NumericOperationNode;
+class NumericExpressionNode;
+
+// TextNodes.hpp
+class TextFormatterNode;
diff --git a/app/source/Cplt/Model/Workflow/Value.hpp b/app/source/Cplt/Model/Workflow/Value.hpp
new file mode 100644
index 0000000..70fcb57
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Value.hpp
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <Cplt/Utils/Color.hpp>
+#include <Cplt/fwd.hpp>
+
+#include <iosfwd>
+#include <memory>
+#include <string>
+#include <vector>
+
+class BaseValue
+{
+public:
+ enum Kind
+ {
+ KD_Numeric,
+ KD_Text,
+ KD_DateTime,
+ KD_DatabaseRowId,
+ KD_List,
+ KD_Dictionary,
+
+ KD_BaseObject,
+ KD_SaleDatabaseRow,
+ KD_PurchaseDatabaseRow,
+ KD_BaseObjectLast = KD_PurchaseDatabaseRow,
+
+ /// An unspecified type, otherwise known as "any" in some contexts.
+ InvalidKind,
+ KindCount = InvalidKind,
+ };
+
+ struct KindInfo
+ {
+ ImGui::IconType PinIcon;
+ RgbaColor PinColor;
+ };
+
+private:
+ Kind mKind;
+
+public:
+ static const KindInfo& QueryInfo(Kind kind);
+ static const char* Format(Kind kind);
+ static std::unique_ptr<BaseValue> CreateByKind(Kind kind);
+
+ static bool IsInstance(const BaseValue* value);
+
+ BaseValue(Kind kind);
+ virtual ~BaseValue() = default;
+
+ BaseValue(const BaseValue&) = delete;
+ BaseValue& operator=(const BaseValue&) = delete;
+ BaseValue(BaseValue&&) = default;
+ BaseValue& operator=(BaseValue&&) = default;
+
+ Kind GetKind() const;
+
+ // TODO get constant editor
+
+ /// The functions \c ReadFrom, \c WriteTo will only be valid to call if this function returns true.
+ virtual bool SupportsConstant() const;
+ virtual void ReadFrom(std::istream& stream);
+ virtual void WriteTo(std::ostream& stream);
+};
+
+class BaseObjectDescription
+{
+public:
+ struct Property
+ {
+ std::string Name;
+ BaseValue::Kind Kind;
+ bool Mutatable = true;
+ };
+
+public:
+ std::vector<Property> Properties;
+};
+
+class BaseObjectValue : public BaseValue
+{
+public:
+ /// \param kind A value kind enum, within the range of KD_BaseObject and KD_BaseObjectLast (both inclusive).
+ static const BaseObjectDescription& QueryObjectInfo(Kind kind);
+
+ static bool IsInstance(const BaseValue* value);
+ BaseObjectValue(Kind kind);
+
+ const BaseObjectDescription& GetObjectDescription() const;
+
+ virtual const BaseValue* GetProperty(int idx) const = 0;
+ virtual bool SetProperty(int idx, std::unique_ptr<BaseValue> value) = 0;
+};
diff --git a/app/source/Cplt/Model/Workflow/ValueInternals.hpp b/app/source/Cplt/Model/Workflow/ValueInternals.hpp
new file mode 100644
index 0000000..45842db
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/ValueInternals.hpp
@@ -0,0 +1,21 @@
+// This file contains utility classes and macros for implementing values
+// As consumers, you should not include this header as it contains unnecessary symbols and can pollute your files
+// for this reason, classes here aren't forward-declared in fwd.hpp either.
+
+#pragma once
+
+#include <Cplt/Utils/RTTI.hpp>
+
+#include <utility>
+
+#define CHECK_VALUE_TYPE(Type, value) \
+ if (!is_a<Type>(value)) { \
+ return false; \
+ }
+
+#define CHECK_VALUE_TYPE_AND_MOVE(Type, dest, value) \
+ if (auto ptr = dyn_cast<Type>(value)) { \
+ dest = std::move(*ptr); \
+ } else { \
+ return false; \
+ }
diff --git a/app/source/Cplt/Model/Workflow/Value_Main.cpp b/app/source/Cplt/Model/Workflow/Value_Main.cpp
new file mode 100644
index 0000000..ca972c4
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Value_Main.cpp
@@ -0,0 +1,35 @@
+#include "Value.hpp"
+
+BaseValue::BaseValue(Kind kind)
+ : mKind{ kind }
+{
+}
+
+BaseValue::Kind BaseValue::GetKind() const
+{
+ return mKind;
+}
+
+bool BaseValue::SupportsConstant() const
+{
+ return false;
+}
+
+void BaseValue::ReadFrom(std::istream& stream)
+{
+}
+
+void BaseValue::WriteTo(std::ostream& stream)
+{
+}
+
+BaseObjectValue::BaseObjectValue(Kind kind)
+ : BaseValue(kind)
+{
+ assert(kind >= KD_BaseObject && kind <= KD_BaseObjectLast);
+}
+
+const BaseObjectDescription& BaseObjectValue::GetObjectDescription() const
+{
+ return QueryObjectInfo(this->GetKind());
+}
diff --git a/app/source/Cplt/Model/Workflow/Value_RTTI.cpp b/app/source/Cplt/Model/Workflow/Value_RTTI.cpp
new file mode 100644
index 0000000..a2a6960
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Value_RTTI.cpp
@@ -0,0 +1,174 @@
+#include "Value.hpp"
+
+#include <Cplt/Model/Workflow/Values/Basic.hpp>
+#include <Cplt/Model/Workflow/Values/Database.hpp>
+#include <Cplt/Model/Workflow/Values/Dictionary.hpp>
+#include <Cplt/Model/Workflow/Values/List.hpp>
+#include <Cplt/UI/UI.hpp>
+#include <Cplt/Utils/I18n.hpp>
+
+constexpr BaseValue::KindInfo kEmptyInfo{
+ .PinIcon = ImGui::IconType::Circle,
+ .PinColor = RgbaColor(),
+};
+
+constexpr BaseValue::KindInfo kNumericInfo{
+ .PinIcon = ImGui::IconType::Circle,
+ .PinColor = RgbaColor(147, 226, 74),
+};
+
+constexpr BaseValue::KindInfo kTextInfo{
+ .PinIcon = ImGui::IconType::Circle,
+ .PinColor = RgbaColor(124, 21, 153),
+};
+
+constexpr BaseValue::KindInfo kDateTimeInfo{
+ .PinIcon = ImGui::IconType::Circle,
+ .PinColor = RgbaColor(147, 226, 74),
+};
+
+constexpr BaseValue::KindInfo kDatabaseRowIdInfo{
+ .PinIcon = ImGui::IconType::Circle,
+ .PinColor = RgbaColor(216, 42, 221),
+};
+
+constexpr BaseValue::KindInfo kListInfo{
+ .PinIcon = ImGui::IconType::Diamond,
+ .PinColor = RgbaColor(58, 154, 214),
+};
+
+constexpr BaseValue::KindInfo kDictionaryInfo{
+ .PinIcon = ImGui::IconType::Diamond,
+ .PinColor = RgbaColor(240, 240, 34),
+};
+
+constexpr BaseValue::KindInfo kDatabaseRowInfo{
+ .PinIcon = ImGui::IconType::Square,
+ .PinColor = RgbaColor(15, 124, 196),
+};
+
+constexpr BaseValue::KindInfo kObjectInfo{
+ .PinIcon = ImGui::IconType::Square,
+ .PinColor = RgbaColor(161, 161, 161),
+};
+
+const BaseValue::KindInfo& BaseValue::QueryInfo(BaseValue::Kind kind)
+{
+ switch (kind) {
+ case KD_Numeric: return kNumericInfo;
+ case KD_Text: return kTextInfo;
+ case KD_DateTime: return kDateTimeInfo;
+ case KD_DatabaseRowId: return kDatabaseRowIdInfo;
+ case KD_List: return kListInfo;
+ case KD_Dictionary: return kDictionaryInfo;
+
+ case KD_BaseObject: return kObjectInfo;
+ case KD_SaleDatabaseRow:
+ case KD_PurchaseDatabaseRow:
+ return kDatabaseRowInfo;
+
+ case InvalidKind: break;
+ }
+ return kEmptyInfo;
+}
+
+const char* BaseValue::Format(Kind kind)
+{
+ switch (kind) {
+ case KD_Numeric: return I18N_TEXT("Numeric", L10N_VALUE_NUMERIC);
+ case KD_Text: return I18N_TEXT("Text", L10N_VALUE_TEXT);
+ case KD_DateTime: return I18N_TEXT("Date/time", L10N_VALUE_DATE_TIME);
+ case KD_DatabaseRowId: return I18N_TEXT("Row id", L10N_VALUE_ROW_ID);
+ case KD_List: return I18N_TEXT("List", L10N_VALUE_LIST);
+ case KD_Dictionary: return I18N_TEXT("Dictionary", L10N_VALUE_DICT);
+
+ case KD_BaseObject: return I18N_TEXT("Object", L10N_VALUE_OBJECT);
+ case KD_SaleDatabaseRow: return I18N_TEXT("Sale record", L10N_VALUE_SALE_RECORD);
+ case KD_PurchaseDatabaseRow: return I18N_TEXT("Purchase record", L10N_VALUE_PURCHASE_RECORD);
+
+ case InvalidKind: break;
+ }
+ return "";
+}
+
+std::unique_ptr<BaseValue> BaseValue::CreateByKind(BaseValue::Kind kind)
+{
+ switch (kind) {
+ case KD_Numeric: return std::make_unique<NumericValue>();
+ case KD_Text: return std::make_unique<TextValue>();
+ case KD_DateTime: return std::make_unique<DateTimeValue>();
+ case KD_DatabaseRowId: return std::make_unique<DatabaseRowIdValue>();
+ case KD_List: return std::make_unique<ListValue>();
+ case KD_Dictionary: return std::make_unique<DictionaryValue>();
+
+ case KD_BaseObject: return nullptr;
+ case KD_SaleDatabaseRow: return std::make_unique<SaleDatabaseRowValue>();
+ case KD_PurchaseDatabaseRow: return std::make_unique<PurchaseDatabaseRowValue>();
+
+ case InvalidKind: break;
+ }
+ return nullptr;
+}
+
+bool BaseValue::IsInstance(const BaseValue* value)
+{
+ return true;
+}
+
+const BaseObjectDescription kEmptyObjectInfo{
+ .Properties = {},
+};
+
+const BaseObjectDescription kSaleDbRowObject{
+ .Properties = {
+ {
+ .Name = I18N_TEXT("Customer", L10N_VALUE_PROPERTY_CUSTOMER),
+ .Kind = BaseValue::KD_Text,
+ .Mutatable = false,
+ },
+ {
+ .Name = I18N_TEXT("Deadline", L10N_VALUE_PROPERTY_DEADLINE),
+ .Kind = BaseValue::KD_DateTime,
+ },
+ {
+ .Name = I18N_TEXT("Delivery time", L10N_VALUE_PROPERTY_DELIVERY_TIME),
+ .Kind = BaseValue::KD_DateTime,
+ },
+ },
+};
+
+const BaseObjectDescription kPurchaseDbRowObject{
+ .Properties = {
+ {
+ .Name = I18N_TEXT("Factory", L10N_VALUE_PROPERTY_FACTORY),
+ .Kind = BaseValue::KD_Text,
+ .Mutatable = false,
+ },
+ {
+ .Name = I18N_TEXT("Order time", L10N_VALUE_PROPERTY_ORDER_TIME),
+ .Kind = BaseValue::KD_DateTime,
+ },
+ {
+ .Name = I18N_TEXT("Delivery time", L10N_VALUE_PROPERTY_DELIVERY_TIME),
+ .Kind = BaseValue::KD_DateTime,
+ },
+ },
+};
+
+const BaseObjectDescription& BaseObjectValue::QueryObjectInfo(Kind kind)
+{
+ switch (kind) {
+ case KD_BaseObject: return kEmptyObjectInfo;
+ case KD_SaleDatabaseRow: return kSaleDbRowObject;
+ case KD_PurchaseDatabaseRow: return kPurchaseDbRowObject;
+
+ default: break;
+ }
+ return kEmptyObjectInfo;
+}
+
+bool BaseObjectValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() >= KD_BaseObject &&
+ value->GetKind() <= KD_BaseObjectLast;
+}
diff --git a/app/source/Cplt/Model/Workflow/Values/Basic.cpp b/app/source/Cplt/Model/Workflow/Values/Basic.cpp
new file mode 100644
index 0000000..198387c
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/Basic.cpp
@@ -0,0 +1,111 @@
+#include "Basic.hpp"
+
+#include <charconv>
+#include <cmath>
+#include <limits>
+
+bool NumericValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_Numeric;
+}
+
+NumericValue::NumericValue()
+ : BaseValue(BaseValue::KD_Numeric)
+{
+}
+
+template <class T, int kMaxSize>
+static std::string NumberToString(T value)
+{
+ char buf[kMaxSize];
+ auto res = std::to_chars(buf, buf + kMaxSize, value);
+ if (res.ec == std::errc()) {
+ return std::string(buf, res.ptr);
+ } else {
+ return "<err>";
+ }
+}
+
+std::string NumericValue::GetTruncatedString() const
+{
+ constexpr auto kMaxSize = std::numeric_limits<int64_t>::digits10;
+ return ::NumberToString<int64_t, kMaxSize>((int64_t)mValue);
+}
+
+std::string NumericValue::GetRoundedString() const
+{
+ constexpr auto kMaxSize = std::numeric_limits<int64_t>::digits10;
+ return ::NumberToString<int64_t, kMaxSize>((int64_t)std::round(mValue));
+}
+
+std::string NumericValue::GetString() const
+{
+ constexpr auto kMaxSize = std::numeric_limits<double>::max_digits10;
+ return ::NumberToString<double, kMaxSize>(mValue);
+}
+
+int64_t NumericValue::GetInt() const
+{
+ return static_cast<int64_t>(mValue);
+}
+
+double NumericValue::GetValue() const
+{
+ return mValue;
+}
+
+void NumericValue::SetValue(double value)
+{
+ mValue = value;
+}
+
+bool TextValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_Text;
+}
+
+TextValue::TextValue()
+ : BaseValue(BaseValue::KD_Text)
+{
+}
+
+const std::string& TextValue::GetValue() const
+{
+ return mValue;
+}
+
+void TextValue::SetValue(const std::string& value)
+{
+ mValue = value;
+}
+
+bool DateTimeValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_DateTime;
+}
+
+DateTimeValue::DateTimeValue()
+ : BaseValue(BaseValue::KD_DateTime)
+{
+}
+
+std::string DateTimeValue::GetString() const
+{
+ namespace chrono = std::chrono;
+ auto t = chrono::system_clock::to_time_t(mValue);
+
+ char data[32];
+ std::strftime(data, sizeof(data), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
+
+ return std::string(data);
+}
+
+const std::chrono::time_point<std::chrono::system_clock>& DateTimeValue::GetValue() const
+{
+ return mValue;
+}
+
+void DateTimeValue::SetValue(const std::chrono::time_point<std::chrono::system_clock>& value)
+{
+ mValue = value;
+}
diff --git a/app/source/Cplt/Model/Workflow/Values/Basic.hpp b/app/source/Cplt/Model/Workflow/Values/Basic.hpp
new file mode 100644
index 0000000..820fb13
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/Basic.hpp
@@ -0,0 +1,67 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Value.hpp>
+
+#include <chrono>
+#include <cstdint>
+#include <string>
+
+class NumericValue : public BaseValue
+{
+private:
+ double mValue;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ NumericValue();
+
+ NumericValue(const NumericValue&) = delete;
+ NumericValue& operator=(const NumericValue&) = delete;
+ NumericValue(NumericValue&&) = default;
+ NumericValue& operator=(NumericValue&&) = default;
+
+ std::string GetTruncatedString() const;
+ std::string GetRoundedString() const;
+ std::string GetString() const;
+
+ int64_t GetInt() const;
+ double GetValue() const;
+ void SetValue(double value);
+};
+
+class TextValue : public BaseValue
+{
+private:
+ std::string mValue;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ TextValue();
+
+ TextValue(const TextValue&) = delete;
+ TextValue& operator=(const TextValue&) = delete;
+ TextValue(TextValue&&) = default;
+ TextValue& operator=(TextValue&&) = default;
+
+ const std::string& GetValue() const;
+ void SetValue(const std::string& value);
+};
+
+class DateTimeValue : public BaseValue
+{
+private:
+ std::chrono::time_point<std::chrono::system_clock> mValue;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ DateTimeValue();
+
+ DateTimeValue(const DateTimeValue&) = delete;
+ DateTimeValue& operator=(const DateTimeValue&) = delete;
+ DateTimeValue(DateTimeValue&&) = default;
+ DateTimeValue& operator=(DateTimeValue&&) = default;
+
+ std::string GetString() const;
+ const std::chrono::time_point<std::chrono::system_clock>& GetValue() const;
+ void SetValue(const std::chrono::time_point<std::chrono::system_clock>& value);
+};
diff --git a/app/source/Cplt/Model/Workflow/Values/Database.cpp b/app/source/Cplt/Model/Workflow/Values/Database.cpp
new file mode 100644
index 0000000..25b77e9
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/Database.cpp
@@ -0,0 +1,88 @@
+#include "Database.hpp"
+
+#include <Cplt/Model/Database.hpp>
+#include <Cplt/Model/Workflow/ValueInternals.hpp>
+
+#include <limits>
+
+TableKind DatabaseRowIdValue::GetTable() const
+{
+ return mTable;
+}
+
+int64_t DatabaseRowIdValue::GetRowId() const
+{
+ return mRowId;
+}
+
+bool DatabaseRowIdValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_DatabaseRowId;
+}
+
+DatabaseRowIdValue::DatabaseRowIdValue()
+ : BaseValue(KD_DatabaseRowId)
+ , mTable{ TableKind::Sales }
+ , mRowId{ std::numeric_limits<int64_t>::max() }
+{
+}
+
+bool SaleDatabaseRowValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_SaleDatabaseRow;
+}
+
+SaleDatabaseRowValue::SaleDatabaseRowValue()
+ : BaseObjectValue(KD_SaleDatabaseRow)
+{
+}
+
+const BaseValue* SaleDatabaseRowValue::GetProperty(int idx) const
+{
+ switch (idx) {
+ case 0: return &mCustomerName;
+ case 1: return &mDeadline;
+ case 2: return &mDeliveryTime;
+ default: return nullptr;
+ }
+}
+
+bool SaleDatabaseRowValue::SetProperty(int idx, std::unique_ptr<BaseValue> value)
+{
+ switch (idx) {
+ case 0: return false;
+ case 1: CHECK_VALUE_TYPE_AND_MOVE(DateTimeValue, mDeadline, value.get()); break;
+ case 2: CHECK_VALUE_TYPE_AND_MOVE(DateTimeValue, mDeliveryTime, value.get()); break;
+ }
+ return true;
+}
+
+bool PurchaseDatabaseRowValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_PurchaseDatabaseRow;
+}
+
+PurchaseDatabaseRowValue::PurchaseDatabaseRowValue()
+ : BaseObjectValue(KD_PurchaseDatabaseRow)
+{
+}
+
+const BaseValue* PurchaseDatabaseRowValue::GetProperty(int idx) const
+{
+ switch (idx) {
+ case 0: return &mFactoryName;
+ case 1: return &mOrderTime;
+ case 2: return &mDeliveryTime;
+ default: return nullptr;
+ }
+}
+
+bool PurchaseDatabaseRowValue::SetProperty(int idx, std::unique_ptr<BaseValue> value)
+{
+ switch (idx) {
+ case 0: return false;
+ case 1: CHECK_VALUE_TYPE_AND_MOVE(DateTimeValue, mOrderTime, value.get()); break;
+ case 2: CHECK_VALUE_TYPE_AND_MOVE(DateTimeValue, mDeliveryTime, value.get()); break;
+ }
+ return true;
+}
diff --git a/app/source/Cplt/Model/Workflow/Values/Database.hpp b/app/source/Cplt/Model/Workflow/Values/Database.hpp
new file mode 100644
index 0000000..f1c1571
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/Database.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Value.hpp>
+#include <Cplt/Model/Workflow/Values/Basic.hpp>
+#include <Cplt/fwd.hpp>
+
+class DatabaseRowIdValue : public BaseValue
+{
+private:
+ TableKind mTable;
+ int64_t mRowId;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ DatabaseRowIdValue();
+
+ TableKind GetTable() const;
+ int64_t GetRowId() const;
+};
+
+class SaleDatabaseRowValue : public BaseObjectValue
+{
+private:
+ int mCustomerId;
+ TextValue mCustomerName;
+ DateTimeValue mDeadline;
+ DateTimeValue mDeliveryTime;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ SaleDatabaseRowValue();
+
+ virtual const BaseValue* GetProperty(int idx) const;
+ virtual bool SetProperty(int idx, std::unique_ptr<BaseValue> value);
+};
+
+class PurchaseDatabaseRowValue : public BaseObjectValue
+{
+private:
+ int mFactoryId;
+ TextValue mFactoryName;
+ DateTimeValue mOrderTime;
+ DateTimeValue mDeliveryTime;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ PurchaseDatabaseRowValue();
+
+ virtual const BaseValue* GetProperty(int idx) const;
+ virtual bool SetProperty(int idx, std::unique_ptr<BaseValue> value);
+};
diff --git a/app/source/Cplt/Model/Workflow/Values/Dictionary.cpp b/app/source/Cplt/Model/Workflow/Values/Dictionary.cpp
new file mode 100644
index 0000000..97bf509
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/Dictionary.cpp
@@ -0,0 +1,49 @@
+#include "Dictionary.hpp"
+
+#include <Cplt/Utils/Macros.hpp>
+
+bool DictionaryValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_Dictionary;
+}
+
+DictionaryValue::DictionaryValue()
+ : BaseValue(KD_Dictionary)
+{
+}
+
+int DictionaryValue::GetCount() const
+{
+ return mElements.size();
+}
+
+BaseValue* DictionaryValue::Find(std::string_view key)
+{
+ auto iter = mElements.find(key);
+ if (iter != mElements.end()) {
+ return iter.value().get();
+ } else {
+ return nullptr;
+ }
+}
+
+BaseValue* DictionaryValue::Insert(std::string_view key, std::unique_ptr<BaseValue>& value)
+{
+ auto [iter, success] = mElements.insert(key, std::move(value));
+ if (success) {
+ return iter.value().get();
+ } else {
+ return nullptr;
+ }
+}
+
+BaseValue& DictionaryValue::InsertOrReplace(std::string_view key, std::unique_ptr<BaseValue> value)
+{
+ auto [iter, DISCARD] = mElements.emplace(key, std::move(value));
+ return *iter.value();
+}
+
+void DictionaryValue::Remove(std::string_view key)
+{
+ mElements.erase(mElements.find(key));
+}
diff --git a/app/source/Cplt/Model/Workflow/Values/Dictionary.hpp b/app/source/Cplt/Model/Workflow/Values/Dictionary.hpp
new file mode 100644
index 0000000..6eff308
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/Dictionary.hpp
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Value.hpp>
+
+#include <tsl/array_map.h>
+#include <memory>
+#include <string>
+#include <string_view>
+
+class DictionaryValue : public BaseValue
+{
+private:
+ tsl::array_map<char, std::unique_ptr<BaseValue>> mElements;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ DictionaryValue();
+
+ int GetCount() const;
+ BaseValue* Find(std::string_view key);
+
+ BaseValue* Insert(std::string_view key, std::unique_ptr<BaseValue>& value);
+ BaseValue& InsertOrReplace(std::string_view key, std::unique_ptr<BaseValue> value);
+ void Remove(std::string_view key);
+};
diff --git a/app/source/Cplt/Model/Workflow/Values/List.cpp b/app/source/Cplt/Model/Workflow/Values/List.cpp
new file mode 100644
index 0000000..9fd6bfd
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/List.cpp
@@ -0,0 +1,100 @@
+#include "List.hpp"
+
+#include <utility>
+
+BaseValue* ListValue::Iterator::operator*() const
+{
+ return mIter->get();
+}
+
+BaseValue* ListValue::Iterator::operator->() const
+{
+ return mIter->get();
+}
+
+ListValue::Iterator& ListValue::Iterator::operator++()
+{
+ ++mIter;
+ return *this;
+}
+
+ListValue::Iterator ListValue::Iterator::operator++(int) const
+{
+ return Iterator(mIter + 1);
+}
+
+ListValue::Iterator& ListValue::Iterator::operator--()
+{
+ --mIter;
+ return *this;
+}
+
+ListValue::Iterator ListValue::Iterator::operator--(int) const
+{
+ return Iterator(mIter - 1);
+}
+
+bool operator==(const ListValue::Iterator& a, const ListValue::Iterator& b)
+{
+ return a.mIter == b.mIter;
+}
+
+ListValue::Iterator::Iterator(decltype(mIter) iter)
+ : mIter{ iter }
+{
+}
+
+bool ListValue::IsInstance(const BaseValue* value)
+{
+ return value->GetKind() == KD_List;
+}
+
+ListValue::ListValue()
+ : BaseValue(KD_List)
+{
+}
+
+int ListValue::GetCount() const
+{
+ return mElements.size();
+}
+
+BaseValue* ListValue::GetElement(int i) const
+{
+ return mElements[i].get();
+}
+
+void ListValue::Append(std::unique_ptr<BaseValue> element)
+{
+ mElements.push_back(std::move(element));
+}
+
+void ListValue::Insert(int i, std::unique_ptr<BaseValue> element)
+{
+ mElements.insert(mElements.begin() + i, std::move(element));
+}
+
+void ListValue::Insert(Iterator iter, std::unique_ptr<BaseValue> element)
+{
+ mElements.insert(iter.mIter, std::move(element));
+}
+
+void ListValue::Remove(int i)
+{
+ mElements.erase(mElements.begin() + i);
+}
+
+void ListValue::Remove(Iterator iter)
+{
+ mElements.erase(iter.mIter);
+}
+
+ListValue::Iterator ListValue::begin()
+{
+ return Iterator(mElements.begin());
+}
+
+ListValue::Iterator ListValue::end()
+{
+ return Iterator(mElements.end());
+}
diff --git a/app/source/Cplt/Model/Workflow/Values/List.hpp b/app/source/Cplt/Model/Workflow/Values/List.hpp
new file mode 100644
index 0000000..cc8e061
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/List.hpp
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Value.hpp>
+
+#include <memory>
+#include <vector>
+
+class ListValue : public BaseValue
+{
+public:
+ class Iterator
+ {
+ private:
+ std::vector<std::unique_ptr<BaseValue>>::iterator mIter;
+
+ public:
+ BaseValue* operator*() const;
+ BaseValue* operator->() const;
+
+ Iterator& operator++();
+ Iterator operator++(int) const;
+ Iterator& operator--();
+ Iterator operator--(int) const;
+
+ friend bool operator==(const Iterator& a, const Iterator& b);
+
+ private:
+ friend class ListValue;
+ Iterator(decltype(mIter) iter);
+ };
+
+private:
+ std::vector<std::unique_ptr<BaseValue>> mElements;
+
+public:
+ static bool IsInstance(const BaseValue* value);
+ ListValue();
+
+ int GetCount() const;
+ BaseValue* GetElement(int i) const;
+
+ void Append(std::unique_ptr<BaseValue> element);
+ void Insert(int i, std::unique_ptr<BaseValue> element);
+ void Insert(Iterator iter, std::unique_ptr<BaseValue> element);
+ void Remove(int i);
+ void Remove(Iterator iter);
+
+ Iterator begin();
+ Iterator end();
+};
diff --git a/app/source/Cplt/Model/Workflow/Values/fwd.hpp b/app/source/Cplt/Model/Workflow/Values/fwd.hpp
new file mode 100644
index 0000000..51a04e9
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Values/fwd.hpp
@@ -0,0 +1,17 @@
+#pragma once
+
+// Basic.hpp
+class NumericValue;
+class TextValue;
+class DateTimeValue;
+
+// Database.hpp
+class DatabaseRowIdValue;
+class SaleDatabaseRowValue;
+class PurchaseDatabaseRowValue;
+
+// Dictionary.hpp
+class DictionaryValue;
+
+// List.hpp
+class ListValue;
diff --git a/app/source/Cplt/Model/Workflow/Workflow.hpp b/app/source/Cplt/Model/Workflow/Workflow.hpp
new file mode 100644
index 0000000..e075e3c
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Workflow.hpp
@@ -0,0 +1,316 @@
+#pragma once
+
+#include <Cplt/Model/Assets.hpp>
+#include <Cplt/Model/Workflow/Value.hpp>
+#include <Cplt/Utils/Vector.hpp>
+#include <Cplt/fwd.hpp>
+
+#include <imgui_node_editor.h>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <functional>
+#include <iosfwd>
+#include <limits>
+#include <memory>
+#include <span>
+#include <string>
+#include <variant>
+#include <vector>
+
+namespace ImNodes = ax::NodeEditor;
+
+class WorkflowConnection
+{
+public:
+ static constexpr auto kInvalidId = std::numeric_limits<uint32_t>::max();
+
+ uint32_t Id;
+ uint32_t SourceNode;
+ uint32_t SourcePin;
+ uint32_t DestinationNode;
+ uint32_t DestinationPin;
+
+public:
+ WorkflowConnection();
+
+ bool IsValid() const;
+
+ /// Used for `LinkId` when interfacing with imgui node editor. Runtime only (not saved to disk and generated when loading).
+ ImNodes::LinkId GetLinkId() const;
+
+ void DrawDebugInfo() const;
+ void ReadFrom(std::istream& stream);
+ void WriteTo(std::ostream& stream) const;
+};
+
+class WorkflowNode
+{
+public:
+ static constexpr auto kInvalidId = std::numeric_limits<uint32_t>::max();
+ static constexpr auto kInvalidPinId = std::numeric_limits<uint32_t>::max();
+
+ enum Type
+ {
+ InputType,
+ TransformType,
+ OutputType,
+ };
+
+ enum Kind
+ {
+ KD_NumericAddition,
+ KD_NumericSubtraction,
+ KD_NumericMultiplication,
+ KD_NumericDivision,
+ KD_NumericExpression,
+ KD_TextFormatting,
+ KD_DocumentTemplateExpansion,
+ KD_FormInput,
+ KD_DatabaseRowsInput,
+
+ InvalidKind,
+ KindCount = InvalidKind,
+ };
+
+ enum Category
+ {
+ CG_Numeric,
+ CG_Text,
+ CG_Document,
+ CG_UserInput,
+ CG_SystemInput,
+ CG_Output,
+
+ InvalidCategory,
+ CategoryCount = InvalidCategory,
+ };
+
+ struct InputPin
+ {
+ uint32_t Connection = WorkflowConnection::kInvalidId;
+ BaseValue::Kind MatchingType = BaseValue::InvalidKind;
+ bool ConnectionToConst = false;
+
+ /// A constant connection connects from a user-specified constant value, feeding to a valid \c DestinationNode and \c DestinationPin (i.e. input pins).
+ bool IsConstantConnection() const;
+ bool IsConnected() const;
+ BaseValue::Kind GetMatchingType() const;
+ };
+
+ struct OutputPin
+ {
+ uint32_t Connection = WorkflowConnection::kInvalidId;
+ BaseValue::Kind MatchingType = BaseValue::InvalidKind;
+
+ bool IsConnected() const;
+ BaseValue::Kind GetMatchingType() const;
+ };
+
+protected:
+ friend class Workflow;
+ friend class WorkflowEvaluationContext;
+
+ Workflow* mWorkflow;
+ std::vector<InputPin> mInputs;
+ std::vector<OutputPin> mOutputs;
+ Vec2i mPosition;
+ uint32_t mId;
+ Kind mKind;
+ int mDepth;
+ bool mLocked;
+
+public:
+ static const char* FormatKind(Kind kind);
+ static const char* FormatCategory(Category category);
+ static const char* FormatType(Type type);
+ static Category QueryCategory(Kind kind);
+ static std::span<const Kind> QueryCategoryMembers(Category category);
+ static std::unique_ptr<WorkflowNode> CreateByKind(Kind kind);
+
+ static bool IsInstance(const WorkflowNode* node);
+
+ WorkflowNode(Kind kind, bool locked);
+ virtual ~WorkflowNode() = default;
+
+ WorkflowNode(const WorkflowNode&) = delete;
+ WorkflowNode& operator=(const WorkflowNode&) = delete;
+ WorkflowNode(WorkflowNode&&) = default;
+ WorkflowNode& operator=(WorkflowNode&&) = default;
+
+ void SetPosition(const Vec2i& position);
+ Vec2i GetPosition() const;
+
+ uint32_t GetId() const;
+ /// Used for `NodeId` when interfacing with imgui node editor. Runtime only (not saved to disk and generated when loading).
+ ImNodes::NodeId GetNodeId() const;
+ Kind GetKind() const;
+ int GetDepth() const;
+ bool IsLocked() const;
+
+ Type GetType() const;
+ bool IsInputNode() const;
+ bool IsOutputNode() const;
+
+ void ConnectInput(uint32_t pinId, WorkflowNode& srcNode, uint32_t srcPinId);
+ void DisconnectInput(uint32_t pinId);
+
+ void DrawInputPinDebugInfo(uint32_t pinId) const;
+ const InputPin& GetInputPin(uint32_t pinId) const;
+ ImNodes::PinId GetInputPinUniqueId(uint32_t pinId) const;
+
+ void ConnectOutput(uint32_t pinId, WorkflowNode& dstNode, uint32_t dstPinId);
+ void DisconnectOutput(uint32_t pinId);
+
+ void DrawOutputPinDebugInfo(uint32_t pinId) const;
+ const OutputPin& GetOutputPin(uint32_t pinId) const;
+ ImNodes::PinId GetOutputPinUniqueId(uint32_t pinId) const;
+
+ virtual void Evaluate(WorkflowEvaluationContext& ctx) = 0;
+
+ void Draw();
+ virtual void DrawExtra() {}
+
+ void DrawDebugInfo() const;
+ virtual void DrawExtraDebugInfo() const {}
+
+ virtual void ReadFrom(std::istream& istream);
+ virtual void WriteTo(std::ostream& ostream);
+
+protected:
+ InputPin& InsertInputPin(int atIdx);
+ void RemoveInputPin(int pin);
+ void SwapInputPin(int a, int b);
+ OutputPin& InsertOutputPin(int atIdx);
+ void RemoveOutputPin(int pin);
+ void SwapOutputPin(int a, int b);
+
+ /* For \c Workflow to invoke, override by implementations */
+
+ void OnAttach(Workflow& workflow, uint32_t newId);
+ void OnDetach();
+};
+
+class Workflow : public Asset
+{
+ friend class WorkflowNode;
+ friend class WorkflowEvaluationContext;
+ class Private;
+
+public:
+ using CategoryType = WorkflowAssetList;
+ static constinit const WorkflowAssetList Category;
+
+private:
+ std::vector<WorkflowConnection> mConnections;
+ std::vector<std::unique_ptr<WorkflowNode>> mNodes;
+ std::vector<std::unique_ptr<BaseValue>> mConstants;
+ std::vector<std::vector<uint32_t>> mDepthGroups;
+ int mConnectionCount;
+ int mNodeCount;
+ int mConstantCount;
+ bool mDepthsDirty = true;
+
+public:
+ /* Graph access */
+
+ const std::vector<WorkflowConnection>& GetConnections() const;
+ std::vector<WorkflowConnection>& GetConnections();
+ const std::vector<std::unique_ptr<WorkflowNode>>& GetNodes() const;
+ std::vector<std::unique_ptr<WorkflowNode>>& GetNodes();
+ const std::vector<std::unique_ptr<BaseValue>>& GetConstants() const;
+ std::vector<std::unique_ptr<BaseValue>>& GetConstants();
+
+ WorkflowConnection* GetConnectionById(uint32_t id);
+ WorkflowConnection* GetConnectionByLinkId(ImNodes::LinkId linkId);
+ WorkflowNode* GetNodeById(uint32_t id);
+ WorkflowNode* GetNodeByNodeId(ImNodes::NodeId nodeId);
+ BaseValue* GetConstantById(uint32_t id);
+
+ struct GlobalPinId
+ {
+ WorkflowNode* Node;
+ uint32_t PinId;
+ /// true => input pin
+ /// false => output pin
+ bool IsOutput;
+ };
+
+ /// `pinId` should be the `UniqueId` of a pin from a node that's within this workflow.
+ GlobalPinId DisassembleGlobalPinId(ImNodes::PinId id);
+ ImNodes::PinId FabricateGlobalPinId(const WorkflowNode& node, uint32_t pinId, bool isOutput) const;
+
+ const std::vector<std::vector<uint32_t>>& GetDepthGroups() const;
+ bool DoesDepthNeedsUpdate() const;
+
+ /* Graph mutation */
+
+ void AddNode(std::unique_ptr<WorkflowNode> step);
+ void RemoveNode(uint32_t id);
+
+ void RemoveConnection(uint32_t id);
+
+ bool Connect(WorkflowNode& sourceNode, uint32_t sourcePin, WorkflowNode& destinationNode, uint32_t destinationPin);
+ bool DisconnectBySource(WorkflowNode& sourceNode, uint32_t sourcePin);
+ bool DisconnectByDestination(WorkflowNode& destinationNode, uint32_t destinationPin);
+
+ /* Graph rebuild */
+
+ enum GraphUpdateResult
+ {
+ /// Successfully rebuilt graph dependent data.
+ /// Details: nothing is written.
+ GUR_Success,
+ /// Nothing has changed since last time UpdateGraph() was called.
+ /// Details: nothing is written.
+ GUR_NoWorkToDo,
+ /// Details: list of nodes is written.
+ GUR_UnsatisfiedDependencies,
+ /// Details: list of nodes is written.
+ GUR_UnreachableNodes,
+ };
+
+ using GraphUpdateDetails = std::variant<
+ // Case: nothing
+ std::monostate,
+ // Case: list of nodes (ids)
+ std::vector<uint32_t>>;
+
+ GraphUpdateResult UpdateGraph(GraphUpdateDetails* details = nullptr);
+
+ /* Serialization */
+
+ void ReadFromDataStream(InputDataStream& stream);
+ void WriteToDataStream(OutputDataStream& stream) const;
+
+private:
+ std::pair<WorkflowConnection&, uint32_t> AllocWorkflowConnection();
+ std::pair<std::unique_ptr<WorkflowNode>&, uint32_t> AllocWorkflowStep();
+};
+
+class WorkflowAssetList final : public AssetListTyped<Workflow>
+{
+private:
+ // AC = Asset Creator
+ std::string mACNewName;
+ NameSelectionError mACNewNameError = NameSelectionError::Empty;
+
+public:
+ // Inherit constructors
+ using AssetListTyped::AssetListTyped;
+
+protected:
+ void DiscoverFiles(const std::function<void(SavedAsset)>& callback) const override;
+
+ std::string RetrieveNameFromFile(const std::filesystem::path& file) const override;
+ uuids::uuid RetrieveUuidFromFile(const std::filesystem::path& file) const override;
+ std::filesystem::path RetrievePathFromAsset(const SavedAsset& asset) const override;
+
+ bool SaveInstance(const SavedAsset& assetInfo, const Asset* asset) const override;
+ Workflow* LoadInstance(const SavedAsset& assetInfo) const override;
+ Workflow* CreateInstance(const SavedAsset& assetInfo) const override;
+ bool RenameInstanceOnDisk(const SavedAsset& assetInfo, std::string_view oldName) const override;
+
+ void DisplayAssetCreator(ListState& state) override;
+ void DisplayDetailsTable(ListState& state) const override;
+};
diff --git a/app/source/Cplt/Model/Workflow/Workflow_Main.cpp b/app/source/Cplt/Model/Workflow/Workflow_Main.cpp
new file mode 100644
index 0000000..0f35b32
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Workflow_Main.cpp
@@ -0,0 +1,846 @@
+#include "Workflow.hpp"
+
+#include <Cplt/Model/GlobalStates.hpp>
+#include <Cplt/Model/Project.hpp>
+#include <Cplt/UI/UI.hpp>
+#include <Cplt/Utils/I18n.hpp>
+#include <Cplt/Utils/IO/Archive.hpp>
+#include <Cplt/Utils/UUID.hpp>
+
+#include <imgui.h>
+#include <imgui_node_editor.h>
+#include <imgui_stdlib.h>
+#include <tsl/robin_set.h>
+#include <algorithm>
+#include <cassert>
+#include <cstdint>
+#include <fstream>
+#include <iostream>
+#include <queue>
+#include <utility>
+
+using namespace std::literals::string_view_literals;
+namespace fs = std::filesystem;
+namespace ImNodes = ax::NodeEditor;
+
+WorkflowConnection::WorkflowConnection()
+ : Id{ 0 }
+ , SourceNode{ WorkflowNode::kInvalidId }
+ , SourcePin{ WorkflowNode::kInvalidPinId }
+ , DestinationNode{ WorkflowNode::kInvalidId }
+ , DestinationPin{ WorkflowNode::kInvalidPinId }
+{
+}
+
+bool WorkflowConnection::IsValid() const
+{
+ return Id != 0;
+}
+
+ImNodes::LinkId WorkflowConnection::GetLinkId() const
+{
+ // Our id is 0-based (represents an index directly)
+ // but imgui-node-editor uses the value 0 to represent a null id, so we need to offset by 1
+ return Id + 1;
+}
+
+void WorkflowConnection::DrawDebugInfo() const
+{
+ ImGui::Text("Source (node with output pin):");
+ ImGui::Text("{ Node = %u, Pin = %u }", SourceNode, SourcePin);
+ ImGui::Text("Destination (node with input pin):");
+ ImGui::Text("{ Node = %u, Pin = %u }", DestinationNode, DestinationPin);
+}
+
+void WorkflowConnection::ReadFrom(std::istream& stream)
+{
+ stream >> SourceNode >> SourcePin;
+ stream >> DestinationNode >> DestinationPin;
+}
+
+void WorkflowConnection::WriteTo(std::ostream& stream) const
+{
+ stream << SourceNode << SourcePin;
+ stream << DestinationNode << DestinationPin;
+}
+
+bool WorkflowNode::InputPin::IsConstantConnection() const
+{
+ return ConnectionToConst && IsConnected();
+}
+
+bool WorkflowNode::InputPin::IsConnected() const
+{
+ return Connection != WorkflowConnection::kInvalidId;
+}
+
+BaseValue::Kind WorkflowNode::InputPin::GetMatchingType() const
+{
+ return MatchingType;
+}
+
+bool WorkflowNode::OutputPin::IsConnected() const
+{
+ return Connection != WorkflowConnection::kInvalidId;
+}
+
+BaseValue::Kind WorkflowNode::OutputPin::GetMatchingType() const
+{
+ return MatchingType;
+}
+
+WorkflowNode::WorkflowNode(Kind kind, bool locked)
+ : mKind{ kind }
+ , mDepth{ -1 }
+ , mLocked(locked)
+{
+}
+
+Vec2i WorkflowNode::GetPosition() const
+{
+ return mPosition;
+}
+
+void WorkflowNode::SetPosition(const Vec2i& position)
+{
+ mPosition = position;
+}
+
+uint32_t WorkflowNode::GetId() const
+{
+ return mId;
+}
+
+ImNodes::NodeId WorkflowNode::GetNodeId() const
+{
+ // See WorkflowConnection::GetLinkId for the rationale
+ return mId + 1;
+}
+
+WorkflowNode::Kind WorkflowNode::GetKind() const
+{
+ return mKind;
+}
+
+int WorkflowNode::GetDepth() const
+{
+ return mDepth;
+}
+
+bool WorkflowNode::IsLocked() const
+{
+ return mLocked;
+}
+
+WorkflowNode::Type WorkflowNode::GetType() const
+{
+ if (IsInputNode()) {
+ return InputType;
+ } else if (IsOutputNode()) {
+ return OutputType;
+ } else {
+ return TransformType;
+ }
+}
+
+bool WorkflowNode::IsInputNode() const
+{
+ return mInputs.size() == 0;
+}
+
+bool WorkflowNode::IsOutputNode() const
+{
+ return mOutputs.size() == 0;
+}
+
+void WorkflowNode::ConnectInput(uint32_t pinId, WorkflowNode& srcNode, uint32_t srcPinId)
+{
+ mWorkflow->Connect(*this, pinId, srcNode, srcPinId);
+}
+
+void WorkflowNode::DisconnectInput(uint32_t pinId)
+{
+ mWorkflow->DisconnectByDestination(*this, pinId);
+}
+
+void WorkflowNode::DrawInputPinDebugInfo(uint32_t pinId) const
+{
+ ImGui::Text("Node ID: %d", mId);
+ ImGui::Text("Pin ID: (input) %d", pinId);
+}
+
+const WorkflowNode::InputPin& WorkflowNode::GetInputPin(uint32_t pinId) const
+{
+ return mInputs[pinId];
+}
+
+ImNodes::PinId WorkflowNode::GetInputPinUniqueId(uint32_t pinId) const
+{
+ return mWorkflow->FabricateGlobalPinId(*this, pinId, false);
+}
+
+void WorkflowNode::ConnectOutput(uint32_t pinId, WorkflowNode& dstNode, uint32_t dstPinId)
+{
+ mWorkflow->Connect(dstNode, dstPinId, *this, pinId);
+}
+
+void WorkflowNode::DisconnectOutput(uint32_t pinId)
+{
+ mWorkflow->DisconnectBySource(*this, pinId);
+}
+
+void WorkflowNode::DrawOutputPinDebugInfo(uint32_t pinId) const
+{
+ ImGui::Text("Node ID: %d", mId);
+ ImGui::Text("Pin ID: (output) %d", pinId);
+}
+
+const WorkflowNode::OutputPin& WorkflowNode::GetOutputPin(uint32_t pinId) const
+{
+ return mOutputs[pinId];
+}
+
+ImNodes::PinId WorkflowNode::GetOutputPinUniqueId(uint32_t pinId) const
+{
+ return mWorkflow->FabricateGlobalPinId(*this, pinId, true);
+}
+
+void WorkflowNode::Draw()
+{
+ for (uint32_t i = 0; i < mInputs.size(); ++i) {
+ auto& pin = mInputs[i];
+ auto& typeInfo = BaseValue::QueryInfo(pin.MatchingType);
+ ImNodes::BeginPin(GetInputPinUniqueId(i), ImNodes::PinKind::Input);
+ // TODO
+ ImNodes::EndPin();
+ }
+ for (uint32_t i = 0; i < mOutputs.size(); ++i) {
+ auto& pin = mOutputs[i];
+ auto& typeInfo = BaseValue::QueryInfo(pin.MatchingType);
+ ImNodes::BeginPin(GetOutputPinUniqueId(i), ImNodes::PinKind::Output);
+ // TODO
+ ImNodes::EndPin();
+ }
+}
+
+void WorkflowNode::DrawDebugInfo() const
+{
+ ImGui::Text("Node kind: %s", FormatKind(mKind));
+ ImGui::Text("Node type: %s", FormatType(GetType()));
+ ImGui::Text("Node ID: %u", mId);
+ ImGui::Text("Depth: %d", mDepth);
+ DrawExtraDebugInfo();
+}
+
+void WorkflowNode::ReadFrom(std::istream& stream)
+{
+ stream >> mId;
+ stream >> mPosition.x >> mPosition.y;
+}
+
+void WorkflowNode::WriteTo(std::ostream& stream)
+{
+ stream << mId;
+ stream << mPosition.x << mPosition.y;
+}
+
+WorkflowNode::InputPin& WorkflowNode::InsertInputPin(int atIdx)
+{
+ assert(atIdx >= 0 && atIdx < mInputs.size());
+
+ mInputs.push_back(InputPin{});
+ for (int i = (int)mInputs.size() - 1, end = atIdx + 1; i >= end; --i) {
+ SwapInputPin(i, i + 1);
+ }
+
+ return mInputs[atIdx];
+}
+
+void WorkflowNode::RemoveInputPin(int pin)
+{
+ DisconnectInput(pin);
+ for (int i = 0, end = (int)mInputs.size() - 1; i < end; ++i) {
+ SwapInputPin(i, i + 1);
+ }
+ mInputs.resize(mInputs.size() - 1);
+}
+
+void WorkflowNode::SwapInputPin(int a, int b)
+{
+ auto& pinA = mInputs[a];
+ auto& pinB = mInputs[b];
+
+ if (mWorkflow) {
+ if (pinA.IsConnected() && !pinA.IsConstantConnection()) {
+ auto& conn = *mWorkflow->GetConnectionById(pinA.Connection);
+ conn.DestinationPin = b;
+ }
+ if (pinB.IsConnected() && !pinB.IsConstantConnection()) {
+ auto& conn = *mWorkflow->GetConnectionById(pinB.Connection);
+ conn.DestinationPin = a;
+ }
+ }
+
+ std::swap(pinA, pinB);
+}
+
+WorkflowNode::OutputPin& WorkflowNode::InsertOutputPin(int atIdx)
+{
+ assert(atIdx >= 0 && atIdx < mOutputs.size());
+
+ mOutputs.push_back(OutputPin{});
+ for (int i = (int)mOutputs.size() - 1, end = atIdx + 1; i >= end; --i) {
+ SwapOutputPin(i, i + 1);
+ }
+
+ return mOutputs[atIdx];
+}
+
+void WorkflowNode::RemoveOutputPin(int pin)
+{
+ DisconnectOutput(pin);
+ for (int i = 0, end = (int)mOutputs.size() - 1; i < end; ++i) {
+ SwapInputPin(i, i + 1);
+ }
+ mOutputs.resize(mOutputs.size() - 1);
+}
+
+void WorkflowNode::SwapOutputPin(int a, int b)
+{
+ auto& pinA = mOutputs[a];
+ auto& pinB = mOutputs[b];
+
+ if (mWorkflow) {
+ if (pinA.IsConnected()) {
+ auto& conn = *mWorkflow->GetConnectionById(pinA.Connection);
+ conn.SourcePin = b;
+ }
+ if (pinB.IsConnected()) {
+ auto& conn = *mWorkflow->GetConnectionById(pinB.Connection);
+ conn.SourcePin = a;
+ }
+ }
+
+ std::swap(pinA, pinB);
+}
+
+void WorkflowNode::OnAttach(Workflow& workflow, uint32_t newId)
+{
+}
+
+void WorkflowNode::OnDetach()
+{
+}
+
+const std::vector<WorkflowConnection>& Workflow::GetConnections() const
+{
+ return mConnections;
+}
+
+std::vector<WorkflowConnection>& Workflow::GetConnections()
+{
+ return mConnections;
+}
+
+const std::vector<std::unique_ptr<WorkflowNode>>& Workflow::GetNodes() const
+{
+ return mNodes;
+}
+
+std::vector<std::unique_ptr<WorkflowNode>>& Workflow::GetNodes()
+{
+ return mNodes;
+}
+
+const std::vector<std::unique_ptr<BaseValue>>& Workflow::GetConstants() const
+{
+ return mConstants;
+}
+
+std::vector<std::unique_ptr<BaseValue>>& Workflow::GetConstants()
+{
+ return mConstants;
+}
+
+WorkflowConnection* Workflow::GetConnectionById(uint32_t id)
+{
+ return &mConnections[id];
+}
+
+WorkflowConnection* Workflow::GetConnectionByLinkId(ImNodes::LinkId id)
+{
+ return &mConnections[(uint32_t)(size_t)id - 1];
+}
+
+WorkflowNode* Workflow::GetNodeById(uint32_t id)
+{
+ return mNodes[id].get();
+}
+
+WorkflowNode* Workflow::GetNodeByNodeId(ImNodes::NodeId id)
+{
+ return mNodes[(uint32_t)(size_t)id - 1].get();
+}
+
+BaseValue* Workflow::GetConstantById(uint32_t id)
+{
+ return mConstants[id].get();
+}
+
+Workflow::GlobalPinId Workflow::DisassembleGlobalPinId(ImNodes::PinId pinId)
+{
+ // imgui-node-editor requires all pins to have a global, unique id
+ // but in our model the pin are typed (input vs output) and associated with a node: there is no built-in global id
+ // Therefore we encode one ourselves
+
+ // Global pin id format
+ // nnnnnnnn nnnnnnnn nnnnnnnn nnnnnnnn Tppppppp ppppppppp pppppppp pppppppp
+ // <------- (32 bits) node id -------> ^<------ (31 bits) pin id -------->
+ // | (1 bit) input (false) vs output (true)
+
+ // 1 is added to pin id to prevent the 0th node's 0th input pin resulting in a 0 global pin id
+ // (this is problematic because imgui-node-editor use 0 to represent null)
+
+ auto id = static_cast<uint64_t>(pinId);
+ GlobalPinId result;
+
+ result.Node = mNodes[id >> 32].get();
+ result.PinId = (uint32_t)(id & 0x000000001FFFFFFF) - 1;
+ result.IsOutput = id >> 31;
+
+ return result;
+}
+
+ImNodes::PinId Workflow::FabricateGlobalPinId(const WorkflowNode& node, uint32_t pinId, bool isOutput) const
+{
+ // See this->DisassembleGlobalPinId for format details and rationale
+
+ uint64_t id = 0;
+ id |= ((uint64_t)node.GetId() << 32);
+ id |= (isOutput << 31);
+ id |= ((pinId + 1) & 0x1FFFFFFF);
+
+ return id;
+}
+
+const std::vector<std::vector<uint32_t>>& Workflow::GetDepthGroups() const
+{
+ return mDepthGroups;
+}
+
+bool Workflow::DoesDepthNeedsUpdate() const
+{
+ return mDepthsDirty;
+}
+
+void Workflow::AddNode(std::unique_ptr<WorkflowNode> step)
+{
+ auto [storage, id] = AllocWorkflowStep();
+ storage = std::move(step);
+ storage->OnAttach(*this, id);
+ storage->mWorkflow = this;
+ storage->mId = id;
+}
+
+void Workflow::RemoveNode(uint32_t id)
+{
+ auto& step = mNodes[id];
+ if (step == nullptr) return;
+
+ step->OnDetach();
+ step->mWorkflow = nullptr;
+ step->mId = WorkflowNode::kInvalidId;
+}
+
+void Workflow::RemoveConnection(uint32_t id)
+{
+ auto& conn = mConnections[id];
+ if (!conn.IsValid()) return;
+
+ mNodes[conn.SourceNode]->mInputs[conn.SourcePin].Connection = WorkflowNode::kInvalidId;
+ mNodes[conn.DestinationNode]->mInputs[conn.DestinationPin].Connection = WorkflowNode::kInvalidId;
+
+ conn = {};
+ mDepthsDirty = true;
+}
+
+bool Workflow::Connect(WorkflowNode& sourceNode, uint32_t sourcePin, WorkflowNode& destinationNode, uint32_t destinationPin)
+{
+ auto& src = sourceNode.mOutputs[sourcePin];
+ auto& dst = destinationNode.mInputs[destinationPin];
+
+ // TODO report error to user?
+ if (src.GetMatchingType() != dst.GetMatchingType()) {
+ return false;
+ }
+
+ if (src.IsConnected()) {
+ DisconnectBySource(sourceNode, sourcePin);
+ }
+
+ auto [conn, id] = AllocWorkflowConnection();
+ conn.SourceNode = sourceNode.GetId();
+ conn.SourcePin = sourcePin;
+ conn.DestinationNode = destinationNode.GetId();
+ conn.DestinationPin = destinationPin;
+
+ src.Connection = id;
+ dst.Connection = id;
+
+ mDepthsDirty = true;
+ return true;
+}
+
+bool Workflow::DisconnectBySource(WorkflowNode& sourceNode, uint32_t sourcePin)
+{
+ auto& sn = sourceNode.mOutputs[sourcePin];
+ if (!sn.IsConnected()) return false;
+
+ auto& conn = mConnections[sn.Connection];
+ auto& dn = mNodes[conn.DestinationNode]->mInputs[conn.DestinationPin];
+
+ sn.Connection = WorkflowConnection::kInvalidId;
+ dn.Connection = WorkflowConnection::kInvalidId;
+ conn = {};
+
+ mDepthsDirty = true;
+ return true;
+}
+
+bool Workflow::DisconnectByDestination(WorkflowNode& destinationNode, uint32_t destinationPin)
+{
+ auto& dn = destinationNode.mOutputs[destinationPin];
+ if (!dn.IsConnected()) return false;
+
+ auto& conn = mConnections[dn.Connection];
+ auto& sn = mNodes[conn.SourceNode]->mInputs[conn.SourcePin];
+
+ sn.Connection = WorkflowConnection::kInvalidId;
+ dn.Connection = WorkflowConnection::kInvalidId;
+ conn = {};
+
+ mDepthsDirty = true;
+ return true;
+}
+
+Workflow::GraphUpdateResult Workflow::UpdateGraph(GraphUpdateDetails* details)
+{
+ if (!mDepthsDirty) {
+ return GUR_NoWorkToDo;
+ }
+
+ // Terminology:
+ // - Dependency = nodes its input pins are connected to
+ // - Dependents = nodes its output pins are connected to
+
+ struct WorkingNode
+ {
+ // The max depth out of all dependency nodes, maintained during the traversal and committed as the actual depth
+ // when all dependencies of this node has been resolved. Add 1 to get the depth that will be assigned to the node.
+ int MaximumDepth = 0;
+ int FulfilledInputCount = 0;
+ };
+
+ std::vector<WorkingNode> workingNodes;
+ std::queue<uint32_t> q;
+
+ // Check if all dependencies of this node is satisfied
+ auto CheckNodeDependencies = [&](WorkflowNode& node) -> bool {
+ for (auto& pin : node.mInputs) {
+ if (!pin.IsConnected()) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ workingNodes.reserve(mNodes.size());
+ {
+ std::vector<uint32_t> unsatisfiedNodes;
+ for (uint32_t i = 0; i < mNodes.size(); ++i) {
+ auto& node = mNodes[i];
+ workingNodes.push_back(WorkingNode{});
+
+ if (!node) continue;
+
+ if (!CheckNodeDependencies(*node)) {
+ unsatisfiedNodes.push_back(i);
+ }
+
+ node->mDepth = -1;
+
+ // Start traversing with the input nodes
+ if (node->GetType() == WorkflowNode::InputType) {
+ q.push(i);
+ }
+ }
+
+ if (!unsatisfiedNodes.empty()) {
+ if (details) {
+ details->emplace<decltype(unsatisfiedNodes)>(std::move(unsatisfiedNodes));
+ }
+ return GUR_UnsatisfiedDependencies;
+ }
+ }
+
+ auto ProcessNode = [&](WorkflowNode& node) -> void {
+ for (auto& pin : node.mOutputs) {
+ if (!pin.IsConnected()) continue;
+ auto& conn = mConnections[pin.Connection];
+
+ auto& wn = workingNodes[conn.DestinationNode];
+ auto& n = *mNodes[conn.DestinationPin].get();
+
+ wn.FulfilledInputCount++;
+ wn.MaximumDepth = std::max(node.mDepth, wn.MaximumDepth);
+
+ // Node's dependency is fulfilled, we can process its dependents next
+ // We use >= here because for a many-to-one pin, the dependency is an "or" relation ship, i.e. any of the nodes firing before this will fulfill the requirement
+ if (n.mInputs.size() >= wn.FulfilledInputCount) {
+ n.mDepth = wn.MaximumDepth + 1;
+ }
+ }
+ };
+
+ int processedNodes = 0;
+ while (!q.empty()) {
+ auto& wn = workingNodes[q.front()];
+ auto& n = *mNodes[q.front()];
+ q.pop();
+ processedNodes++;
+
+ ProcessNode(n);
+ }
+
+ if (processedNodes < mNodes.size()) {
+ // There is unreachable nodes, collect them and report to the caller
+
+ std::vector<uint32_t> unreachableNodes;
+ for (uint32_t i = 0; i < mNodes.size(); ++i) {
+ auto& wn = workingNodes[i];
+ auto& n = *mNodes[i];
+
+ // This is a reachable node
+ if (n.mDepth != -1) continue;
+
+ unreachableNodes.push_back(i);
+ }
+
+ if (details) {
+ details->emplace<decltype(unreachableNodes)>(std::move(unreachableNodes));
+ }
+ return GUR_UnreachableNodes;
+ }
+
+ return GUR_Success;
+}
+
+class Workflow::Private
+{
+public:
+ template <class TSelf, class TProxy>
+ static void OperateStream(TSelf& self, TProxy& proxy)
+ {
+ // TODO
+ }
+};
+
+void Workflow::ReadFromDataStream(InputDataStream& stream)
+{
+ Private::OperateStream(*this, stream);
+}
+
+void Workflow::WriteToDataStream(OutputDataStream& stream) const
+{
+ Private::OperateStream(*this, stream);
+}
+
+std::pair<WorkflowConnection&, uint32_t> Workflow::AllocWorkflowConnection()
+{
+ for (size_t idx = 0; idx < mConnections.size(); ++idx) {
+ auto& elm = mConnections[idx];
+ if (!elm.IsValid()) {
+ return { elm, (uint32_t)idx };
+ }
+ }
+
+ auto id = (uint32_t)mConnections.size();
+ auto& conn = mConnections.emplace_back(WorkflowConnection{});
+ conn.Id = id;
+
+ return { conn, id };
+}
+
+std::pair<std::unique_ptr<WorkflowNode>&, uint32_t> Workflow::AllocWorkflowStep()
+{
+ for (size_t idx = 0; idx < mNodes.size(); ++idx) {
+ auto& elm = mNodes[idx];
+ if (elm == nullptr) {
+ return { elm, (uint32_t)idx };
+ }
+ }
+
+ auto id = (uint32_t)mNodes.size();
+ auto& node = mNodes.emplace_back(std::unique_ptr<WorkflowNode>());
+
+ return { node, id };
+}
+
+void WorkflowAssetList::DiscoverFiles(const std::function<void(SavedAsset)>& callback) const
+{
+ auto dir = GetConnectedProject().GetWorkflowsDirectory();
+ DiscoverFilesByExtension(callback, dir, ".cplt-workflow"sv);
+}
+
+std::string WorkflowAssetList::RetrieveNameFromFile(const fs::path& file) const
+{
+ auto res = DataArchive::LoadFile(file);
+ if (!res) return "";
+ auto& stream = res.value();
+
+ SavedAsset assetInfo;
+ stream.ReadObject(assetInfo);
+
+ return assetInfo.Name;
+}
+
+uuids::uuid WorkflowAssetList::RetrieveUuidFromFile(const fs::path& file) const
+{
+ return uuids::uuid::from_string(file.stem().string());
+}
+
+fs::path WorkflowAssetList::RetrievePathFromAsset(const SavedAsset& asset) const
+{
+ auto fileName = uuids::to_string(asset.Uuid);
+ return GetConnectedProject().GetWorkflowPath(fileName);
+}
+
+bool WorkflowAssetList::SaveInstance(const SavedAsset& assetInfo, const Asset* asset) const
+{
+ auto path = RetrievePathFromAsset(assetInfo);
+ auto res = DataArchive::SaveFile(path);
+ if (!res) return false;
+ auto& stream = res.value();
+
+ stream.WriteObject(assetInfo);
+ // This cast is fine: calls to this class will always be wrapped in TypedAssetList<T>, which will ensure `asset` points to some Workflow
+ if (auto workflow = static_cast<const Workflow*>(asset)) { // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)
+ stream.WriteObject(*workflow);
+ }
+
+ return true;
+}
+
+static std::unique_ptr<Workflow> LoadWorkflowFromFile(const fs::path& path)
+{
+ auto res = DataArchive::LoadFile(path);
+ if (!res) return nullptr;
+ auto& stream = res.value();
+
+ // TODO this is currently unused
+ SavedAsset assetInfo;
+ stream.ReadObject(assetInfo);
+
+ auto workflow = std::make_unique<Workflow>();
+ stream.ReadObject(*workflow);
+
+ return workflow;
+}
+
+Workflow* WorkflowAssetList::LoadInstance(const SavedAsset& assetInfo) const
+{
+ return ::LoadWorkflowFromFile(RetrievePathFromAsset(assetInfo)).release();
+}
+
+Workflow* WorkflowAssetList::CreateInstance(const SavedAsset& assetInfo) const
+{
+ return new Workflow();
+}
+
+bool WorkflowAssetList::RenameInstanceOnDisk(const SavedAsset& assetInfo, std::string_view oldName) const
+{
+ auto path = RetrievePathFromAsset(assetInfo);
+
+ auto workflow = ::LoadWorkflowFromFile(path);
+ if (!workflow) return false;
+
+ SaveInstance(assetInfo, workflow.get());
+
+ return true;
+}
+
+void WorkflowAssetList::DisplayAssetCreator(ListState& state)
+{
+ auto ValidateNewName = [&]() -> void {
+ if (mACNewName.empty()) {
+ mACNewNameError = NameSelectionError::Empty;
+ return;
+ }
+
+ if (FindByName(mACNewName)) {
+ mACNewNameError = NameSelectionError::Duplicated;
+ return;
+ }
+
+ mACNewNameError = NameSelectionError::None;
+ };
+
+ auto ShowNewNameErrors = [&]() -> void {
+ switch (mACNewNameError) {
+ case NameSelectionError::None: break;
+ case NameSelectionError::Duplicated:
+ ImGui::ErrorMessage(I18N_TEXT("Duplicate name", L10N_DUPLICATE_NAME_ERROR));
+ break;
+ case NameSelectionError::Empty:
+ ImGui::ErrorMessage(I18N_TEXT("Name cannot be empty", L10N_EMPTY_NAME_ERROR));
+ break;
+ }
+ };
+
+ auto IsInputValid = [&]() -> bool {
+ return mACNewNameError == NameSelectionError::None;
+ };
+
+ auto ResetState = [&]() -> void {
+ mACNewName.clear();
+ ValidateNewName();
+ };
+
+ if (ImGui::InputText(I18N_TEXT("Name", L10N_NAME), &mACNewName)) {
+ ValidateNewName();
+ }
+
+ ShowNewNameErrors();
+
+ if (ImGui::Button(I18N_TEXT("OK", L10N_CONFIRM), !IsInputValid())) {
+ ImGui::CloseCurrentPopup();
+
+ Create(SavedAsset{
+ .Name = mACNewName,
+ });
+ ResetState();
+ }
+ ImGui::SameLine();
+ if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) {
+ ImGui::CloseCurrentPopup();
+ }
+}
+
+void WorkflowAssetList::DisplayDetailsTable(ListState& state) const
+{
+ ImGui::BeginTable("AssetDetailsTable", 1, ImGuiTableFlags_Borders);
+
+ ImGui::TableSetupColumn(I18N_TEXT("Name", L10N_NAME));
+ ImGui::TableHeadersRow();
+
+ for (auto& asset : this->GetAssets()) {
+ ImGui::TableNextRow();
+
+ ImGui::TableNextColumn();
+ if (ImGui::Selectable(asset.Name.c_str(), state.SelectedAsset == &asset, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_DontClosePopups)) {
+ state.SelectedAsset = &asset;
+ }
+ }
+
+ ImGui::EndTable();
+}
diff --git a/app/source/Cplt/Model/Workflow/Workflow_RTTI.cpp b/app/source/Cplt/Model/Workflow/Workflow_RTTI.cpp
new file mode 100644
index 0000000..ee3da28
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/Workflow_RTTI.cpp
@@ -0,0 +1,143 @@
+#include "Workflow.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/Utils/I18n.hpp>
+#include <Cplt/Utils/Macros.hpp>
+
+#include <memory>
+
+const char* WorkflowNode::FormatKind(Kind kind)
+{
+ switch (kind) {
+ case KD_NumericAddition: return I18N_TEXT("Add", L10N_WORKFLOW_ADD);
+ case KD_NumericSubtraction: return I18N_TEXT("Subtract", L10N_WORKFLOW_SUB);
+ case KD_NumericMultiplication: return I18N_TEXT("Multiply", L10N_WORKFLOW_MUL);
+ case KD_NumericDivision: return I18N_TEXT("Divide", L10N_WORKFLOW_DIV);
+ case KD_NumericExpression: return I18N_TEXT("Evaluate expression", L10N_WORKFLOW_EVAL);
+ case KD_TextFormatting: return I18N_TEXT("Format text", L10N_WORKFLOW_FMT);
+ case KD_DocumentTemplateExpansion: return I18N_TEXT("Expand template", L10N_WORKFLOW_INSTANTIATE_TEMPLATE);
+ case KD_FormInput: return I18N_TEXT("Form input", L10N_WORKFLOW_FORM_INPUT);
+ case KD_DatabaseRowsInput: return I18N_TEXT("Database input", L10N_WORKFLOW_DB_INPUT);
+
+ case InvalidKind: break;
+ }
+ return "";
+}
+
+const char* WorkflowNode::FormatCategory(WorkflowNode::Category category)
+{
+ switch (category) {
+ case CG_Numeric: return I18N_TEXT("Numeric", L10N_WORKFLOW_CATEGORY_NUMERIC);
+ case CG_Text: return I18N_TEXT("Text", L10N_WORKFLOW_CATEGORY_TEXT);
+ case CG_Document: return I18N_TEXT("Document", L10N_WORKFLOW_CATEGORY_DOCUMENT);
+ case CG_UserInput: return I18N_TEXT("User input", L10N_WORKFLOW_CATEGORY_USER_INPUT);
+ case CG_SystemInput: return I18N_TEXT("System input", L10N_WORKFLOW_CATEGORY_SYS_INPUT);
+ case CG_Output: return I18N_TEXT("Output", L10N_WORKFLOW_CATEGORY_OUTPUT);
+
+ case InvalidCategory: break;
+ }
+ return "";
+}
+
+const char* WorkflowNode::FormatType(Type type)
+{
+ switch (type) {
+ case InputType: return I18N_TEXT("Input", L10N_WORKFLOW_KIND_INPUT);
+ case TransformType: return I18N_TEXT("Transform", L10N_WORKFLOW_KIND_TRANSFORM);
+ case OutputType: return I18N_TEXT("Output", L10N_WORKFLOW_KIND_OUTPUT);
+ }
+ return "";
+}
+
+WorkflowNode::Category WorkflowNode::QueryCategory(Kind kind)
+{
+ switch (kind) {
+ case KD_NumericAddition:
+ case KD_NumericSubtraction:
+ case KD_NumericMultiplication:
+ case KD_NumericDivision:
+ case KD_NumericExpression:
+ return CG_Numeric;
+ case KD_TextFormatting:
+ return CG_Text;
+ case KD_DocumentTemplateExpansion:
+ return CG_Document;
+ case KD_FormInput:
+ case KD_DatabaseRowsInput:
+ return CG_UserInput;
+
+ case InvalidKind: break;
+ }
+ return InvalidCategory;
+}
+
+std::span<const WorkflowNode::Kind> WorkflowNode::QueryCategoryMembers(Category category)
+{
+ constexpr WorkflowNode::Kind kNumeric[] = {
+ KD_NumericAddition,
+ KD_NumericSubtraction,
+ KD_NumericMultiplication,
+ KD_NumericDivision,
+ KD_NumericExpression,
+ };
+
+ constexpr WorkflowNode::Kind kText[] = {
+ KD_TextFormatting,
+ };
+
+ constexpr WorkflowNode::Kind kDocument[] = {
+ KD_DocumentTemplateExpansion,
+ };
+
+ constexpr WorkflowNode::Kind kUserInput[] = {
+ KD_FormInput,
+ KD_DatabaseRowsInput,
+ };
+
+ // TODO remove invalid kinds after we have nodes of these categories
+ constexpr WorkflowNode::Kind kSystemInput[] = {
+ InvalidKind,
+ };
+
+ constexpr WorkflowNode::Kind kOutput[] = {
+ InvalidKind,
+ };
+
+ switch (category) {
+ case CG_Numeric: return kNumeric;
+ case CG_Text: return kText;
+ case CG_Document: return kDocument;
+ case CG_UserInput: return kUserInput;
+ case CG_SystemInput: return kSystemInput;
+ case CG_Output: return kOutput;
+
+ case InvalidCategory: break;
+ }
+ return {};
+}
+
+std::unique_ptr<WorkflowNode> WorkflowNode::CreateByKind(WorkflowNode::Kind kind)
+{
+ switch (kind) {
+ case KD_NumericAddition: return std::make_unique<NumericOperationNode>(NumericOperationNode::Addition);
+ case KD_NumericSubtraction: return std::make_unique<NumericOperationNode>(NumericOperationNode::Subtraction);
+ case KD_NumericMultiplication: return std::make_unique<NumericOperationNode>(NumericOperationNode::Multiplication);
+ case KD_NumericDivision: return std::make_unique<NumericOperationNode>(NumericOperationNode::Division);
+ case KD_NumericExpression: return std::make_unique<NumericExpressionNode>();
+ case KD_TextFormatting: return std::make_unique<TextFormatterNode>();
+ case KD_DocumentTemplateExpansion: return std::make_unique<DocumentTemplateExpansionNode>();
+ case KD_FormInput: return std::make_unique<FormInputNode>();
+ case KD_DatabaseRowsInput: return std::make_unique<DatabaseRowsInputNode>();
+
+ case InvalidKind: break;
+ }
+ return nullptr;
+}
+
+bool WorkflowNode::IsInstance(const WorkflowNode* node)
+{
+ return true;
+}
diff --git a/app/source/Cplt/Model/Workflow/fwd.hpp b/app/source/Cplt/Model/Workflow/fwd.hpp
new file mode 100644
index 0000000..ce5b6db
--- /dev/null
+++ b/app/source/Cplt/Model/Workflow/fwd.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <Cplt/Model/Workflow/Nodes/fwd.hpp>
+#include <Cplt/Model/Workflow/Values/fwd.hpp>
+
+// Evaluation.hpp
+class WorkflowEvaluationError;
+class WorkflowEvaluationContext;
+
+// SavedWorkflow.hpp
+class SavedWorkflowCache;
+class SavedWorkflow;
+
+// Value.hpp
+class BaseValue;
+class BaseObjectValue;
+
+// Workflow.hpp
+class WorkflowConnection;
+class WorkflowNode;
+class Workflow;
+class WorkflowAssetList;
diff --git a/app/source/Cplt/Model/fwd.hpp b/app/source/Cplt/Model/fwd.hpp
new file mode 100644
index 0000000..c7e44e6
--- /dev/null
+++ b/app/source/Cplt/Model/fwd.hpp
@@ -0,0 +1,35 @@
+#pragma once
+
+#include <Cplt/Model/Template/fwd.hpp>
+#include <Cplt/Model/Workflow/fwd.hpp>
+
+// Database.hpp
+enum class TableKind;
+class SalesTable;
+class PurchasesTable;
+class DeliveryTable;
+class MainDatabase;
+
+// Assets.hpp
+struct SavedAsset;
+class Asset;
+enum class NameSelectionError;
+class AssetList;
+
+// Filter.hpp
+class TableRowsFilter;
+
+// GlobalStates.hpp
+class GlobalStates;
+
+// Items.hpp
+template <class T>
+class ItemList;
+template <class TSelf>
+class ItemBase;
+class ProductItem;
+class FactoryItem;
+class CustomerItem;
+
+// Project.hpp
+class Project;
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;
+}
diff --git a/app/source/Cplt/Utils/Color.hpp b/app/source/Cplt/Utils/Color.hpp
new file mode 100644
index 0000000..15fe6a1
--- /dev/null
+++ b/app/source/Cplt/Utils/Color.hpp
@@ -0,0 +1,202 @@
+#pragma once
+
+#include <Cplt/Utils/Math.hpp>
+#include <Cplt/Utils/Vector.hpp>
+#include <Cplt/Utils/fwd.hpp>
+
+#include <imgui.h>
+#include <algorithm>
+#include <cstdint>
+#include <limits>
+
+class RgbaColor
+{
+public:
+ uint8_t r;
+ uint8_t g;
+ uint8_t b;
+ uint8_t a;
+
+public:
+ constexpr RgbaColor() noexcept
+ : r{ 255 }
+ , g{ 255 }
+ , b{ 255 }
+ , a{ 255 }
+ {
+ }
+
+ constexpr RgbaColor(float r, float g, float b, float a = 1.0f) noexcept
+ : r{ static_cast<uint8_t>(r * 255.0f) }
+ , g{ static_cast<uint8_t>(g * 255.0f) }
+ , b{ static_cast<uint8_t>(b * 255.0f) }
+ , a{ static_cast<uint8_t>(a * 255.0f) }
+ {
+ }
+
+ constexpr RgbaColor(int r, int g, int b, int a = 255) noexcept
+ : r{ static_cast<uint8_t>(r & 0xFF) }
+ , g{ static_cast<uint8_t>(g & 0xFF) }
+ , b{ static_cast<uint8_t>(b & 0xFF) }
+ , a{ static_cast<uint8_t>(a & 0xFF) }
+ {
+ }
+
+ constexpr RgbaColor(uint32_t rgba) noexcept
+ : r{ static_cast<uint8_t>((rgba >> 0) & 0xFF) }
+ , g{ static_cast<uint8_t>((rgba >> 8) & 0xFF) }
+ , b{ static_cast<uint8_t>((rgba >> 16) & 0xFF) }
+ , a{ static_cast<uint8_t>((rgba >> 24) & 0xFF) }
+ {
+ }
+
+ constexpr uint32_t GetScalar() const noexcept
+ {
+ uint32_t res = 0;
+ res |= r << 24;
+ res |= g << 16;
+ res |= b << 8;
+ res |= a;
+ return res;
+ }
+
+ constexpr void SetScalar(uint32_t scalar) noexcept
+ {
+ r = (scalar >> 0) & 0xFF;
+ g = (scalar >> 8) & 0xFF;
+ b = (scalar >> 16) & 0xFF;
+ a = (scalar >> 24) & 0xFF;
+ }
+
+ constexpr float GetNormalizedRed() const noexcept
+ {
+ return r / 255.0f;
+ }
+
+ constexpr float GetNormalizedGreen() const noexcept
+ {
+ return g / 255.0f;
+ }
+
+ constexpr float GetNormalizedBlue() const noexcept
+ {
+ return b / 255.0f;
+ }
+
+ constexpr float GetNormalizedAlpha() const noexcept
+ {
+ return a / 255.0f;
+ }
+
+ constexpr Vec4i AsVec4i() const noexcept
+ {
+ return Vec4i{ r, g, b, a };
+ }
+
+ constexpr Vec4f AsVec4f() const noexcept
+ {
+ return Vec4f{
+ GetNormalizedRed(),
+ GetNormalizedGreen(),
+ GetNormalizedBlue(),
+ GetNormalizedAlpha(),
+ };
+ }
+
+ ImVec4 AsImVec() const
+ {
+ auto v = AsVec4f();
+ return ImVec4{ v.x, v.y, v.z, v.w };
+ }
+
+ ImColor AsImColor() const
+ {
+ auto v = AsVec4f();
+ return ImColor{ v.x, v.y, v.z, v.w };
+ }
+
+ ImU32 AsImU32() const
+ {
+ ImU32 res;
+ res |= r << IM_COL32_R_SHIFT;
+ res |= g << IM_COL32_G_SHIFT;
+ res |= b << IM_COL32_B_SHIFT;
+ res |= a << IM_COL32_A_SHIFT;
+ return res;
+ }
+
+ constexpr void SetVec(const Vec4f& vec) noexcept
+ {
+ r = (uint8_t)(vec.x * 255.0f);
+ g = (uint8_t)(vec.y * 255.0f);
+ b = (uint8_t)(vec.z * 255.0f);
+ a = (uint8_t)(vec.w * 255.0f);
+ }
+
+ // Forward declaring because cyclic reference between RgbaColor and HsvColor
+ constexpr HsvColor ToHsv() const noexcept;
+
+ friend constexpr bool operator==(const RgbaColor&, const RgbaColor&) noexcept = default;
+};
+
+class HsvColor
+{
+public:
+ float h;
+ float s;
+ float v;
+ float a;
+
+public:
+ constexpr HsvColor() noexcept
+ : h{ 0.0f }
+ , s{ 0.0f }
+ , v{ 1.0f }
+ , a{ 1.0f }
+ {
+ }
+
+ constexpr HsvColor(float h, float s, float v, float a) noexcept
+ : h{ h }
+ , s{ s }
+ , v{ v }
+ , a{ a }
+ {
+ }
+
+ // Forward declaring because cyclic reference between RgbaColor and HsvColor
+ constexpr RgbaColor ToRgba() const noexcept;
+};
+
+constexpr HsvColor RgbaColor::ToHsv() const noexcept
+{
+ float fr = GetNormalizedRed();
+ float fg = GetNormalizedBlue();
+ float fb = GetNormalizedGreen();
+ float fa = GetNormalizedAlpha();
+
+ auto p = fg < fb ? Vec4f{ fb, fg, -1, 2.0f / 3.0f } : Vec4f{ fg, fb, 0, -1.0f / 3.0f };
+ auto q = fr < p.x ? Vec4f{ p.x, p.y, p.w, fr } : Vec4f{ fr, p.y, p.z, p.x };
+ float c = q.x - std::min(q.w, q.y);
+ float h = MathUtils::Abs((q.w - q.y) / (6 * c + std::numeric_limits<float>::epsilon()) + q.z);
+
+ Vec3f hcv{ h, c, q.x };
+ float s = hcv.y / (hcv.z + std::numeric_limits<float>::epsilon());
+ return HsvColor(hcv.x, s, hcv.z, fa);
+}
+
+constexpr RgbaColor HsvColor::ToRgba() const noexcept
+{
+ float r = MathUtils::Abs(h * 6 - 3) - 1;
+ float g = 2 - MathUtils::Abs(h * 6 - 2);
+ float b = 2 - MathUtils::Abs(h * 6 - 4);
+
+ auto rgb = Vec3f{
+ std::clamp(r, 0.0f, 1.0f),
+ std::clamp(g, 0.0f, 1.0f),
+ std::clamp(b, 0.0f, 1.0f),
+ };
+ auto vc = (rgb - Vec3f{ 0, 0, 0 }) * s + Vec3f{ 1, 1, 1 } * v;
+
+ return RgbaColor(vc.x, vc.y, vc.z, a);
+}
diff --git a/app/source/Cplt/Utils/Hash.hpp b/app/source/Cplt/Utils/Hash.hpp
new file mode 100644
index 0000000..cf7713a
--- /dev/null
+++ b/app/source/Cplt/Utils/Hash.hpp
@@ -0,0 +1,15 @@
+#pragma once
+
+#include <cstddef>
+#include <functional>
+
+namespace HashUtils {
+
+template <class T>
+void Combine(size_t& seed, const T& v)
+{
+ std::hash<T> hasher;
+ seed ^= hasher(v) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
+}
+
+} // namespace HashUtils
diff --git a/app/source/Cplt/Utils/I18n.hpp b/app/source/Cplt/Utils/I18n.hpp
new file mode 100644
index 0000000..895856a
--- /dev/null
+++ b/app/source/Cplt/Utils/I18n.hpp
@@ -0,0 +1,10 @@
+#pragma once
+
+#include <Cplt/Utils/Macros.hpp>
+
+#if !defined(TARGET_LOCALE)
+# define I18N_TEXT(defaultText, name) defaultText
+#else
+# include TARGET_LOCALE_FILE
+# define I18N_TEXT(defaultText, name) name
+#endif
diff --git a/app/source/Cplt/Utils/IO/Archive.cpp b/app/source/Cplt/Utils/IO/Archive.cpp
new file mode 100644
index 0000000..f6e7b27
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/Archive.cpp
@@ -0,0 +1,57 @@
+#include "Archive.hpp"
+
+constexpr uint8_t kMagicNumbers[] = { 0x98, 0xd8, 0xa4, 0x65, 0x18, 0xa2, 0xd6, 0xa0 };
+constexpr size_t kMagicNumberCount = std::size(kMagicNumbers);
+
+constexpr uint8_t kByteOrderMark = []() {
+ switch (std::endian::native) {
+ case std::endian::little: return 0;
+ case std::endian::big: return 1;
+ }
+}();
+
+std::span<const uint8_t, 8> DataArchive::GetMagicNumbers()
+{
+ return std::span<const uint8_t, 8>{ kMagicNumbers };
+}
+
+std::optional<InputDataStream> DataArchive::LoadFile(const std::filesystem::path& path)
+{
+ InputFileStream fileStream(path);
+ fileStream.SetReadInSize(1024);
+ InputDataStream stream(std::move(fileStream));
+
+ uint8_t magicNumbers[kMagicNumberCount];
+ stream.ReadBytes(kMagicNumberCount, magicNumbers);
+
+ for (size_t i = 0; i < kMagicNumberCount; ++i) {
+ if (magicNumbers[i] != kMagicNumbers[i]) {
+ return {};
+ }
+ }
+
+ uint8_t byteOrderMark;
+ stream.Read(byteOrderMark);
+
+ switch (byteOrderMark) {
+ case 0: stream.SetEndianness(std::endian::little); break;
+ case 1: stream.SetEndianness(std::endian::big); break;
+ default: std::abort();
+ }
+
+ return stream;
+}
+
+std::optional<OutputDataStream> DataArchive::SaveFile(const std::filesystem::path& path)
+{
+ OutputFileStream fileStream(path, OutputFileStream::TruncateFile);
+ fileStream.SetMaxBufferSize(1024);
+ OutputDataStream stream(std::move(fileStream));
+
+ stream.WriteBytes(kMagicNumberCount, kMagicNumbers);
+ stream.Write(kByteOrderMark);
+
+ stream.SetEndianness(std::endian::native);
+
+ return stream;
+}
diff --git a/app/source/Cplt/Utils/IO/Archive.hpp b/app/source/Cplt/Utils/IO/Archive.hpp
new file mode 100644
index 0000000..7632f3b
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/Archive.hpp
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+
+#include <cstdint>
+#include <filesystem>
+#include <optional>
+#include <span>
+
+class DataArchive
+{
+public:
+ static std::span<const uint8_t, 8> GetMagicNumbers();
+
+ // TODO more complete impl
+ static std::optional<InputDataStream> LoadFile(const std::filesystem::path& path);
+ static std::optional<OutputDataStream> SaveFile(const std::filesystem::path& path);
+};
diff --git a/app/source/Cplt/Utils/IO/CstdioFile.cpp b/app/source/Cplt/Utils/IO/CstdioFile.cpp
new file mode 100644
index 0000000..c414dfb
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/CstdioFile.cpp
@@ -0,0 +1,36 @@
+#include "CstdioFile.hpp"
+
+#include <Cplt/Utils/Macros.hpp>
+
+#pragma push_macro("MODE_STRING")
+#undef MODE_STRING
+
+#if defined(_WIN32)
+# define MODE_STRING(x) L##x
+#else
+# define MODE_STRING(x) x
+#endif
+
+namespace CPLT_UNITY_ID {
+auto GetModeString(FileUtils::IoMode mode)
+{
+ switch (mode) {
+ case FileUtils::IM_Read: return MODE_STRING("rb");
+ case FileUtils::IM_WriteAppend: return MODE_STRING("ab");
+ case FileUtils::IM_WriteTruncate: return MODE_STRING("wb");
+ }
+ return MODE_STRING("");
+}
+} // namespace CPLT_UNITY_ID
+
+#pragma pop_macro("MODE_STRING")
+
+FILE* FileUtils::OpenCstdioFile(const std::filesystem::path& path, IoMode mode)
+{
+#ifdef _WIN32
+ // std::filesystem::path::c_str() returns `const wchar_t*` under Windows, because NT uses UTF-16 natively
+ return _wfopen(path.c_str(), ::GetModeString(mode));
+#else
+ return fopen(path.c_str(), CPLT_UNITY_ID::GetModeString(mode));
+#endif
+}
diff --git a/app/source/Cplt/Utils/IO/CstdioFile.hpp b/app/source/Cplt/Utils/IO/CstdioFile.hpp
new file mode 100644
index 0000000..e863dd5
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/CstdioFile.hpp
@@ -0,0 +1,17 @@
+#pragma once
+
+#include <cstdio>
+#include <filesystem>
+
+namespace FileUtils {
+
+enum IoMode
+{
+ IM_Read,
+ IM_WriteAppend,
+ IM_WriteTruncate,
+};
+
+FILE* OpenCstdioFile(const std::filesystem::path& path, IoMode mode);
+
+} // namespace FileUtils
diff --git a/app/source/Cplt/Utils/IO/DataStream.cpp b/app/source/Cplt/Utils/IO/DataStream.cpp
new file mode 100644
index 0000000..c0797e3
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/DataStream.cpp
@@ -0,0 +1,283 @@
+#include "DataStream.hpp"
+
+#include <bit>
+#include <limits>
+#include <utility>
+
+static_assert(std::numeric_limits<float>::is_iec559, "Non IEE754/IEC559 'float' is not supported.");
+static_assert(std::numeric_limits<double>::is_iec559, "Non IEE754/IEC559 'double' is not supported.");
+
+static_assert(std::endian::native == std::endian::little || std::endian::native == std::endian::big, "Mixed endian is not supported.");
+
+// Reading/writing signed integer byte-by-byte is fine, since the representation got fixed to 2's complements in C++20
+
+static uint16_t ByteSwap(uint16_t n)
+{
+ auto bytes = reinterpret_cast<std::byte*>(n);
+ std::swap(bytes[0], bytes[1]);
+ return n;
+}
+
+static uint32_t ByteSwap(uint32_t n)
+{
+#ifdef _MSC_VER
+ return _byteswap_ulong(n);
+#else
+ return __builtin_bswap32(n);
+#endif
+}
+
+static uint64_t ByteSwap(uint64_t n)
+{
+#ifdef _MSC_VER
+ return _byteswap_uint64(n);
+#else
+ return __builtin_bswap64(n);
+#endif
+}
+
+template <class TSigned>
+static TSigned ByteSwap(TSigned n)
+{
+ using Unsigned = std::make_unsigned_t<TSigned>;
+
+ auto swapped = ::ByteSwap(std::bit_cast<Unsigned>(n));
+ return std::bit_cast<TSigned>(swapped);
+}
+
+std::endian BaseDataStream::GetEndianness() const
+{
+ return mEndian;
+}
+
+void BaseDataStream::SetEndianness(std::endian endianness)
+{
+ mEndian = endianness;
+}
+
+InputDataStream::InputDataStream(InputFileStream stream)
+ : mBackend{ std::move(stream) }
+{
+}
+
+void InputDataStream::ReadBytes(size_t byteCount, std::byte* buffer)
+{
+ mBackend.ReadBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<std::byte*>(buffer));
+}
+
+void InputDataStream::ReadBytes(size_t byteCount, char* buffer)
+{
+ mBackend.ReadBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<std::byte*>(buffer));
+}
+
+void InputDataStream::ReadBytes(size_t byteCount, signed char* buffer)
+{
+ mBackend.ReadBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<std::byte*>(buffer));
+}
+
+void InputDataStream::ReadBytes(size_t byteCount, unsigned char* buffer)
+{
+ mBackend.ReadBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<std::byte*>(buffer));
+}
+
+void InputDataStream::Read(int8_t& n)
+{
+ // sizeof() of a reference type yields the size of the reference
+ mBackend.ReadBytes(sizeof(n), reinterpret_cast<std::byte*>(&n));
+}
+
+void InputDataStream::Read(int16_t& n)
+{
+ int16_t tmp;
+ mBackend.ReadBytes(sizeof(tmp), reinterpret_cast<std::byte*>(&tmp));
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(tmp);
+ } else {
+ n = tmp;
+ }
+}
+
+void InputDataStream::Read(int32_t& n)
+{
+ int32_t tmp;
+ mBackend.ReadBytes(sizeof(tmp), reinterpret_cast<std::byte*>(&tmp));
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(tmp);
+ } else {
+ n = tmp;
+ }
+}
+
+void InputDataStream::Read(int64_t& n)
+{
+ int64_t tmp;
+ mBackend.ReadBytes(sizeof(tmp), reinterpret_cast<std::byte*>(&tmp));
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(tmp);
+ } else {
+ n = tmp;
+ }
+}
+
+void InputDataStream::Read(uint8_t& n)
+{
+ mBackend.ReadBytes(sizeof(n), reinterpret_cast<std::byte*>(&n));
+}
+
+void InputDataStream::Read(uint16_t& n)
+{
+ uint16_t tmp;
+ mBackend.ReadBytes(sizeof(tmp), reinterpret_cast<std::byte*>(&tmp));
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(tmp);
+ } else {
+ n = tmp;
+ }
+}
+
+void InputDataStream::Read(uint32_t& n)
+{
+ uint32_t tmp;
+ mBackend.ReadBytes(sizeof(tmp), reinterpret_cast<std::byte*>(&tmp));
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(tmp);
+ } else {
+ n = tmp;
+ }
+}
+
+void InputDataStream::Read(uint64_t& n)
+{
+ uint64_t tmp;
+ mBackend.ReadBytes(sizeof(tmp), reinterpret_cast<std::byte*>(&tmp));
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(tmp);
+ } else {
+ n = tmp;
+ }
+}
+
+void InputDataStream::Read(float& n)
+{
+ uint32_t buffer;
+ Read(buffer);
+
+ if (GetEndianness() != std::endian::native) {
+ buffer = ::ByteSwap(buffer);
+ }
+
+ n = std::bit_cast<float>(buffer);
+}
+
+void InputDataStream::Read(double& n)
+{
+ uint64_t buffer;
+ Read(buffer);
+
+ if (GetEndianness() != std::endian::native) {
+ buffer = ::ByteSwap(buffer);
+ }
+
+ n = std::bit_cast<double>(buffer);
+}
+
+OutputDataStream::OutputDataStream(OutputFileStream stream)
+ : mBackend{ std::move(stream) }
+{
+}
+
+void OutputDataStream::WriteBytes(size_t byteCount, const std::byte* buffer)
+{
+ mBackend.WriteBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<const std::byte*>(buffer));
+}
+
+void OutputDataStream::WriteBytes(size_t byteCount, const char* buffer)
+{
+ mBackend.WriteBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<const std::byte*>(buffer));
+}
+
+void OutputDataStream::WriteBytes(size_t byteCount, const signed char* buffer)
+{
+ mBackend.WriteBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<const std::byte*>(buffer));
+}
+
+void OutputDataStream::WriteBytes(size_t byteCount, const unsigned char* buffer)
+{
+ mBackend.WriteBytes(static_cast<std::streamsize>(byteCount), reinterpret_cast<const std::byte*>(buffer));
+}
+
+void OutputDataStream::Write(int8_t n)
+{
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(int16_t n)
+{
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(n);
+ }
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(int32_t n)
+{
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(n);
+ }
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(int64_t n)
+{
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(n);
+ }
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(uint8_t n)
+{
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(uint16_t n)
+{
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(n);
+ }
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(uint32_t n)
+{
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(n);
+ }
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(uint64_t n)
+{
+ if (GetEndianness() != std::endian::native) {
+ n = ::ByteSwap(n);
+ }
+ mBackend.WriteBytes(sizeof(n), reinterpret_cast<const std::byte*>(&n));
+}
+
+void OutputDataStream::Write(float n)
+{
+ auto buffer = std::bit_cast<uint32_t>(n);
+ if (GetEndianness() != std::endian::native) {
+ buffer = ::ByteSwap(buffer);
+ }
+ mBackend.WriteBytes(sizeof(buffer), reinterpret_cast<const std::byte*>(&buffer));
+}
+
+void OutputDataStream::Write(double n)
+{
+ auto buffer = std::bit_cast<uint64_t>(n);
+ if (GetEndianness() != std::endian::native) {
+ buffer = ::ByteSwap(buffer);
+ }
+ mBackend.WriteBytes(sizeof(buffer), reinterpret_cast<const std::byte*>(&buffer));
+}
diff --git a/app/source/Cplt/Utils/IO/DataStream.hpp b/app/source/Cplt/Utils/IO/DataStream.hpp
new file mode 100644
index 0000000..133adc2
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/DataStream.hpp
@@ -0,0 +1,210 @@
+#pragma once
+
+#include "FileStream.hpp"
+#include <Cplt/fwd.hpp>
+
+#include <bit>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+class BaseDataStream
+{
+private:
+ std::endian mEndian = std::endian::big;
+
+public:
+ std::endian GetEndianness() const;
+ void SetEndianness(std::endian endianness);
+};
+
+class InputDataStream : public BaseDataStream
+{
+private:
+ InputFileStream mBackend;
+
+public:
+ static constexpr bool IsSerializer()
+ {
+ return false;
+ }
+
+ InputDataStream(InputFileStream stream);
+
+ void ReadBytes(size_t byteCount, std::byte* buffer);
+ void ReadBytes(size_t byteCount, char* buffer);
+ void ReadBytes(size_t byteCount, signed char* buffer);
+ void ReadBytes(size_t byteCount, unsigned char* buffer);
+
+ template <class TInserter>
+ void ReadBytes(size_t byteCount, TInserter&& inserter)
+ {
+ for (size_t i = 0; i < byteCount; ++i) {
+ uint8_t byte;
+ Read(byte);
+
+ inserter = byte;
+ }
+ }
+
+ void Read(int8_t& n);
+ void Read(int16_t& n);
+ void Read(int32_t& n);
+ void Read(int64_t& n);
+
+ void Read(uint8_t& n);
+ void Read(uint16_t& n);
+ void Read(uint32_t& n);
+ void Read(uint64_t& n);
+
+ void Read(float& n);
+ void Read(double& n);
+
+ template <class TEnum>
+ requires std::is_enum_v<TEnum>
+ void ReadEnum(TEnum& e)
+ {
+ std::underlying_type_t<TEnum> n;
+ Read(n);
+ e = static_cast<TEnum>(e);
+ }
+
+ template <class TObject>
+ void ReadObject(TObject& obj)
+ {
+ obj.ReadFromDataStream(*this);
+ }
+
+ template <class TAdapter, class TObject>
+ void ReadObjectAdapted(TObject& obj)
+ {
+ TAdapter::ReadFromDataStream(*this, obj);
+ }
+
+public:
+ // Proxy functions for writing templated IO functions
+
+ template <class T>
+ void Bytes(size_t byteCount, T* buffer)
+ {
+ ReadBytes(byteCount, buffer);
+ }
+
+ template <class T>
+ void Value(T& t)
+ {
+ Read(t);
+ }
+
+ template <class T>
+ void Enum(T& t)
+ {
+ ReadEnum(t);
+ }
+
+ template <class T>
+ void Object(T& obj)
+ {
+ ReadObject(obj);
+ }
+
+ template <class TAdapter, class TObject>
+ void ObjectAdapted(TObject& obj)
+ {
+ ReadObjectAdapted<TAdapter>(obj);
+ }
+};
+
+class OutputDataStream : public BaseDataStream
+{
+private:
+ OutputFileStream mBackend;
+
+public:
+ static constexpr bool IsSerializer()
+ {
+ return true;
+ }
+
+ OutputDataStream(OutputFileStream stream);
+
+ void WriteBytes(size_t byteCount, const std::byte* buffer);
+ void WriteBytes(size_t byteCount, const char* buffer);
+ void WriteBytes(size_t byteCount, const signed char* buffer);
+ void WriteBytes(size_t byteCount, const unsigned char* buffer);
+
+ template <class TIterator>
+ void WriteBytes(TIterator&& begin, TIterator&& end)
+ {
+ for (; begin != end; ++begin) {
+ uint8_t byte = *begin;
+ Write(byte);
+ }
+ }
+
+ void Write(int8_t n);
+ void Write(int16_t n);
+ void Write(int32_t n);
+ void Write(int64_t n);
+
+ void Write(uint8_t n);
+ void Write(uint16_t n);
+ void Write(uint32_t n);
+ void Write(uint64_t n);
+
+ void Write(float n);
+ void Write(double n);
+
+ template <class TEnum>
+ requires std::is_enum_v<TEnum>
+ void WriteEnum(TEnum e)
+ {
+ auto n = static_cast<std::underlying_type_t<TEnum>>(e);
+ Write(n);
+ }
+
+ template <class TObject>
+ void WriteObject(const TObject& obj)
+ {
+ obj.WriteToDataStream(*this);
+ }
+
+ template <class TAdapter, class TObject>
+ void WriteObjectAdapted(const TObject& obj)
+ {
+ TAdapter::WriteToDataStream(*this, obj);
+ }
+
+public:
+ // Proxy functions for writing templated IO functions
+
+ template <class T>
+ void Bytes(size_t byteCount, T* buffer)
+ {
+ WriteBytes(byteCount, buffer);
+ }
+
+ template <class T>
+ void Value(T t)
+ {
+ Write(t);
+ }
+
+ template <class T>
+ void Enum(T t)
+ {
+ WriteEnum(t);
+ }
+
+ template <class T>
+ void Object(T& obj)
+ {
+ WriteObject(obj);
+ }
+
+ template <class TAdapter, class TObject>
+ void ObjectAdapted(TObject& obj)
+ {
+ WriteObjectAdapted<TAdapter>(obj);
+ }
+};
diff --git a/app/source/Cplt/Utils/IO/FileStream.cpp b/app/source/Cplt/Utils/IO/FileStream.cpp
new file mode 100644
index 0000000..8b83712
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/FileStream.cpp
@@ -0,0 +1,7 @@
+#include "FileStream.hpp"
+
+#if defined(CPLT_FILESTREAM_USE_CSTDIO)
+# include "FileStream_Cstdio.inl"
+#else
+# include "FileStream_Custom.inl"
+#endif
diff --git a/app/source/Cplt/Utils/IO/FileStream.hpp b/app/source/Cplt/Utils/IO/FileStream.hpp
new file mode 100644
index 0000000..453ddbe
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/FileStream.hpp
@@ -0,0 +1,97 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+
+// TODO switch to custom when unit tests are ready and bugs are fixed
+#define CPLT_FILESTREAM_USE_CSTDIO
+
+struct IoResult
+{
+ enum ErrorKind
+ {
+ ERR_None,
+ ERR_PermissionDenied,
+ ERR_UnexpectedEof,
+ ERR_Unsupported,
+ ERR_OutOfSpace,
+ ERR_Other,
+ };
+
+ ErrorKind Error;
+ uint32_t SystemError;
+ size_t BytesMoved;
+};
+
+class InputFileStream
+{
+private:
+#if defined(CPLT_FILESTREAM_USE_CSTDIO)
+ FILE* mFile;
+#else
+ alignas(void*) char mOsFileHandle[sizeof(void*)];
+
+ // mBuffer is always mReadInSize size
+ std::unique_ptr<std::byte[]> mBuffer;
+ int mReadInSize = 1024;
+
+ int mFirstByteIdx = 0;
+ int mAvailableBytes = 0;
+
+ bool mEof = false;
+#endif
+
+public:
+ InputFileStream(const std::filesystem::path& path);
+ ~InputFileStream();
+
+ InputFileStream(const InputFileStream&) = delete;
+ InputFileStream& operator=(const InputFileStream&) = delete;
+ InputFileStream(InputFileStream&&);
+ InputFileStream& operator=(InputFileStream&&);
+
+ int GetReadInSize() const;
+ void SetReadInSize(int size);
+
+ bool IsEof() const;
+
+ IoResult ReadBytes(size_t bufferLength, std::byte* buffer);
+};
+
+class OutputFileStream
+{
+public:
+ enum WriteMode
+ {
+ AppendFile,
+ TruncateFile,
+ };
+
+private:
+#if defined(CPLT_FILESTREAM_USE_CSTDIO)
+ FILE* mFile;
+#else
+ alignas(void*) char mOsFileHandle[sizeof(void*)];
+ std::unique_ptr<std::byte[]> mBuffer;
+ int mMaxBufferSize = 1024;
+ int mCurrentBufferSize = 0;
+#endif
+
+public:
+ OutputFileStream(const std::filesystem::path& path, WriteMode mode);
+ ~OutputFileStream();
+
+ OutputFileStream(const OutputFileStream&) = delete;
+ OutputFileStream& operator=(const OutputFileStream&) = delete;
+ OutputFileStream(OutputFileStream&&);
+ OutputFileStream& operator=(OutputFileStream&&);
+
+ int GetMaxBufferSize() const;
+ void SetMaxBufferSize(int maxSize);
+
+ IoResult WriteBytes(size_t bufferLength, const std::byte* buffer);
+
+ void FlushBuffer();
+};
diff --git a/app/source/Cplt/Utils/IO/FileStream_Cstdio.inl b/app/source/Cplt/Utils/IO/FileStream_Cstdio.inl
new file mode 100644
index 0000000..ff2ca01
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/FileStream_Cstdio.inl
@@ -0,0 +1,126 @@
+// Note: included by FileStream.cpp conditionally, not compiled separately
+#include "FileStream.hpp"
+
+#include <Cplt/Utils/IO/CstdioFile.hpp>
+
+#include <cstdio>
+#include <filesystem>
+
+namespace fs = std::filesystem;
+
+InputFileStream::InputFileStream(const fs::path& path)
+ : mFile{ FileUtils::OpenCstdioFile(path, FileUtils::IM_Read) }
+{
+}
+
+InputFileStream::~InputFileStream()
+{
+ if (mFile) {
+ std::fclose(mFile);
+ }
+}
+
+InputFileStream::InputFileStream(InputFileStream&& that)
+ : mFile{ that.mFile }
+{
+ that.mFile = nullptr;
+}
+
+InputFileStream& InputFileStream::operator=(InputFileStream&& that)
+{
+ if (this == &that) return *this;
+
+ if (mFile) {
+ std::fclose(mFile);
+ }
+ mFile = that.mFile;
+ that.mFile = nullptr;
+
+ return *this;
+}
+
+OutputFileStream::OutputFileStream(const fs::path& path, WriteMode mode)
+{
+ switch (mode) {
+ case AppendFile: mFile = FileUtils::OpenCstdioFile(path, FileUtils::IM_WriteAppend); break;
+ case TruncateFile: mFile = FileUtils::OpenCstdioFile(path, FileUtils::IM_WriteTruncate); break;
+ }
+}
+
+OutputFileStream::~OutputFileStream()
+{
+ if (mFile) {
+ std::fclose(mFile);
+ }
+}
+
+OutputFileStream::OutputFileStream(OutputFileStream&& that)
+ : mFile{ that.mFile }
+{
+ that.mFile = nullptr;
+}
+
+OutputFileStream& OutputFileStream::operator=(OutputFileStream&& that)
+{
+ if (this == &that) return *this;
+
+ if (mFile) {
+ std::fclose(mFile);
+ }
+ mFile = that.mFile;
+ that.mFile = nullptr;
+
+ return *this;
+}
+
+int InputFileStream::GetReadInSize() const
+{
+ return 0;
+}
+
+void InputFileStream::SetReadInSize(int size)
+{
+ // No-op
+}
+
+bool InputFileStream::IsEof() const
+{
+ return std::feof(mFile);
+}
+
+IoResult InputFileStream::ReadBytes(size_t bufferLength, std::byte* buffer)
+{
+ auto bytesRead = std::fread(buffer, 1, bufferLength, mFile);
+
+ return {
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = bytesRead,
+ };
+}
+
+int OutputFileStream::GetMaxBufferSize() const
+{
+ return 0;
+}
+
+void OutputFileStream::SetMaxBufferSize(int maxSize)
+{
+ // No-op
+}
+
+IoResult OutputFileStream::WriteBytes(size_t bufferLength, const std::byte* buffer)
+{
+ auto bytesWritten = std::fwrite(buffer, 1, bufferLength, mFile);
+
+ return IoResult{
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = bytesWritten,
+ };
+}
+
+void OutputFileStream::FlushBuffer()
+{
+ // No-op
+}
diff --git a/app/source/Cplt/Utils/IO/FileStream_Custom.inl b/app/source/Cplt/Utils/IO/FileStream_Custom.inl
new file mode 100644
index 0000000..004dd01
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/FileStream_Custom.inl
@@ -0,0 +1,358 @@
+// Note: included by FileStream.cpp conditionally, not compiled separately
+#include "FileStream.hpp"
+
+#include <cstring>
+#include <filesystem>
+#include <iostream>
+
+namespace fs = std::filesystem;
+
+#if defined(_WIN32)
+# define WIN32_LEAN_AND_MEAN
+# define NOMINMAX
+# include <Windows.h>
+
+InputFileStream::InputFileStream(const fs::path& path)
+ : mOsFileHandle{ 0 }
+{
+ auto handle = reinterpret_cast<HANDLE*>(mOsFileHandle);
+
+ *handle = CreateFileW(
+ path.c_str(), // fs::path::c_str() returns a wide string on Windows
+ GENERIC_READ,
+ /* No sharing */ 0,
+ /* Use default security*/ nullptr,
+ OPEN_EXISTING,
+ FILE_ATTRIBUTE_NORMAL,
+ /* No attribute template */ nullptr);
+
+ // TODO handle error
+}
+
+InputFileStream::~InputFileStream()
+{
+ auto handle = reinterpret_cast<HANDLE*>(mOsFileHandle);
+ CloseHandle(*handle);
+}
+
+OutputFileStream::OutputFileStream(const fs::path& path, WriteMode mode)
+ : mOsFileHandle{ 0 }
+{
+ auto handle = reinterpret_cast<HANDLE*>(mOsFileHandle);
+
+ DWORD creationDisposition;
+ switch (mode) {
+ case AppendFile: creationDisposition = OPEN_ALWAYS; break;
+ case TruncateFile: creationDisposition = CREATE_ALWAYS; break;
+ }
+
+ *handle = CreateFileW(
+ path.c_str(),
+ GENERIC_WRITE,
+ /* No sharing */ 0,
+ /* Use default security*/ nullptr,
+ creationDisposition,
+ FILE_ATTRIBUTE_NORMAL,
+ /* No attribute template */ nullptr);
+
+ // TODO handle error
+}
+
+OutputFileStream::~OutputFileStream()
+{
+ auto handle = reinterpret_cast<HANDLE*>(mOsFileHandle);
+ CloseHandle(*handle);
+}
+
+static IoResult::ErrorKind MapErrorCodeToIoResult(DWORD error)
+{
+ switch (error) {
+ // TODO
+
+ default:
+ std::cerr << "Unimplemented win32 error code " << error << ", report bug immediately.\n";
+ std::abort();
+ }
+}
+
+static IoResult ReadBytesDirect(HANDLE hFile, size_t byteCount, std::byte* bytes)
+{
+ DWORD bytesRead;
+ BOOL result = ReadFile(hFile, bytes, byteCount, &bytesRead, nullptr);
+
+ if (result) {
+ return IoResult{
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = bytesRead,
+ };
+ } else {
+ DWORD errorCode = GetLastError();
+ return IoResult{
+ .Error = ::MapErrorCodeToIoResult(errorCode),
+ .SystemError = errorCode,
+ .BytesMoved = bytesRead,
+ };
+ }
+}
+
+static IoResult WriteBytesDirect(HANDLE hFile, size_t byteCount, const std::byte* bytes)
+{
+ DWORD bytesWritten;
+ BOOL result = WriteFile(hFile, bytes, byteCount, &bytesWritten, nullptr);
+
+ if (result) {
+ return IoResult{
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = bytesWritten,
+ };
+ } else {
+ DWORD errorCode = GetLastError();
+ return IoResult{
+ .Error = ::MapErrorCodeToIoResult(errorCode),
+ .SystemError = errorCode,
+ .BytesMoved = bytesWritten,
+ };
+ }
+}
+
+#elif defined(__APPLE__) || defined(__linux__)
+# include <fcntl.h>
+# include <sys/stat.h>
+# include <sys/types.h>
+# include <unistd.h>
+
+InputFileStream::InputFileStream(const fs::path& path)
+ : mOsFileHandle{ 0 }
+{
+ auto fd = reinterpret_cast<int*>(mOsFileHandle);
+ *fd = open(path.c_str(), O_RDONLY);
+}
+
+InputFileStream::~InputFileStream()
+{
+ auto fd = reinterpret_cast<int*>(mOsFileHandle);
+ close(*fd);
+}
+
+OutputFileStream::OutputFileStream(const fs::path& path, WriteMode mode)
+ : mOsFileHandle{ 0 }
+{
+ auto fd = reinterpret_cast<int*>(mOsFileHandle);
+
+ int flags = O_WRONLY | O_CREAT;
+ switch (mode) {
+ case AppendFile: flags |= O_APPEND; break;
+ case TruncateFile: flags |= O_TRUNC; break;
+ }
+
+ *fd = open(path.c_str(), flags, 0644);
+}
+
+OutputFileStream::~OutputFileStream()
+{
+ auto fd = reinterpret_cast<int*>(mOsFileHandle);
+ close(*fd);
+}
+
+static IoResult::ErrorKind MapErrnoToIoResult(int err)
+{
+ switch (err) {
+ // TODO
+ case EFAULT: return IoResult::ERR_UnexpectedEof;
+ case EPERM: return IoResult::ERR_PermissionDenied;
+ case ENOSPC: return IoResult::ERR_OutOfSpace;
+ case EIO: return IoResult::ERR_Other;
+
+ default:
+ std::cerr << "Unimplemented POSIX errno " << err << ", report bug immediately.\n";
+ std::abort();
+ }
+}
+
+static IoResult ReadBytesDirect(const char* osFileHandle, size_t byteCount, std::byte* bytes)
+{
+ int fd = *reinterpret_cast<const int*>(osFileHandle);
+ int status = read(fd, bytes, byteCount);
+
+ if (status == -1) {
+ int err = errno;
+ return IoResult{
+ .Error = ::MapErrnoToIoResult(err),
+ .SystemError = (uint32_t)err,
+ .BytesMoved = 0,
+ };
+ } else {
+ return IoResult{
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = (size_t)status, // Equal to number of bytes read
+ };
+ }
+}
+
+static IoResult WriteBytesDirect(const char* osFileHandle, size_t byteCount, const std::byte* bytes)
+{
+ int fd = *reinterpret_cast<const int*>(osFileHandle);
+ int status = write(fd, bytes, byteCount);
+
+ if (status == -1) {
+ int err = errno;
+ return IoResult{
+ .Error = ::MapErrnoToIoResult(err),
+ .SystemError = (uint32_t)err,
+ .BytesMoved = 0,
+ };
+ } else {
+ return IoResult{
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = (size_t)status, // Equal to number of bytes read
+ };
+ }
+}
+
+#else
+# error "Unsupported target platform."
+#endif
+
+int InputFileStream::GetReadInSize() const
+{
+ return mReadInSize;
+}
+
+void InputFileStream::SetReadInSize(int size)
+{
+ if (size > mReadInSize) {
+ mReadInSize = size;
+ mBuffer = std::make_unique<std::byte[]>(size);
+ }
+}
+
+bool InputFileStream::IsEof() const
+{
+ return mEof;
+}
+
+IoResult InputFileStream::ReadBytes(size_t bufferLength, std::byte* buffer)
+{
+ // TODO reduce duplicated code
+
+ auto bytesMoved = std::min<size_t>(mAvailableBytes, bufferLength);
+
+ // On first call after construction, mFirstByteIdx will equal to mReadInSize, i.e. bytesAvailable == 0
+ // and this call to std::memcpy will be no-op
+ std::memcpy(buffer, &mBuffer[mFirstByteIdx], bytesMoved);
+ mFirstByteIdx += (int)bytesMoved;
+ mAvailableBytes -= (int)bytesMoved;
+ buffer += bytesMoved;
+
+ size_t bytesLeft = bufferLength - bytesMoved;
+ if (bytesLeft > mReadInSize) {
+ // Our buffer can't handle rest of the request, just skip the buffering step
+
+ // Read rest of the data into buffer
+ {
+ auto result = ::ReadBytesDirect(mOsFileHandle, bytesLeft, buffer);
+ bytesMoved += result.BytesMoved;
+
+ if (result.Error == IoResult::ERR_None) {
+ if (result.BytesMoved < mReadInSize) {
+ mEof = true;
+ }
+ } else {
+ goto end;
+ }
+ }
+
+ // Refill our buffer
+ {
+ auto result = ::ReadBytesDirect(mOsFileHandle, mReadInSize, mBuffer.get());
+ mFirstByteIdx = 0;
+ mAvailableBytes = (int)result.BytesMoved;
+
+ if (result.Error == IoResult::ERR_None) {
+ if (result.BytesMoved < mReadInSize) {
+ mEof = true;
+ }
+ } else {
+ goto end;
+ }
+ }
+ } else if (bytesLeft > 0) {
+ // Our buffer can handle rest of the request, first buffer than supply the requested data
+
+ // Refill our buffer
+ {
+ auto result = ::ReadBytesDirect(mOsFileHandle, mReadInSize, mBuffer.get());
+ mFirstByteIdx = 0;
+ mAvailableBytes = (int)result.BytesMoved;
+
+ if (result.Error == IoResult::ERR_None) {
+ if (result.BytesMoved < mReadInSize) {
+ mEof = true;
+ }
+ } else {
+ goto end;
+ }
+ }
+
+ // Copy data into buffer
+ {
+ std::memcpy(buffer, &mBuffer[mFirstByteIdx], bytesLeft);
+ mFirstByteIdx += (int)bytesLeft;
+ bytesMoved += bytesLeft;
+ buffer += bytesLeft;
+ }
+ } else {
+ // Request completed already
+ }
+
+end:
+ return IoResult{
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = bytesMoved,
+ };
+}
+
+int OutputFileStream::GetMaxBufferSize() const
+{
+ return mMaxBufferSize;
+}
+
+void OutputFileStream::SetMaxBufferSize(int maxSize)
+{
+ FlushBuffer();
+ if (maxSize > mMaxBufferSize) {
+ mMaxBufferSize = maxSize;
+ mBuffer = std::make_unique<std::byte[]>(maxSize);
+ }
+}
+
+IoResult OutputFileStream::WriteBytes(size_t bufferLength, const std::byte* buffer)
+{
+ if (bufferLength + mCurrentBufferSize > mMaxBufferSize) {
+ FlushBuffer();
+
+ if (bufferLength > mMaxBufferSize) {
+ return ::WriteBytesDirect(mOsFileHandle, bufferLength, buffer);
+ }
+ }
+
+ std::memcpy(mBuffer.get() + mCurrentBufferSize, buffer, bufferLength);
+ mCurrentBufferSize += (int)bufferLength;
+
+ return IoResult{
+ .Error = IoResult::ERR_None,
+ .SystemError = 0,
+ .BytesMoved = bufferLength,
+ };
+}
+
+void OutputFileStream::FlushBuffer()
+{
+ ::WriteBytesDirect(mOsFileHandle, mCurrentBufferSize, mBuffer.get());
+ mCurrentBufferSize = 0;
+}
diff --git a/app/source/Cplt/Utils/IO/Helper.hpp b/app/source/Cplt/Utils/IO/Helper.hpp
new file mode 100644
index 0000000..7a84103
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/Helper.hpp
@@ -0,0 +1,43 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+
+namespace DataStreamAdapters {
+
+/// Helper to invoke either Read() or ReadObject().
+/// This is intended for writing IO adapters, users that's writing IO logic shouldn't using this - it increases compile time while reducing readability.
+template <class TAdapter, class T>
+void ReadHelper(InputDataStream& stream, T& t)
+{
+ if constexpr (!std::is_same_v<TAdapter, void>) {
+ stream.ReadObjectAdapted<TAdapter>(t);
+ } else if constexpr (requires(T tt, InputDataStream ss) { ss.Read(tt); }) {
+ stream.Read(t);
+ } else if constexpr (requires(T tt, InputDataStream ss) { ss.ReadEnum(tt); }) {
+ stream.ReadEnum(t);
+ } else if constexpr (requires(T tt, InputDataStream ss) { ss.ReadObject(tt); }) {
+ stream.ReadObject(t);
+ } else {
+ static_assert(false && sizeof(T), "This type is neither a 'value' nor an 'object'.");
+ }
+}
+
+/// Helper to invoke either Write() or WriteObject().
+/// This is intended for writing IO adapters, users that's writing IO logic shouldn't using this - it increases compile time while reducing readability.
+template <class TAdapter, class T>
+void WriteHelper(OutputDataStream& stream, T& t)
+{
+ if constexpr (!std::is_same_v<TAdapter, void>) {
+ stream.WriteObjectAdapted<TAdapter>(t);
+ } else if constexpr (requires(T tt, OutputDataStream ss) { ss.Write(tt); }) {
+ stream.Write(t);
+ } else if constexpr (requires(T tt, OutputDataStream ss) { ss.WriteEnum(tt); }) {
+ stream.WriteEnum(t);
+ } else if constexpr (requires(T tt, OutputDataStream ss) { ss.WriteObject(tt); }) {
+ stream.WriteObject(t);
+ } else {
+ static_assert(false && sizeof(T), "This type is neither a 'value' nor an 'object'.");
+ }
+}
+
+} // namespace DataStreamAdapters
diff --git a/app/source/Cplt/Utils/IO/StringIntegration.hpp b/app/source/Cplt/Utils/IO/StringIntegration.hpp
new file mode 100644
index 0000000..66f42b0
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/StringIntegration.hpp
@@ -0,0 +1,37 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+
+#include <iterator>
+#include <string>
+#include <string_view>
+
+namespace DataStreamAdapters {
+struct String
+{
+ static void ReadFromDataStream(InputDataStream& stream, std::string& str)
+ {
+ uint64_t size;
+ stream.Read(size);
+
+ str = {};
+ str.reserve(size);
+ stream.ReadBytes(size, std::back_inserter(str));
+ }
+
+ static void WriteToDataStream(OutputDataStream& stream, const std::string& str)
+ {
+ stream.Write((uint64_t)str.size());
+ stream.WriteBytes(str.size(), str.data());
+ }
+};
+
+struct StringView
+{
+ static void WriteToDataStream(OutputDataStream& stream, const std::string_view& str)
+ {
+ stream.Write((uint64_t)str.size());
+ stream.WriteBytes(str.size(), str.data());
+ }
+};
+} // namespace DataStreamAdapters
diff --git a/app/source/Cplt/Utils/IO/TslArrayIntegration.hpp b/app/source/Cplt/Utils/IO/TslArrayIntegration.hpp
new file mode 100644
index 0000000..b585bee
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/TslArrayIntegration.hpp
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+#include <Cplt/Utils/IO/Helper.hpp>
+#include <Cplt/Utils/IO/StringIntegration.hpp>
+
+#include <tsl/array_map.h>
+#include <tsl/array_set.h>
+#include <string>
+#include <type_traits>
+
+// TODO support custom key types
+
+namespace DataStreamAdapters {
+template <class TAdapter = void>
+struct TslArrayMap
+{
+ template <class TValue>
+ static void ReadFromDataStream(InputDataStream& stream, tsl::array_map<char, TValue>& map)
+ {
+ static_assert(std::is_default_constructible_v<TValue>);
+ static_assert(std::is_move_constructible_v<TValue>);
+
+ uint64_t size;
+ stream.Read(size);
+ map.reserve(size);
+
+ for (uint64_t i = 0; i < size; ++i) {
+ std::string key;
+ stream.ReadObjectAdapted<DataStreamAdapters::String>(key);
+
+ TValue value;
+ ReadHelper<TAdapter>(stream, value);
+
+ map.insert(key, std::move(value));
+ }
+ }
+
+ template <class TValue>
+ static void WriteToDataStream(OutputDataStream& stream, const tsl::array_map<char, TValue>& map)
+ {
+ stream.Write((uint64_t)map.size());
+
+ for (auto it = map.begin(); it != map.end(); ++it) {
+ stream.WriteObjectAdapted<DataStreamAdapters::StringView>(it.key_sv());
+ WriteHelper<TAdapter>(stream, it.value());
+ }
+ }
+};
+} // namespace DataStreamAdapters
diff --git a/app/source/Cplt/Utils/IO/TslRobinIntegration.hpp b/app/source/Cplt/Utils/IO/TslRobinIntegration.hpp
new file mode 100644
index 0000000..bdea505
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/TslRobinIntegration.hpp
@@ -0,0 +1,78 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+#include <Cplt/Utils/IO/Helper.hpp>
+
+#include <tsl/robin_map.h>
+#include <tsl/robin_set.h>
+#include <type_traits>
+
+namespace DataStreamAdapters {
+template <class TKeyAdapter = void, class TValueAdapter = void>
+struct TslRobinMap
+{
+ template <class TKey, class TValue>
+ static void ReadFromDataStream(InputDataStream& stream, tsl::robin_map<TKey, TValue>& map)
+ {
+ static_assert(std::is_default_constructible_v<TValue>);
+ static_assert(std::is_move_constructible_v<TValue>);
+
+ uint64_t size;
+ stream.Read(size);
+ map.reserve(size);
+
+ for (uint64_t i = 0; i < size; ++i) {
+ TKey key;
+ ReadHelper<TKeyAdapter>(stream, key);
+
+ TValue value;
+ ReadHelper<TValueAdapter>(stream, value);
+
+ map.insert(std::move(key), std::move(value));
+ }
+ }
+
+ template <class TKey, class TValue>
+ static void WriteToDataStream(OutputDataStream& stream, const tsl::robin_map<TKey, TValue>& map)
+ {
+ stream.Write((uint64_t)map.size());
+
+ for (auto it = map.begin(); it != map.end(); ++it) {
+ WriteHelper<TKeyAdapter>(stream, it.key());
+ WriteHelper<TValueAdapter>(stream, it.value());
+ }
+ }
+};
+
+template <class TAdapter = void>
+struct TslRobinSet
+{
+ template <class TElement>
+ static void ReadFromDataStream(InputDataStream& stream, tsl::robin_set<TElement>& set)
+ {
+ static_assert(std::is_default_constructible_v<TElement>);
+ static_assert(std::is_move_constructible_v<TElement>);
+
+ uint64_t size;
+ stream.Read(size);
+ set.reserve(size);
+
+ for (uint64_t i = 0; i < size; ++i) {
+ TElement element;
+ ReadHelper<TAdapter>(stream, element);
+
+ set.insert(std::move(element));
+ }
+ }
+
+ template <class TElement>
+ static void WriteToDataStream(OutputDataStream& stream, const tsl::robin_set<TElement>& set)
+ {
+ stream.Write((uint64_t)set.size());
+
+ for (auto& element : set) {
+ WriteHelper<TAdapter>(stream, element);
+ }
+ }
+};
+} // namespace DataStreamAdapters
diff --git a/app/source/Cplt/Utils/IO/UuidIntegration.hpp b/app/source/Cplt/Utils/IO/UuidIntegration.hpp
new file mode 100644
index 0000000..20c1e7e
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/UuidIntegration.hpp
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+#include <Cplt/Utils/UUID.hpp>
+
+#include <cstddef>
+#include <cstdint>
+#include <iterator>
+
+namespace DataStreamAdapters {
+struct Uuid
+{
+ static void ReadFromDataStream(InputDataStream& stream, uuids::uuid& uuid)
+ {
+ uint8_t buffer[16];
+ stream.ReadBytes(16, buffer);
+
+ uuid = uuids::uuid(gsl::span<uint8_t, 16>{ buffer });
+ }
+
+ static void WriteToDataStream(OutputDataStream& stream, const uuids::uuid& uuid)
+ {
+ auto gslSpan = uuid.as_bytes();
+ stream.WriteBytes(gslSpan.size(), gslSpan.data());
+ }
+};
+} // namespace DataStreamAdapters
diff --git a/app/source/Cplt/Utils/IO/VectorIntegration.hpp b/app/source/Cplt/Utils/IO/VectorIntegration.hpp
new file mode 100644
index 0000000..93967f6
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/VectorIntegration.hpp
@@ -0,0 +1,42 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+#include <Cplt/Utils/IO/Helper.hpp>
+
+#include <type_traits>
+#include <vector>
+
+namespace DataStreamAdapters {
+template <class TAdapter = void>
+struct Vector
+{
+ template <class TElement>
+ static void ReadFromDataStream(InputDataStream& stream, std::vector<TElement>& vec)
+ {
+ static_assert(std::is_default_constructible_v<TElement>);
+ static_assert(std::is_move_constructible_v<TElement>);
+
+ uint64_t size;
+ stream.Read(size);
+
+ vec.clear();
+ vec.reserve(size);
+
+ for (uint64_t i = 0; i < size; ++i) {
+ TElement element;
+ ReadHelper<TAdapter>(stream, element);
+
+ vec.push_back(std::move(element));
+ }
+ }
+
+ template <class TElement>
+ static void WriteToDataStream(OutputDataStream& stream, const std::vector<TElement>& vec)
+ {
+ stream.Write((uint64_t)vec.size());
+ for (auto& element : vec) {
+ WriteHelper<TAdapter>(stream, element);
+ }
+ }
+};
+} // namespace DataStreamAdapters
diff --git a/app/source/Cplt/Utils/IO/fwd.hpp b/app/source/Cplt/Utils/IO/fwd.hpp
new file mode 100644
index 0000000..9f1492b
--- /dev/null
+++ b/app/source/Cplt/Utils/IO/fwd.hpp
@@ -0,0 +1,13 @@
+#pragma once
+
+// Archive.hpp
+class DataArchive;
+
+// BaseDataStream.hpp
+class BaseDataStream;
+class InputDataStream;
+class OutputDataStream;
+
+// FileStream.hpp
+class InputFileStream;
+class OutputFileStream;
diff --git a/app/source/Cplt/Utils/Macros.hpp b/app/source/Cplt/Utils/Macros.hpp
new file mode 100644
index 0000000..6958ed1
--- /dev/null
+++ b/app/source/Cplt/Utils/Macros.hpp
@@ -0,0 +1,13 @@
+#pragma once
+
+#define STRINGIFY_IMPL(text) #text
+#define STRINGIFY(text) STRINGIFY_IMPL(text)
+
+#define CONCAT_IMPL(a, b) a##b
+#define CONCAT(a, b) CONCAT_IMPL(a, b)
+#define CONCAT_3(a, b, c) CONCAT(a, CONCAT(b, c))
+#define CONCAT_4(a, b, c, d) CONCAT(CONCAT(a, b), CONCAT(c, d))
+
+#define UNIQUE_NAME(prefix) CONCAT(prefix, __COUNTER__)
+#define UNIQUE_NAME_LINE(prefix) CONCAT(prefix, __LINE__)
+#define DISCARD UNIQUE_NAME(_discard)
diff --git a/app/source/Cplt/Utils/Math.hpp b/app/source/Cplt/Utils/Math.hpp
new file mode 100644
index 0000000..da53da2
--- /dev/null
+++ b/app/source/Cplt/Utils/Math.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+namespace MathUtils {
+
+template <class T>
+constexpr T Abs(T t)
+{
+ return t < 0 ? -t : t;
+}
+
+} // namespace MathUtils
diff --git a/app/source/Cplt/Utils/RTTI.hpp b/app/source/Cplt/Utils/RTTI.hpp
new file mode 100644
index 0000000..86b1e2c
--- /dev/null
+++ b/app/source/Cplt/Utils/RTTI.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+#include <cassert>
+
+template <class T, class TBase>
+bool is_a(TBase* t)
+{
+ assert(t != nullptr);
+ return T::IsInstance(t);
+}
+
+template <class T, class TBase>
+bool is_a_nullable(TBase* t)
+{
+ if (t) {
+ return is_a<T, TBase>(t);
+ } else {
+ return false;
+ }
+}
+
+template <class T, class TBase>
+T* dyn_cast(TBase* t)
+{
+ assert(t != nullptr);
+ if (T::IsInstance(t)) {
+ return static_cast<T*>(t);
+ } else {
+ return nullptr;
+ }
+}
+
+template <class T, class TBase>
+const T* dyn_cast(const TBase* t)
+{
+ assert(t != nullptr);
+ if (T::IsInstance(t)) {
+ return static_cast<const T*>(t);
+ } else {
+ return nullptr;
+ }
+}
+
+template <class T, class TBase>
+T* dyn_cast_nullable(TBase* t)
+{
+ if (!t) return nullptr;
+ return dyn_cast<T, TBase>(t);
+}
diff --git a/app/source/Cplt/Utils/ScopeGuard.hpp b/app/source/Cplt/Utils/ScopeGuard.hpp
new file mode 100644
index 0000000..f2b7f46
--- /dev/null
+++ b/app/source/Cplt/Utils/ScopeGuard.hpp
@@ -0,0 +1,39 @@
+#pragma once
+
+#include <Cplt/Utils/Macros.hpp>
+
+#include <utility>
+
+template <class TCleanupFunc>
+class ScopeGuard
+{
+private:
+ TCleanupFunc mFunc;
+ bool mDismissed = false;
+
+public:
+ /// Specifically left this implicit so that constructs like
+ /// \code
+ /// ScopeGuard sg = [&]() { res.Cleanup(); };
+ /// \endcode
+ /// would work. It is highly discourage and unlikely that one would want to use ScopeGuard as a function
+ /// parameter, so the normal argument that implicit conversion are harmful doesn't really apply here.
+ ScopeGuard(TCleanupFunc func)
+ : mFunc{ std::move(func) }
+ {
+ }
+
+ ~ScopeGuard()
+ {
+ if (!mDismissed) {
+ mFunc();
+ }
+ }
+
+ void Dismiss() noexcept
+ {
+ mDismissed = true;
+ }
+};
+
+#define DEFER ScopeGuard UNIQUE_NAME(scopeGuard) = [&]()
diff --git a/app/source/Cplt/Utils/Sigslot.cpp b/app/source/Cplt/Utils/Sigslot.cpp
new file mode 100644
index 0000000..1132dfb
--- /dev/null
+++ b/app/source/Cplt/Utils/Sigslot.cpp
@@ -0,0 +1,233 @@
+#include "Sigslot.hpp"
+
+#include <doctest/doctest.h>
+
+bool SignalStub::Connection::IsOccupied() const
+{
+ return id != InvalidId;
+}
+
+SignalStub::SignalStub(IWrapper& wrapper)
+ : mWrapper{ &wrapper }
+{
+}
+
+SignalStub::~SignalStub()
+{
+ RemoveAllConnections();
+}
+
+std::span<const SignalStub::Connection> SignalStub::GetConnections() const
+{
+ return mConnections;
+}
+
+SignalStub::Connection& SignalStub::InsertConnection(SlotGuard* guard)
+{
+ Connection* result;
+ int size = static_cast<int>(mConnections.size());
+ for (int i = 0; i < size; ++i) {
+ auto& conn = mConnections[i];
+ if (!conn.IsOccupied()) {
+ result = &conn;
+ result->id = i;
+ goto setup;
+ }
+ }
+
+ mConnections.push_back(Connection{});
+ result = &mConnections.back();
+ result->id = size;
+
+setup:
+ if (guard) {
+ result->guard = guard;
+ result->slotId = guard->InsertConnection(*this, result->id);
+ }
+ return *result;
+}
+
+void SignalStub::RemoveConnection(int id)
+{
+ if (id >= 0 && id < mConnections.size()) {
+ auto& conn = mConnections[id];
+ if (conn.IsOccupied()) {
+ mWrapper->RemoveFunction(conn.id);
+ if (conn.guard) {
+ conn.guard->RemoveConnection(conn.slotId);
+ }
+
+ conn.guard = nullptr;
+ conn.slotId = SignalStub::InvalidId;
+ conn.id = SignalStub::InvalidId;
+ }
+ }
+}
+
+void SignalStub::RemoveConnectionFor(SlotGuard& guard)
+{
+ guard.RemoveConnectionFor(*this);
+}
+
+void SignalStub::RemoveAllConnections()
+{
+ for (size_t i = 0; i < mConnections.size(); ++i) {
+ RemoveConnection(i);
+ }
+}
+
+SlotGuard::SlotGuard()
+{
+}
+
+SlotGuard::~SlotGuard()
+{
+ DisconnectAll();
+}
+
+void SlotGuard::DisconnectAll()
+{
+ for (auto& conn : mConnections) {
+ if (conn.stub) {
+ // Also calls SlotGuard::removeConnection, our copy of the data will be cleared in it
+ conn.stub->RemoveConnection(conn.stubId);
+ }
+ }
+}
+
+int SlotGuard::InsertConnection(SignalStub& stub, int stubId)
+{
+ int size = static_cast<int>(mConnections.size());
+ for (int i = 0; i < size; ++i) {
+ auto& conn = mConnections[i];
+ if (!conn.stub) {
+ conn.stub = &stub;
+ conn.stubId = stubId;
+ return i;
+ }
+ }
+
+ mConnections.push_back(Connection{});
+ auto& conn = mConnections.back();
+ conn.stub = &stub;
+ conn.stubId = stubId;
+ return size;
+}
+
+void SlotGuard::RemoveConnectionFor(SignalStub& stub)
+{
+ for (auto& conn : mConnections) {
+ if (conn.stub == &stub) {
+ conn.stub->RemoveConnection(conn.stubId);
+ }
+ }
+}
+
+void SlotGuard::RemoveConnection(int slotId)
+{
+ mConnections[slotId] = {};
+}
+
+TEST_CASE("Signal connect and disconnect")
+{
+ Signal<> sig;
+
+ int counter = 0;
+ int id = sig.Connect([&]() { counter++; });
+
+ sig();
+ CHECK(counter == 1);
+
+ sig();
+ CHECK(counter == 2);
+
+ sig.Disconnect(id);
+ sig();
+ CHECK(counter == 2);
+}
+
+TEST_CASE("Signal with parameters")
+{
+ Signal<int> sig;
+
+ int counter = 0;
+ int id = sig.Connect([&](int i) { counter += i; });
+
+ sig(1);
+ CHECK(counter == 1);
+
+ sig(0);
+ CHECK(counter == 1);
+
+ sig(4);
+ CHECK(counter == 5);
+
+ sig.Disconnect(id);
+ sig(1);
+ CHECK(counter == 5);
+}
+
+TEST_CASE("Signal disconnectAll()")
+{
+ Signal<> sig;
+
+ int counter1 = 0;
+ int counter2 = 0;
+ sig.Connect([&]() { counter1++; });
+ sig.Connect([&]() { counter2++; });
+
+ sig();
+ CHECK(counter1 == 1);
+ CHECK(counter2 == 1);
+
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+
+ sig.DisconnectAll();
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+}
+
+TEST_CASE("SlotGuard auto-disconnection")
+{
+ int counter1 = 0;
+ int counter2 = 0;
+ Signal<> sig;
+
+ {
+ SlotGuard guard;
+ sig.Connect(guard, [&]() { counter1 += 1; });
+ sig.Connect(guard, [&]() { counter2 += 1; });
+
+ sig();
+ CHECK(counter1 == 1);
+ CHECK(counter2 == 1);
+
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+ }
+
+ sig();
+ CHECK(counter1 == 2);
+ CHECK(counter2 == 2);
+}
+
+TEST_CASE("Signal destruct before SlotGuard")
+{
+ int counter = 0;
+ SlotGuard guard;
+
+ {
+ Signal<> sig2;
+ sig2.Connect(guard, [&]() { counter++; });
+
+ sig2();
+ CHECK(counter == 1);
+ }
+
+ // Shouldn't error
+ guard.DisconnectAll();
+}
diff --git a/app/source/Cplt/Utils/Sigslot.hpp b/app/source/Cplt/Utils/Sigslot.hpp
new file mode 100644
index 0000000..a4ab94e
--- /dev/null
+++ b/app/source/Cplt/Utils/Sigslot.hpp
@@ -0,0 +1,165 @@
+#pragma once
+
+#include <Cplt/Utils/fwd.hpp>
+
+#include <cstddef>
+#include <functional>
+#include <span>
+#include <utility>
+#include <vector>
+
+class SignalStub
+{
+public:
+ /// Non-template interface for Signal<T...> to implement (a barrier to stop template
+ /// arguments propagation).
+ class IWrapper
+ {
+ public:
+ virtual ~IWrapper() = default;
+ virtual void RemoveFunction(int id) = 0;
+ };
+
+ enum
+ {
+ InvalidId = -1,
+ };
+
+ struct Connection
+ {
+ SlotGuard* guard;
+ int slotId;
+ int id = InvalidId; // If `InvalidId`, then this "spot" is unused
+
+ bool IsOccupied() const;
+ };
+
+private:
+ std::vector<Connection> mConnections;
+ IWrapper* mWrapper;
+
+private:
+ template <class...>
+ friend class Signal;
+ friend class SlotGuard;
+
+ SignalStub(IWrapper& wrapper);
+ ~SignalStub();
+
+ SignalStub(const SignalStub&) = delete;
+ SignalStub& operator=(const SignalStub&) = delete;
+ SignalStub(SignalStub&&) = default;
+ SignalStub& operator=(SignalStub&&) = default;
+
+ std::span<const Connection> GetConnections() const;
+ Connection& InsertConnection(SlotGuard* guard = nullptr);
+ void RemoveConnection(int id);
+ void RemoveConnectionFor(SlotGuard& guard);
+ void RemoveAllConnections();
+};
+
+template <class... TArgs>
+class Signal : public SignalStub::IWrapper
+{
+private:
+ // Must be in this order so that mFunctions is still intact when mStub's destructor runs
+ std::vector<std::function<void(TArgs...)>> mFunctions;
+ SignalStub mStub;
+
+public:
+ Signal()
+ : mStub(*this)
+ {
+ }
+
+ virtual ~Signal() = default;
+
+ Signal(const Signal&) = delete;
+ Signal& operator=(const Signal&) = delete;
+ Signal(Signal&&) = default;
+ Signal& operator=(Signal&&) = default;
+
+ void operator()(TArgs... args)
+ {
+ for (auto& conn : mStub.GetConnections()) {
+ if (conn.IsOccupied()) {
+ mFunctions[conn.id](std::forward<TArgs>(args)...);
+ }
+ }
+ }
+
+ template <class TFunction>
+ int Connect(TFunction slot)
+ {
+ auto& conn = mStub.InsertConnection();
+ mFunctions.resize(std::max(mFunctions.size(), (size_t)conn.id + 1));
+ mFunctions[conn.id] = std::move(slot);
+ return conn.id;
+ }
+
+ template <class TFunction>
+ int Connect(SlotGuard& guard, TFunction slot)
+ {
+ auto& conn = mStub.InsertConnection(&guard);
+ mFunctions.resize(std::max(mFunctions.size(), (size_t)conn.id + 1));
+ mFunctions[conn.id] = std::move(slot);
+ return conn.id;
+ }
+
+ void Disconnect(int id)
+ {
+ mStub.RemoveConnection(id);
+ }
+
+ void DisconnectFor(SlotGuard& guard)
+ {
+ mStub.RemoveConnectionFor(guard);
+ }
+
+ void DisconnectAll()
+ {
+ mStub.RemoveAllConnections();
+ }
+
+ virtual void RemoveFunction(int id)
+ {
+ mFunctions[id] = {};
+ }
+};
+
+/// Automatic disconnection mechanism for Signal<>.
+/// Bind connection to this guard by using the Connect(SlotGuard&, TFunction) overload.
+/// Either DisconnectAll() or the destructor disconnects all connections bound to this guard.
+class SlotGuard
+{
+private:
+ struct Connection
+ {
+ SignalStub* stub = nullptr;
+ int stubId = SignalStub::InvalidId;
+ };
+ std::vector<Connection> mConnections;
+
+public:
+ friend class SignalStub;
+ SlotGuard();
+ ~SlotGuard();
+
+ SlotGuard(const SlotGuard&) = delete;
+ SlotGuard& operator=(const SlotGuard&) = delete;
+ SlotGuard(SlotGuard&&) = default;
+ SlotGuard& operator=(SlotGuard&&) = default;
+
+ /// DisconnectBySource all connection associated with this SlotGuard.
+ void DisconnectAll();
+
+private:
+ /// \return Slot id.
+ int InsertConnection(SignalStub& stub, int stubId);
+ /// Remove the connection data in this associated with slotId. This does not invoke
+ /// the connections' stub's RemoveConnection function.
+ void RemoveConnection(int slotId);
+ /// DisconnectBySource all connections from the given stub associated with this SlotGuard.
+ /// Implementation for SignalStub::RemoveConnectionsFor(SlotGuard&)
+ void RemoveConnectionFor(SignalStub& stub);
+};
diff --git a/app/source/Cplt/Utils/Size.hpp b/app/source/Cplt/Utils/Size.hpp
new file mode 100644
index 0000000..ae38e8a
--- /dev/null
+++ b/app/source/Cplt/Utils/Size.hpp
@@ -0,0 +1,65 @@
+#pragma once
+
+#include <Cplt/Utils/Vector.hpp>
+
+template <class T>
+class Size2 {
+public:
+ T width;
+ T height;
+
+public:
+ Size2()
+ : width{ 0 }, height{ 0 } {
+ }
+
+ Size2(T width, T height)
+ : width{ width }, height{ height } {
+ }
+
+ Size2(Vec2<T> vec)
+ : width{ vec.x }, height{ vec.y }
+ {
+ }
+
+ operator Vec2<T>() const
+ {
+ return { width, height };
+ }
+
+ Vec2<T> AsVec() const
+ {
+ return { width, height };
+ }
+
+ friend bool operator==(const Size2<T>&, const Size2<T>&) = default;
+
+ template <class TTarget>
+ Size2<TTarget> Cast() const
+ {
+ return {
+ static_cast<TTarget>(width),
+ static_cast<TTarget>(height),
+ };
+ }
+};
+
+template <class T>
+Size2<T> operator+(Size2<T> a, Size2<T> b) {
+ return { a.width + b.width, a.height + b.height };
+}
+
+template <class T>
+Size2<T> operator-(Size2<T> a, Size2<T> b) {
+ return { a.width - b.width, a.height - b.height };
+}
+
+template <class T, class N>
+auto operator*(Size2<T> a, N mult) -> Size2<decltype(a.width * mult)> {
+ return { a.width * mult, a.height * mult };
+}
+
+template <class T, class N>
+auto operator/(Size2<T> a, N mult) -> Size2<decltype(a.width / mult)> {
+ return { a.width / mult, a.height / mult };
+}
diff --git a/app/source/Cplt/Utils/StandardDirectories.cpp b/app/source/Cplt/Utils/StandardDirectories.cpp
new file mode 100644
index 0000000..2202f51
--- /dev/null
+++ b/app/source/Cplt/Utils/StandardDirectories.cpp
@@ -0,0 +1,78 @@
+#include "StandardDirectories.hpp"
+
+#include <filesystem>
+#include <stdexcept>
+
+namespace fs = std::filesystem;
+
+#if defined(_WIN32)
+// https://stackoverflow.com/questions/54499256/how-to-find-the-saved-games-folder-programmatically-in-c-c
+# include <ShlObj_core.h>
+# include <objbase.h>
+# pragma comment(lib, "shell32.lib")
+# pragma comment(lib, "ole32.lib")
+
+static fs::path GetAppDataRoaming()
+{
+ PWSTR path = nullptr;
+ HRESULT hr = SHGetKnownFolderPath(FOLDERID_RoamingAppData, KF_FLAG_CREATE, nullptr, &path);
+ if (SUCCEEDED(hr)) {
+ auto dataDir = fs::path(path);
+ CoTaskMemFree(path);
+
+ fs::create_directories(dataDir);
+ return dataDir;
+ } else {
+ fs::path dataDir("~/AppData/Roaming");
+ fs::create_directories(dataDir);
+ return dataDir;
+ }
+}
+
+#elif defined(__APPLE__)
+// TODO
+#elif defined(__linux__)
+# include <cstdlib>
+
+static fs::path GetEnvVar(const char* name, const char* backup)
+{
+ if (const char* path = std::getenv(name)) {
+ fs::path dataDir(path);
+ fs::create_directories(dataDir);
+ return dataDir;
+ } else {
+ fs::path dataDir(backup);
+ fs::create_directories(dataDir);
+ return dataDir;
+ }
+}
+
+#endif
+
+const std::filesystem::path& StandardDirectories::UserData()
+{
+ static auto userDataDir = []() -> fs::path {
+#if defined(_WIN32)
+ return GetAppDataRoaming();
+#elif defined(__APPLE__)
+ // TODO where?
+#elif defined(__linux__)
+ return GetEnvVar("XDG_DATA_HOME", "~/.local/share");
+#endif
+ }();
+ return userDataDir;
+}
+
+const std::filesystem::path& StandardDirectories::UserConfig()
+{
+ static auto userConfigDir = []() -> fs::path {
+#if defined(_WIN32)
+ return GetAppDataRoaming();
+#elif defined(__APPLE__)
+ // TODO where?
+#elif defined(__linux__)
+ return GetEnvVar("XDG_CONFIG_HOME", "~/.config");
+#endif
+ }();
+ return userConfigDir;
+}
diff --git a/app/source/Cplt/Utils/StandardDirectories.hpp b/app/source/Cplt/Utils/StandardDirectories.hpp
new file mode 100644
index 0000000..4f7e5e2
--- /dev/null
+++ b/app/source/Cplt/Utils/StandardDirectories.hpp
@@ -0,0 +1,10 @@
+#pragma once
+
+#include <filesystem>
+
+namespace StandardDirectories {
+
+const std::filesystem::path& UserData();
+const std::filesystem::path& UserConfig();
+
+} // namespace StandardDirectories
diff --git a/app/source/Cplt/Utils/Time.cpp b/app/source/Cplt/Utils/Time.cpp
new file mode 100644
index 0000000..4e79ffa
--- /dev/null
+++ b/app/source/Cplt/Utils/Time.cpp
@@ -0,0 +1,29 @@
+#include "Time.hpp"
+
+#include <ctime>
+
+std::string TimeUtils::StringifyTimePoint(std::chrono::time_point<std::chrono::system_clock> tp)
+{
+ auto t = std::chrono::system_clock::to_time_t(tp);
+
+ char data[32];
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations" // C++ doesn't have std::localtime_s
+ std::strftime(data, sizeof(data), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
+#pragma clang diagnostic pop
+
+ return std::string(data);
+}
+
+std::string TimeUtils::StringifyTimeStamp(int64_t timeStamp)
+{
+ if (timeStamp == 0) {
+ return "";
+ }
+
+ namespace chrono = std::chrono;
+ chrono::milliseconds d{ timeStamp };
+ chrono::time_point<chrono::system_clock> tp{ d };
+
+ return StringifyTimePoint(tp);
+}
diff --git a/app/source/Cplt/Utils/Time.hpp b/app/source/Cplt/Utils/Time.hpp
new file mode 100644
index 0000000..fbbd3b2
--- /dev/null
+++ b/app/source/Cplt/Utils/Time.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#include <chrono>
+#include <string>
+
+namespace TimeUtils {
+
+std::string StringifyTimePoint(std::chrono::time_point<std::chrono::system_clock> tp);
+std::string StringifyTimeStamp(int64_t timeStamp);
+
+} // namespace TimeUtils
diff --git a/app/source/Cplt/Utils/UUID.hpp b/app/source/Cplt/Utils/UUID.hpp
new file mode 100644
index 0000000..9044aa6
--- /dev/null
+++ b/app/source/Cplt/Utils/UUID.hpp
@@ -0,0 +1,5 @@
+#pragma once
+
+#define WIN32_LEAN_AND_MEAN
+#define NOMINMAX
+#include <uuid.h>
diff --git a/app/source/Cplt/Utils/Variant.hpp b/app/source/Cplt/Utils/Variant.hpp
new file mode 100644
index 0000000..df2f882
--- /dev/null
+++ b/app/source/Cplt/Utils/Variant.hpp
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <utility>
+#include <variant>
+
+template <class... Ts>
+struct Overloaded : Ts...
+{
+ using Ts::operator()...;
+};
+template <class... Ts>
+Overloaded(Ts...) -> Overloaded<Ts...>;
+
+template <class... Args>
+struct VariantCastProxy
+{
+ std::variant<Args...> v;
+
+ template <class... ToArgs>
+ operator std::variant<ToArgs...>() const
+ {
+ return std::visit(
+ [](auto&& arg) -> std::variant<ToArgs...> { return arg; },
+ v);
+ }
+};
+
+/// Use snake_case naming to align with `static_cast`, `dynamic_cast`, etc..
+template <class... Args>
+auto variant_cast(std::variant<Args...> v) -> VariantCastProxy<Args...>
+{
+ return { std::move(v) };
+}
diff --git a/app/source/Cplt/Utils/Vector.hpp b/app/source/Cplt/Utils/Vector.hpp
new file mode 100644
index 0000000..79f4ea2
--- /dev/null
+++ b/app/source/Cplt/Utils/Vector.hpp
@@ -0,0 +1,144 @@
+#pragma once
+
+#include <Cplt/Utils/IO/DataStream.hpp>
+
+template <class T>
+struct Vec2
+{
+ T x = 0;
+ T y = 0;
+
+ template <class TTarget>
+ Vec2<TTarget> Cast() const
+ {
+ return {
+ static_cast<TTarget>(x),
+ static_cast<TTarget>(y),
+ };
+ }
+
+ void ReadFromDataStream(InputDataStream& stream)
+ {
+ stream.Value(x);
+ stream.Value(y);
+ }
+
+ void WriteToDataStream(OutputDataStream& stream) const
+ {
+ stream.Value(x);
+ stream.Value(y);
+ }
+
+ friend constexpr bool operator==(const Vec2& a, const Vec2& b) = default;
+
+ friend constexpr Vec2 operator+(const Vec2& a, const Vec2& b) { return { a.x + b.x, a.y + b.y }; }
+ friend constexpr Vec2 operator-(const Vec2& a, const Vec2& b) { return { a.x - b.x, a.y - b.y }; }
+ friend constexpr Vec2 operator*(const Vec2& a, const Vec2& b) { return { a.x * b.x, a.y * b.y }; }
+ friend constexpr Vec2 operator/(const Vec2& a, const Vec2& b) { return { a.x / b.x, a.y / b.y }; }
+
+ friend constexpr Vec2 operator+(const Vec2& a, T n) { return { a.x + n, a.y + n }; }
+ friend constexpr Vec2 operator-(const Vec2& a, T n) { return { a.x - n, a.y - n }; }
+ friend constexpr Vec2 operator*(const Vec2& a, T n) { return { a.x * n, a.y * n }; }
+ friend constexpr Vec2 operator/(const Vec2& a, T n) { return { a.x / n, a.y / n }; }
+};
+
+using Vec2i = Vec2<int>;
+using Vec2f = Vec2<float>;
+
+template <class T>
+struct Vec3
+{
+ T x = 0;
+ T y = 0;
+ T z = 0;
+
+ template <class TTarget>
+ Vec3<TTarget> Cast() const
+ {
+ return {
+ static_cast<TTarget>(x),
+ static_cast<TTarget>(y),
+ static_cast<TTarget>(z),
+ };
+ }
+
+ void ReadFromDataStream(InputDataStream& stream)
+ {
+ stream.Value(x);
+ stream.Value(y);
+ stream.Value(z);
+ }
+
+ void WriteToDataStream(OutputDataStream& stream) const
+ {
+ stream.Value(x);
+ stream.Value(y);
+ stream.Value(z);
+ }
+
+ friend constexpr bool operator==(const Vec3& a, const Vec3& b) = default;
+
+ friend constexpr Vec3 operator+(const Vec3& a, const Vec3& b) { return { a.x + b.x, a.y + b.y, a.z + b.z }; }
+ friend constexpr Vec3 operator-(const Vec3& a, const Vec3& b) { return { a.x - b.x, a.y - b.y, a.z - b.z }; }
+ friend constexpr Vec3 operator*(const Vec3& a, const Vec3& b) { return { a.x * b.x, a.y * b.y, a.z * b.z }; }
+ friend constexpr Vec3 operator/(const Vec3& a, const Vec3& b) { return { a.x / b.x, a.y / b.y, a.z / b.z }; }
+
+ friend constexpr Vec3 operator+(const Vec3& a, T n) { return { a.x + n, a.y + n, a.z + n }; }
+ friend constexpr Vec3 operator-(const Vec3& a, T n) { return { a.x - n, a.y - n, a.z - n }; }
+ friend constexpr Vec3 operator*(const Vec3& a, T n) { return { a.x * n, a.y * n, a.z * n }; }
+ friend constexpr Vec3 operator/(const Vec3& a, T n) { return { a.x / n, a.y / n, a.z / n }; }
+};
+
+using Vec3i = Vec3<int>;
+using Vec3f = Vec3<float>;
+
+template <class T>
+struct Vec4
+{
+ T x = 0;
+ T y = 0;
+ T z = 0;
+ T w = 0;
+
+ template <class TTarget>
+ Vec4<TTarget> Cast() const
+ {
+ return {
+ static_cast<TTarget>(x),
+ static_cast<TTarget>(y),
+ static_cast<TTarget>(z),
+ static_cast<TTarget>(w),
+ };
+ }
+
+ void ReadFromDataStream(InputDataStream& stream)
+ {
+ stream.Value(x);
+ stream.Value(y);
+ stream.Value(z);
+ stream.Value(w);
+ }
+
+ void WriteToDataStream(OutputDataStream& stream) const
+ {
+ stream.Value(x);
+ stream.Value(y);
+ stream.Value(z);
+ stream.Value(w);
+ }
+
+ friend constexpr bool operator==(const Vec4& a, const Vec4& b) = default;
+
+ friend constexpr Vec4 operator+(const Vec4& a, const Vec4& b) { return { a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w }; }
+ friend constexpr Vec4 operator-(const Vec4& a, const Vec4& b) { return { a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w }; }
+ friend constexpr Vec4 operator*(const Vec4& a, const Vec4& b) { return { a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w }; }
+ friend constexpr Vec4 operator/(const Vec4& a, const Vec4& b) { return { a.x / b.x, a.y / b.y, a.z / b.z, a.w / b.w }; }
+
+ friend constexpr Vec4 operator+(const Vec4& a, T n) { return { a.x + n, a.y + n, a.z + n, a.w + n }; }
+ friend constexpr Vec4 operator-(const Vec4& a, T n) { return { a.x - n, a.y - n, a.z - n, a.w - n }; }
+ friend constexpr Vec4 operator*(const Vec4& a, T n) { return { a.x * n, a.y * n, a.z * n, a.w * n }; }
+ friend constexpr Vec4 operator/(const Vec4& a, T n) { return { a.x / n, a.y / n, a.z / n, a.w / n }; }
+};
+
+using Vec4i = Vec4<int>;
+using Vec4f = Vec4<float>;
diff --git a/app/source/Cplt/Utils/VectorHash.hpp b/app/source/Cplt/Utils/VectorHash.hpp
new file mode 100644
index 0000000..f649367
--- /dev/null
+++ b/app/source/Cplt/Utils/VectorHash.hpp
@@ -0,0 +1,46 @@
+#pragma once
+
+#include <Cplt/Utils/Hash.hpp>
+#include <Cplt/Utils/Vector.hpp>
+
+#include <cstddef>
+#include <functional>
+
+template <class T>
+struct std::hash<Vec2<T>>
+{
+ size_t operator()(const Vec2<T>& vec) const
+ {
+ size_t result;
+ HashUtils::Combine(result, vec.x);
+ HashUtils::Combine(result, vec.y);
+ return result;
+ }
+};
+
+template <class T>
+struct std::hash<Vec3<T>>
+{
+ size_t operator()(const Vec3<T>& vec) const
+ {
+ size_t result;
+ HashUtils::Combine(result, vec.x);
+ HashUtils::Combine(result, vec.y);
+ HashUtils::Combine(result, vec.z);
+ return result;
+ }
+};
+
+template <class T>
+struct std::hash<Vec4<T>>
+{
+ size_t operator()(const Vec4<T>& vec) const
+ {
+ size_t result;
+ HashUtils::Combine(result, vec.x);
+ HashUtils::Combine(result, vec.y);
+ HashUtils::Combine(result, vec.z);
+ HashUtils::Combine(result, vec.w);
+ return result;
+ }
+};
diff --git a/app/source/Cplt/Utils/fwd.hpp b/app/source/Cplt/Utils/fwd.hpp
new file mode 100644
index 0000000..366cacc
--- /dev/null
+++ b/app/source/Cplt/Utils/fwd.hpp
@@ -0,0 +1,17 @@
+#pragma once
+
+#include <Cplt/Utils/IO/fwd.hpp>
+
+// Color.hpp
+class RgbaColor;
+class HsvColor;
+
+// Sigslot.hpp
+class SignalStub;
+template <class... TArgs>
+class Signal;
+class SlotGuard;
+
+// String.hpp
+class Utf8Iterator;
+class Utf8IterableString;
diff --git a/app/source/Cplt/fwd.hpp b/app/source/Cplt/fwd.hpp
new file mode 100644
index 0000000..97f669d
--- /dev/null
+++ b/app/source/Cplt/fwd.hpp
@@ -0,0 +1,5 @@
+#pragma once
+
+#include <Cplt/Model/fwd.hpp>
+#include <Cplt/UI/fwd.hpp>
+#include <Cplt/Utils/fwd.hpp>