diff --git a/.gitignore b/.gitignore
index 8cc1d54c..9e3989eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
.idea/
.claude/
Build/
+build/
Bin/
Intermediate/
out/
diff --git a/.gitmodules b/.gitmodules
index d1f45c7d..d93c416b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -40,6 +40,9 @@
[submodule "msdf-atlas-gen"]
path = Elixir/Vendor/msdf-atlas-gen
url = https://github.com/Chlumsky/msdf-atlas-gen
+[submodule "Elixir/Vendor/lunasvg"]
+ path = Elixir/Vendor/lunasvg
+ url = https://github.com/sammycage/lunasvg.git
[submodule "Vendor/vcpkg"]
path = Vendor/vcpkg
url = https://github.com/microsoft/vcpkg
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..cd1b5e55
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,5 @@
+# Instructions for AI agents
+
+## C++ naming
+
+- Never use `b` as a prefix or suffix to denote a `bool`. Prefer concise names that are clear from their context, such as `Cancelled` or `Completed`; use predicates such as `IsVisible`, `HasFocus`, `CanRender`, or `ShouldUpdate` when needed for clarity.
diff --git a/Assets/Icons/chevron-right.svg b/Assets/Icons/chevron-right.svg
new file mode 100644
index 00000000..19752c5e
--- /dev/null
+++ b/Assets/Icons/chevron-right.svg
@@ -0,0 +1,3 @@
+
diff --git a/Assets/Icons/close.svg b/Assets/Icons/close.svg
new file mode 100644
index 00000000..75f9d9bc
--- /dev/null
+++ b/Assets/Icons/close.svg
@@ -0,0 +1,3 @@
+
diff --git a/Assets/Icons/git-branch.svg b/Assets/Icons/git-branch.svg
new file mode 100644
index 00000000..a0675d24
--- /dev/null
+++ b/Assets/Icons/git-branch.svg
@@ -0,0 +1,5 @@
+
diff --git a/Assets/Icons/hierarchy.svg b/Assets/Icons/hierarchy.svg
new file mode 100644
index 00000000..1505e9ce
--- /dev/null
+++ b/Assets/Icons/hierarchy.svg
@@ -0,0 +1,3 @@
+
diff --git a/Assets/Icons/inspector.svg b/Assets/Icons/inspector.svg
new file mode 100644
index 00000000..ff0286d4
--- /dev/null
+++ b/Assets/Icons/inspector.svg
@@ -0,0 +1,6 @@
+
diff --git a/Assets/Icons/move.svg b/Assets/Icons/move.svg
new file mode 100644
index 00000000..c759a81e
--- /dev/null
+++ b/Assets/Icons/move.svg
@@ -0,0 +1,3 @@
+
diff --git a/Assets/Icons/pause.svg b/Assets/Icons/pause.svg
new file mode 100644
index 00000000..2942c88e
--- /dev/null
+++ b/Assets/Icons/pause.svg
@@ -0,0 +1,4 @@
+
diff --git a/Assets/Icons/play.svg b/Assets/Icons/play.svg
new file mode 100644
index 00000000..c93857c7
--- /dev/null
+++ b/Assets/Icons/play.svg
@@ -0,0 +1,3 @@
+
diff --git a/Assets/Icons/rotate.svg b/Assets/Icons/rotate.svg
new file mode 100644
index 00000000..b760eac6
--- /dev/null
+++ b/Assets/Icons/rotate.svg
@@ -0,0 +1,3 @@
+
diff --git a/Assets/Icons/scale.svg b/Assets/Icons/scale.svg
new file mode 100644
index 00000000..b5b2aa2e
--- /dev/null
+++ b/Assets/Icons/scale.svg
@@ -0,0 +1,4 @@
+
diff --git a/Assets/Icons/script.svg b/Assets/Icons/script.svg
new file mode 100644
index 00000000..50385a8a
--- /dev/null
+++ b/Assets/Icons/script.svg
@@ -0,0 +1,3 @@
+
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 41e8aded..2361f71a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -77,6 +77,7 @@ endif()
include(Shaders/Shaders.cmake)
include(Elixir/Elixir.cmake)
include(Dissolve/Dissolve.cmake)
+include(Editor/Editor.cmake)
# Ensure shaders are compiled before the engine builds
add_dependencies(Elixir CompileShaders)
diff --git a/Dissolve/Dissolve.cmake b/Dissolve/Dissolve.cmake
index a0740847..0dfab6e6 100644
--- a/Dissolve/Dissolve.cmake
+++ b/Dissolve/Dissolve.cmake
@@ -3,10 +3,11 @@ include (Utils.cmake)
project("Dissolve")
# Files
-add_executable(${PROJECT_NAME}
- ${CMAKE_CURRENT_LIST_DIR}/Source/Dissolve.h
- ${CMAKE_CURRENT_LIST_DIR}/Source/Dissolve.cpp
+file(GLOB_RECURSE SOURCES
+ "${CMAKE_CURRENT_LIST_DIR}/Source/*.h"
+ "${CMAKE_CURRENT_LIST_DIR}/Source/*.cpp"
)
+add_executable(${PROJECT_NAME} ${SOURCES})
# Set output name
set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "${PROJECT_NAME}")
diff --git a/Editor/Editor.cmake b/Editor/Editor.cmake
new file mode 100644
index 00000000..65015028
--- /dev/null
+++ b/Editor/Editor.cmake
@@ -0,0 +1,69 @@
+include (Utils.cmake)
+
+project("Editor")
+
+# Files
+file(GLOB_RECURSE SOURCES
+ "${CMAKE_CURRENT_LIST_DIR}/Source/*.h"
+ "${CMAKE_CURRENT_LIST_DIR}/Source/*.cpp"
+)
+add_executable(${PROJECT_NAME} ${SOURCES})
+
+# Set output name
+set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "${PROJECT_NAME}")
+
+# Properties
+
+set_target_properties(${PROJECT_NAME} PROPERTIES
+ CXX_STANDARD 20
+ CXX_STANDARD_REQUIRED YES
+ CXX_EXTENSIONS NO
+ POSITION_INDEPENDENT_CODE False
+ INTERPROCEDURAL_OPTIMIZATION False
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL"
+)
+
+set_target_properties(${PROJECT_NAME} PROPERTIES
+ ARCHIVE_OUTPUT_DIRECTORY "${OUTPUT_DIR}/${PROJECT_NAME}"
+ LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_DIR}/${PROJECT_NAME}"
+ RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_DIR}/${PROJECT_NAME}"
+)
+
+# Compile definitions
+
+target_compile_definitions(${PROJECT_NAME} PRIVATE
+ $<$:EE_PROFILE>
+ $<$:EE_DEBUG>
+ $<$:EE_RELEASE>
+ $<$:EE_DIST>
+)
+
+if (WIN32)
+ target_compile_definitions(${PROJECT_NAME} PRIVATE
+ _CRT_SECURE_NO_WARNINGS
+ EE_PLATFORM_WINDOWS
+ )
+elseif (APPLE)
+ target_compile_definitions(${PROJECT_NAME} PRIVATE
+ EE_PLATFORM_MACOS
+ )
+endif()
+
+if (APPLE)
+ target_compile_options(${PROJECT_NAME} PRIVATE -stdlib=libc++)
+ target_link_options(${PROJECT_NAME} PRIVATE -stdlib=libc++)
+endif()
+
+# Include dirs
+
+target_include_directories(${PROJECT_NAME} PRIVATE
+ ${CMAKE_SOURCE_DIR}/Elixir/Source
+)
+
+# Linking
+
+add_dependencies(${PROJECT_NAME}
+ Elixir
+)
+
+link_target_to_engine(${PROJECT_NAME})
diff --git a/Editor/Source/Editor.cpp b/Editor/Source/Editor.cpp
new file mode 100644
index 00000000..8bc37771
--- /dev/null
+++ b/Editor/Source/Editor.cpp
@@ -0,0 +1,64 @@
+#include "Editor.h"
+
+#include
+
+#include "UI/Panels/ViewportPanel.h"
+
+Editor::Editor()
+{
+ EE_PROFILE_ZONE_SCOPED()
+
+ m_Window->SetTitle("Editor");
+
+ m_EditorUI = CreateScope(m_GUIManager.get());
+ m_EditorUI->AddMenuItem("File");
+ m_EditorUI->AddMenuItem("Edit");
+ m_EditorUI->AddMenuItem("Assets");
+ m_EditorUI->AddMenuItem("GameObject");
+ m_EditorUI->AddMenuItem("Component");
+ m_EditorUI->AddMenuItem("Window");
+ m_EditorUI->AddMenuItem("Help");
+
+ m_EditorUI->AddTab("Scene", true);
+ m_EditorUI->AddTab("Game", false);
+
+ // Worked example of GUI::Manager's popup layer stack + GUI::ScrollBox: more items than
+ // fit the dropdown's fixed height, so opening it actually exercises scrolling.
+ m_EditorUI->AddDropdownMenu("Examples", {
+ "Scene", "Prefab", "Material", "Texture 2D", "Cubemap",
+ "Render Texture", "Animation", "Animator Controller",
+ "Audio Mixer", "Physics Material", "Shader Graph",
+ });
+
+ m_EditorUI->AddPanel(CreateScope());
+
+ m_GraphicsContext->SetClearColor({ 0.49f, 0.65f, 0.98f, 1.0f });
+}
+
+Editor::~Editor() = default;
+
+void Editor::OnGUI(const Timestep frameTime)
+{
+ EE_PROFILE_ZONE_SCOPED()
+ Application::OnGUI(frameTime);
+
+ m_EditorUI->Update(frameTime);
+}
+
+void Editor::OnRender(const Timestep frameTime)
+{
+ EE_PROFILE_ZONE_SCOPED()
+ Application::OnRender(frameTime);
+ m_GraphicsContext->Clear();
+}
+
+void Editor::OnEvent(Event& event)
+{
+ Application::OnEvent(event);
+}
+
+Application* Elixir::CreateApplication()
+{
+ EE_PROFILE_ZONE_SCOPED()
+ return new Editor();
+}
diff --git a/Editor/Source/Editor.h b/Editor/Source/Editor.h
new file mode 100644
index 00000000..a59767f8
--- /dev/null
+++ b/Editor/Source/Editor.h
@@ -0,0 +1,20 @@
+#pragma once
+
+#include
+
+#include "UI/EditorUI.h"
+
+class Editor final : public Elixir::Application
+{
+public:
+ Editor();
+ ~Editor() override;
+
+ void OnGUI(Timestep frameTime) override;
+ void OnRender(Timestep frameTime) override;
+
+ void OnEvent(Event& event) override;
+
+private:
+ Scope m_EditorUI;
+};
diff --git a/Editor/Source/UI/EditorPanel.h b/Editor/Source/UI/EditorPanel.h
new file mode 100644
index 00000000..efa7cb52
--- /dev/null
+++ b/Editor/Source/UI/EditorPanel.h
@@ -0,0 +1,19 @@
+#pragma once
+
+#include
+
+// Base class for a pluggable editor UI panel (Hierarchy, Inspector, Viewport, ...).
+// A panel owns a single root widget that EditorUI docks into the editor's content area.
+class EditorPanel
+{
+public:
+ virtual ~EditorPanel() = default;
+
+ virtual const char* GetName() const = 0;
+
+ // Build (or rebuild) this panel's widget tree and return its root widget.
+ virtual Ref Build() = 0;
+
+ // Called once per frame, after the base Application has ticked its own GUI.
+ virtual void OnUpdate(Timestep frameTime) {}
+};
diff --git a/Editor/Source/UI/EditorStyles.cpp b/Editor/Source/UI/EditorStyles.cpp
new file mode 100644
index 00000000..24c51f5b
--- /dev/null
+++ b/Editor/Source/UI/EditorStyles.cpp
@@ -0,0 +1,137 @@
+#include "EditorStyles.h"
+
+namespace
+{
+ const SColor Surface{ 0.118f, 0.122f, 0.133f, 1.0f };
+ const SColor SurfaceSunken{ 0.094f, 0.098f, 0.106f, 1.0f };
+ const SColor Border{ 0.224f, 0.231f, 0.251f, 1.0f };
+ const SColor PanelBorder{ 0.25f, 0.26f, 0.29f, 1.0f };
+ const SColor Panel{ 0.071f, 0.075f, 0.082f, 0.9f };
+ const SColor Section{ 0.169f, 0.176f, 0.188f, 1.0f };
+
+ SWidgetStyle MakeWidgetStyle(
+ const SColor color,
+ const SOutline outline = {},
+ const float radius = 0.0f
+ )
+ {
+ SWidgetStyle style;
+ style.Normal.Background.Color = color;
+ style.Normal.Background.Outline = outline;
+ style.Normal.Background.CornerRadius = glm::vec4(radius);
+ return style;
+ }
+
+ SButtonStyle MakeButtonStyle(const SWidgetStyle& widgetStyle)
+ {
+ SButtonStyle style;
+ style.Normal.Background = widgetStyle.Normal.Background;
+ style.Hovered = style.Normal;
+ style.Pressed = style.Normal;
+ style.Focused = style.Normal;
+ style.Disabled = style.Normal;
+ return style;
+ }
+
+ EditorStyle::SChromeStyles Create()
+ {
+ EditorStyle::SChromeStyles styles;
+ styles.TextPrimary = { 0.875f, 0.882f, 0.898f, 1.0f };
+ styles.TextSecondary = { 0.616f, 0.627f, 0.659f, 1.0f };
+ styles.Accent = { 0.208f, 0.455f, 0.941f, 1.0f };
+ styles.AxisX = { 0.86f, 0.23f, 0.29f, 1.0f };
+ styles.AxisY = { 0.37f, 0.68f, 0.40f, 1.0f };
+ styles.AxisZ = { 0.33f, 0.54f, 0.97f, 1.0f };
+
+ styles.MenuBar = MakeWidgetStyle(Surface);
+ styles.MenuBorder = MakeWidgetStyle(Border);
+ styles.BranchPill = MakeWidgetStyle(SurfaceSunken, { Border, 1.0f }, 4.0f);
+ styles.TabBar = MakeWidgetStyle(Surface);
+ styles.TabActiveIndicator = MakeWidgetStyle(styles.Accent);
+ styles.AssetBrowser = MakeWidgetStyle(Surface);
+ styles.Popup = MakeWidgetStyle({ 0.16f, 0.16f, 0.19f, 1.0f }, { Border, 1.0f }, 4.0f);
+ styles.Toolbar = MakeWidgetStyle({ Surface.R, Surface.G, Surface.B, 0.78f }, { PanelBorder, 1.0f }, 8.0f);
+ styles.Panel = MakeWidgetStyle(Panel, { PanelBorder, 1.0f }, 8.0f);
+ styles.PanelHeaderBorder = MakeWidgetStyle(PanelBorder);
+ styles.PanelSection = MakeWidgetStyle(Section);
+ styles.InspectorField = MakeWidgetStyle(SurfaceSunken, { Border, 1.0f }, 4.0f);
+ styles.Selection = MakeWidgetStyle({ styles.Accent.R, styles.Accent.G, styles.Accent.B, 0.28f }, {}, 4.0f);
+ styles.ToolbarButtonActive = MakeWidgetStyle(styles.Accent, {}, 4.0f);
+ styles.ToolbarButtonInactive = MakeWidgetStyle({}, {}, 4.0f);
+ styles.PlayButtonActive = MakeWidgetStyle({ 0.29f, 0.61f, 0.33f, 1.0f }, {}, 4.0f);
+ styles.PlayButtonInactive = MakeWidgetStyle({}, {}, 4.0f);
+ styles.PauseButtonActive = MakeWidgetStyle({ 0.35f, 0.36f, 0.40f, 1.0f }, {}, 4.0f);
+ styles.PauseButtonInactive = MakeWidgetStyle({}, {}, 4.0f);
+ styles.StatsOverlay = MakeWidgetStyle({ Surface.R, Surface.G, Surface.B, 0.55f }, { PanelBorder, 1.0f }, 5.0f);
+
+ styles.PanelHeaderButton = MakeButtonStyle(styles.Transparent);
+ styles.PanelToggleButton = MakeButtonStyle(styles.Panel);
+
+ styles.TextField.Normal.Background = styles.InspectorField.Normal.Background;
+ styles.TextField.Normal.Foreground = styles.TextPrimary;
+ styles.TextField.Hovered = styles.TextField.Normal;
+ styles.TextField.Focused = styles.TextField.Normal;
+ styles.TextField.Pressed = styles.TextField.Focused;
+ styles.TextField.Disabled = styles.TextField.Normal;
+ styles.TextField.Disabled->Background.Color.A = 0.5f;
+ styles.TextField.Disabled->Foreground.A = 0.5f;
+ styles.TransparentTextField = styles.TextField;
+ styles.TransparentTextField.Normal.Background = {};
+ styles.TransparentTextField.Hovered = styles.TransparentTextField.Normal;
+ styles.TransparentTextField.Focused = styles.TransparentTextField.Normal;
+ styles.TransparentTextField.Pressed = styles.TransparentTextField.Normal;
+ styles.TransparentTextField.Disabled = styles.TransparentTextField.Normal;
+
+ styles.Checkbox.Normal.Background = styles.InspectorField.Normal.Background;
+ styles.Checkbox.Normal.Background.CornerRadius = glm::vec4(3.0f);
+ styles.Checkbox.Hovered = styles.Checkbox.Normal;
+ styles.Checkbox.Pressed = styles.Checkbox.Hovered;
+ styles.Checkbox.Focused = styles.Checkbox.Normal;
+ styles.Checkbox.Disabled = styles.Checkbox.Normal;
+ styles.Checkbox.Checked.Background.Color = styles.Accent;
+ styles.Checkbox.Checked.Background.CornerRadius = glm::vec4(3.0f);
+ styles.Checkbox.CheckedHovered = styles.Checkbox.Checked;
+ styles.Checkbox.CheckedPressed = styles.Checkbox.Checked;
+ styles.Checkbox.CheckedFocused = styles.Checkbox.Checked;
+ styles.Checkbox.CheckedDisabled = styles.Checkbox.Checked;
+ styles.Checkbox.CheckedDisabled->Background.Color.A = 0.5f;
+
+ styles.ScrollBar.Thickness = 11.0f;
+ styles.ScrollBar.MinimumThumbLength = 18.0f;
+ styles.ScrollBar.Normal.Track.Color = {};
+ styles.ScrollBar.Normal.Thumb.Color = { 0.38f, 0.40f, 0.44f, 0.85f };
+ styles.ScrollBar.Normal.Thumb.CornerRadius = glm::vec4(6.0f);
+ styles.ScrollBar.Normal.Thumb.Outline = { Panel, 2.0f };
+ styles.ScrollBar.Hovered = styles.ScrollBar.Normal;
+ styles.ScrollBar.Hovered->Thumb.Color = { 0.50f, 0.52f, 0.56f, 0.95f };
+ styles.ScrollBar.Pressed = styles.ScrollBar.Hovered;
+ styles.ScrollBar.Focused = styles.ScrollBar.Normal;
+ styles.ScrollBar.Disabled = styles.ScrollBar.Normal;
+ styles.ScrollBar.Disabled->Thumb.Color.A = 0.35f;
+
+ styles.PrimaryIcon.Normal.Foreground = styles.TextPrimary;
+ styles.PrimaryIcon.Hovered = styles.PrimaryIcon.Normal;
+ styles.PrimaryIcon.Pressed = styles.PrimaryIcon.Normal;
+ styles.PrimaryIcon.Focused = styles.PrimaryIcon.Normal;
+ styles.PrimaryIcon.Disabled = styles.PrimaryIcon.Normal;
+ styles.PrimaryIcon.Disabled->Foreground.A = 0.5f;
+ styles.SecondaryIcon = styles.PrimaryIcon;
+ styles.SecondaryIcon.Normal.Foreground = styles.TextSecondary;
+ styles.SecondaryIcon.Hovered = styles.SecondaryIcon.Normal;
+ styles.SecondaryIcon.Pressed = styles.SecondaryIcon.Normal;
+ styles.SecondaryIcon.Focused = styles.SecondaryIcon.Normal;
+ styles.SecondaryIcon.Disabled = styles.SecondaryIcon.Normal;
+ styles.SecondaryIcon.Disabled->Foreground.A = 0.5f;
+
+ return styles;
+ }
+}
+
+namespace EditorStyle
+{
+ const SChromeStyles& Get()
+ {
+ static const SChromeStyles styles = Create();
+ return styles;
+ }
+}
diff --git a/Editor/Source/UI/EditorStyles.h b/Editor/Source/UI/EditorStyles.h
new file mode 100644
index 00000000..22e494aa
--- /dev/null
+++ b/Editor/Source/UI/EditorStyles.h
@@ -0,0 +1,59 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+using namespace Elixir;
+
+namespace EditorStyle
+{
+ /** @brief Stores the visual styles used by the Editor chrome. */
+ struct SChromeStyles
+ {
+ SWidgetStyle Transparent;
+ SWidgetStyle MenuBar;
+ SWidgetStyle MenuBorder;
+ SWidgetStyle BranchPill;
+ SWidgetStyle TabBar;
+ SWidgetStyle TabActiveIndicator;
+ SWidgetStyle AssetBrowser;
+ SWidgetStyle Popup;
+ SWidgetStyle Toolbar;
+ SWidgetStyle Panel;
+ SWidgetStyle PanelHeaderBorder;
+ SWidgetStyle PanelSection;
+ SWidgetStyle InspectorField;
+ SWidgetStyle Selection;
+ SWidgetStyle ToolbarButtonActive;
+ SWidgetStyle ToolbarButtonInactive;
+ SWidgetStyle PlayButtonActive;
+ SWidgetStyle PlayButtonInactive;
+ SWidgetStyle PauseButtonActive;
+ SWidgetStyle PauseButtonInactive;
+ SWidgetStyle StatsOverlay;
+ SButtonStyle PanelHeaderButton;
+ SButtonStyle PanelToggleButton;
+ STextFieldStyle TextField;
+ STextFieldStyle TransparentTextField;
+ SCheckboxStyle Checkbox;
+ SScrollBarStyle ScrollBar;
+ SIconStyle PrimaryIcon;
+ SIconStyle SecondaryIcon;
+ float PanelTransitionDelay = 0.12f;
+ float PanelTransitionDuration = 0.18f;
+ float PanelToggleShowDelay = 0.3f;
+ float PanelToggleDuration = 0.15f;
+ SColor TextPrimary;
+ SColor TextSecondary;
+ SColor Accent;
+ SColor AxisX;
+ SColor AxisY;
+ SColor AxisZ;
+ };
+
+ /** @brief Get the Editor's shared chrome styles. */
+ const SChromeStyles& Get();
+}
diff --git a/Editor/Source/UI/EditorUI.cpp b/Editor/Source/UI/EditorUI.cpp
new file mode 100644
index 00000000..95e605cf
--- /dev/null
+++ b/Editor/Source/UI/EditorUI.cpp
@@ -0,0 +1,289 @@
+#include "EditorUI.h"
+#include "EditorPanel.h"
+#include "EditorStyles.h"
+
+#include
+#include
+#include
+
+#include
+
+namespace
+{
+ constexpr float MenuBarHeight = 32.0f;
+ constexpr float TabBarHeight = 34.0f;
+ constexpr float AssetBrowserHeight = 28.0f;
+
+ constexpr float DropdownWidth = 160.0f;
+ constexpr float DropdownRowHeight = 26.0f;
+ constexpr float DropdownMaxVisibleRows = 6.0f;
+ constexpr float DropdownPadding = 4.0f;
+
+}
+
+EditorUI::EditorUI(GUI::Manager* guiManager)
+ : m_GUIManager(guiManager)
+{
+ Build();
+}
+
+void EditorUI::Build()
+{
+ m_Root = CreateRef();
+
+ BuildMenuBar();
+ BuildTabBar();
+
+ m_ContentArea = CreateRef();
+ m_Root->AddChild(m_ContentArea)
+ .SetAnchors(GUI::SAnchors::StretchAll())
+ .SetOffsets(0.0f, MenuBarHeight + TabBarHeight, 0.0f, -AssetBrowserHeight);
+
+ BuildAssetBrowser();
+
+ m_GUIManager->SetRoot(m_Root);
+}
+
+void EditorUI::BuildMenuBar()
+{
+ const auto& styles = EditorStyle::Get();
+ m_MenuBar = CreateRef();
+ m_MenuBar->SetStyle(styles.MenuBar);
+ m_MenuBar->SetPadding({ 10.0f, 0.0f });
+
+ m_Root->AddChild(m_MenuBar)
+ .SetAnchors({ 0.0f, 0.0f, 1.0f, 0.0f })
+ .SetOffsets(0.0f, 0.0f, 0.0f, 0.0f)
+ .SetPosition({ 0.0f, 0.0f })
+ .SetSize({ 0.0f, MenuBarHeight });
+
+ // Logo swatch: a plain colored square stands in for a real product mark.
+ const auto logo = CreateRef();
+ logo->SetSize({ 16.0f, 16.0f });
+ auto logoStyle = styles.ToolbarButtonActive;
+ logoStyle.Normal.Background.CornerRadius = glm::vec4(3.0f);
+ logo->SetStyle(logoStyle);
+ m_MenuBar->AddChild(logo).SetFixedSize(16.0f);
+
+ const auto title = CreateRef("Elixir Engine");
+ title->SetColor(styles.TextPrimary);
+ title->SetFontSize(12.0f);
+ m_MenuBar->AddChild(title).SetMargin(GUI::SMargin(8.0f, 0.0f, 14.0f, 0.0f));
+
+ m_MenuItems = CreateRef();
+ m_MenuBar->AddChild(m_MenuItems);
+
+ const auto spacer = CreateRef();
+ m_MenuBar->AddChild(spacer).SetFillSize();
+
+ const auto branchPill = CreateRef();
+ branchPill->SetStyle(styles.BranchPill);
+ branchPill->SetPadding(GUI::SMargin(10.0f, 4.0f));
+ m_MenuBar->AddChild(branchPill);
+
+ const auto branchIcon = CreateRef();
+ branchIcon->SetIcon(IconManager::Load("./Assets/Icons/git-branch.svg"));
+ branchIcon->SetSize({ 11.0f, 11.0f });
+ branchIcon->SetStyle(styles.SecondaryIcon);
+ branchPill->AddChild(branchIcon).SetMargin(GUI::SMargin(0.0f, 0.0f, 5.0f, 0.0f));
+
+ const auto branchLabel = CreateRef("main");
+ branchLabel->SetColor(styles.TextSecondary);
+ branchLabel->SetFontSize(11.0f);
+ branchPill->AddChild(branchLabel);
+
+ const auto border = CreateRef();
+ border->SetStyle(styles.MenuBorder);
+ m_Root->AddChild(border)
+ .SetAnchors({ 0.0f, 0.0f, 1.0f, 0.0f })
+ .SetPosition({ 0.0f, MenuBarHeight - 1.0f })
+ .SetSize({ 0.0f, 1.0f });
+}
+
+void EditorUI::BuildTabBar()
+{
+ const auto& styles = EditorStyle::Get();
+ m_TabBar = CreateRef();
+ m_TabBar->SetStyle(styles.TabBar);
+ m_TabBar->SetPadding({ 8.0f, 0.0f });
+
+ m_Root->AddChild(m_TabBar)
+ .SetAnchors({ 0.0f, 0.0f, 1.0f, 0.0f })
+ .SetOffsets(0.0f, MenuBarHeight, 0.0f, MenuBarHeight)
+ .SetPosition({ 0.0f, MenuBarHeight })
+ .SetSize({ 0.0f, TabBarHeight });
+}
+
+void EditorUI::BuildAssetBrowser()
+{
+ const auto& styles = EditorStyle::Get();
+ m_AssetBrowser = CreateRef();
+ m_AssetBrowser->SetStyle(styles.AssetBrowser);
+ m_AssetBrowser->SetPadding({ 12.0f, 0.0f });
+
+ // Stretches horizontally (like the menu/tab bars); pinned to the bottom edge via a
+ // non-stretching vertical anchor with Alignment.y = 1 so the slot's pivot is its own
+ // bottom edge, not its top - same trick used to bottom-anchor a fixed-height strip.
+ m_Root->AddChild(m_AssetBrowser)
+ .SetAnchors({ 0.0f, 1.0f, 1.0f, 1.0f })
+ .SetOffsets(0.0f, 0.0f, 0.0f, 0.0f)
+ .SetPosition({ 0.0f, 0.0f })
+ .SetAlignment({ 0.0f, 1.0f })
+ .SetSize({ 0.0f, AssetBrowserHeight });
+
+ const auto title = CreateRef("Project");
+ title->SetColor(styles.TextPrimary);
+ title->SetFontSize(12.0f);
+ m_AssetBrowser->AddChild(title).SetMargin(GUI::SMargin(0.0f, 0.0f, 8.0f, 0.0f));
+
+ const auto path = CreateRef("Assets / Prefabs -- 6 items");
+ path->SetColor(styles.TextSecondary);
+ path->SetFontSize(11.0f);
+ m_AssetBrowser->AddChild(path);
+}
+
+void EditorUI::AddMenuItem(const std::string& label)
+{
+ const auto& styles = EditorStyle::Get();
+ const auto text = CreateRef(label);
+ text->SetColor(styles.TextSecondary);
+ text->SetFontSize(12.0f);
+
+ m_MenuItems->AddChild(text)
+ .SetMargin(GUI::SMargin(9.0f, 0.0f));
+}
+
+void EditorUI::AddTab(const std::string& label, const bool active)
+{
+ // A small VerticalBox column [label, underline] rather than a plain TextBlock, so the
+ // active tab gets a real accent-colored indicator bar instead of just a color change.
+ const auto column = CreateRef();
+
+ const auto text = CreateRef(label);
+ text->SetFontSize(12.0f);
+ column->AddChild(text).SetMargin(GUI::SMargin(16.0f, 7.0f, 16.0f, 4.0f));
+
+ const auto underline = CreateRef();
+ // Explicit small size: without it Canvas's own default desired size (100x100) would
+ // feed into the column's ComputeDesiredSize cross-axis max() below, widening the whole
+ // tab well past the label - EHorizontalAlignment::Fill only overrides the underline's
+ // own LAYOUT width, not what the column reports wanting in the first place.
+ underline->SetSize({ 1.0f, 2.0f });
+ column->AddChild(underline).SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ m_TabBar->AddChild(column).SetVerticalAlignment(GUI::EVerticalAlignment::Bottom);
+
+ const int index = static_cast(m_Tabs.size());
+ m_Tabs.push_back({ text, underline });
+ if (active) m_ActiveTabIndex = index;
+
+ // Registering on the column (not the label/underline individually) works because an
+ // unhandled press on a leaf child bubbles up to the nearest ancestor with a callback -
+ // see Widget::HandleMouseDown's early-out when no callback is set.
+ column->OnClick([this, index] { SetActiveTab(index); });
+
+ SetActiveTab(m_ActiveTabIndex);
+}
+
+void EditorUI::SetActiveTab(const int index)
+{
+ const auto& styles = EditorStyle::Get();
+ m_ActiveTabIndex = index;
+ for (size_t i = 0; i < m_Tabs.size(); ++i)
+ {
+ const bool active = static_cast(i) == index;
+ m_Tabs[i].Label->SetColor(active ? styles.TextPrimary : styles.TextSecondary);
+ m_Tabs[i].Underline->SetStyle(active ? styles.TabActiveIndicator : styles.Transparent);
+ }
+}
+
+void EditorUI::AddDropdownMenu(const std::string& label, const std::vector& items)
+{
+ const auto& styles = EditorStyle::Get();
+ const auto text = CreateRef(label);
+ text->SetColor(styles.TextSecondary);
+ text->SetFontSize(12.0f);
+
+ m_MenuItems->AddChild(text)
+ .SetMargin(GUI::SMargin(9.0f, 0.0f));
+
+ // A plain TextBlock only starts consuming press/click events once a callback is
+ // registered on it (Widget::HandleMouseDown's default stays Unhandled otherwise) - no
+ // need for a Button here, so the trigger keeps looking like the other, static menu items.
+ //
+ // Captured weak: the callback lives inside text->m_OnClickCallback, so capturing text
+ // itself by Ref would be a self-owning reference cycle (the widget would keep its own
+ // click handler alive forever, and vice versa).
+ const WeakRef triggerWeak = text;
+ text->OnClick([this, triggerWeak, items]
+ {
+ const auto trigger = triggerWeak.lock();
+ if (!trigger) return;
+
+ // At most one dropdown open at a time: dropping every layer above the root before
+ // pushing the new one also makes clicking a different trigger while one is already
+ // open switch straight to it, instead of stacking dropdowns.
+ m_GUIManager->ClearPopups();
+ m_GUIManager->PushPopup(BuildDropdownContent(items), trigger->GetGeometry());
+ });
+}
+
+Ref EditorUI::BuildDropdownContent(const std::vector& items) const
+{
+ const auto& styles = EditorStyle::Get();
+ // Overlay, not Canvas: Canvas::ComputeDesiredSize ignores its children and always
+ // reports a fixed 800x600 fallback (it exists for absolute/anchored positioning, not
+ // content-driven sizing), which is what made the popup balloon to that size regardless
+ // of the ScrollBox inside it. Overlay's desired size is the max child size plus padding,
+ // so the popup shrink-wraps to the ScrollBox's configured size instead.
+ const auto panel = CreateRef();
+ panel->SetStyle(styles.Popup);
+ panel->SetPadding(GUI::SPadding(DropdownPadding));
+
+ const auto scrollBox = CreateRef();
+ const float visibleRows = std::min(static_cast(items.size()), DropdownMaxVisibleRows);
+ scrollBox->SetSize({ DropdownWidth, visibleRows * DropdownRowHeight });
+ scrollBox->SetStyle(styles.ScrollBar);
+
+ panel->AddChild(scrollBox)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+
+ const auto list = CreateRef();
+ for (const auto& item : items)
+ {
+ const auto row = CreateRef(item);
+ row->SetColor(styles.TextPrimary);
+ row->SetFontSize(13.0f);
+
+ // Selecting an item closes the dropdown, same as a real menu would.
+ row->OnClick([this] { m_GUIManager->PopPopup(); });
+
+ list->AddChild(row)
+ .SetFixedSize(DropdownRowHeight)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill)
+ .SetMargin(GUI::SMargin(8.0f, 0.0f));
+ }
+
+ scrollBox->SetContent(list);
+
+ return panel;
+}
+
+void EditorUI::AddPanel(Scope panel)
+{
+ const auto widget = panel->Build();
+ if (widget)
+ {
+ m_ContentArea->AddChild(widget)
+ .SetAnchors(GUI::SAnchors::StretchAll());
+ }
+
+ m_Panels.push_back(std::move(panel));
+}
+
+void EditorUI::Update(const Timestep frameTime)
+{
+ for (auto& panel : m_Panels)
+ panel->OnUpdate(frameTime);
+}
diff --git a/Editor/Source/UI/EditorUI.h b/Editor/Source/UI/EditorUI.h
new file mode 100644
index 00000000..f3d33065
--- /dev/null
+++ b/Editor/Source/UI/EditorUI.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include
+
+#include
+#include
+
+class EditorPanel;
+
+// Owns the editor's root widget tree and the set of registered content panels.
+// Builds a stretch-to-fill menu bar plus a content area panels dock into, and
+// installs itself as the GUI manager's root. This is the foundation the
+// Hierarchy/Inspector/Viewport/... panels will build on.
+class EditorUI
+{
+public:
+ explicit EditorUI(GUI::Manager* guiManager);
+
+ // Adds a labeled item to the menu bar (e.g. "File", "Edit", "Window").
+ void AddMenuItem(const std::string& label);
+
+ // Adds a menu item that, when clicked, opens a scrollable dropdown of `items` anchored
+ // below itself - a worked example of GUI::Manager's popup layer stack (PushPopup, click-
+ // outside dismissal, always-on-top z-order) together with GUI::ScrollBox for the case
+ // where `items` doesn't fit the dropdown's fixed height.
+ void AddDropdownMenu(const std::string& label, const std::vector& items);
+
+ // Adds a labeled tab to the tab bar (e.g. "Scene", "Game"). Purely decorative for now -
+ // `active` only controls its initial look, there's no click-to-switch wiring yet.
+ void AddTab(const std::string& label, bool active);
+
+ // Registers a panel, builds its widget, and docks it into the content area.
+ void AddPanel(Scope panel);
+
+ void Update(Timestep frameTime);
+
+private:
+ void Build();
+ void BuildMenuBar();
+ void BuildTabBar();
+ void BuildAssetBrowser();
+
+ // Builds the popup content for AddDropdownMenu: a background panel containing a
+ // ScrollBox, itself containing one row per item. Selecting a row (or clicking outside)
+ // closes the popup.
+ Ref BuildDropdownContent(const std::vector& items) const;
+
+ // Repaints every tab's label/underline for whichever one is now active. Purely visual -
+ // there's no per-tab content to swap yet.
+ void SetActiveTab(int index);
+
+ GUI::Manager* m_GUIManager;
+
+ Ref m_Root;
+ Ref m_MenuBar;
+ Ref m_MenuItems;
+ Ref m_TabBar;
+ Ref m_ContentArea;
+ Ref m_AssetBrowser;
+
+ struct STab
+ {
+ Ref Label;
+ Ref Underline;
+ };
+ std::vector m_Tabs;
+ int m_ActiveTabIndex = 0;
+
+ std::vector> m_Panels;
+};
diff --git a/Editor/Source/UI/Panels/ViewportPanel.cpp b/Editor/Source/UI/Panels/ViewportPanel.cpp
new file mode 100644
index 00000000..b71fa001
--- /dev/null
+++ b/Editor/Source/UI/Panels/ViewportPanel.cpp
@@ -0,0 +1,781 @@
+#include "ViewportPanel.h"
+#include "../EditorStyles.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace
+{
+ const EditorStyle::SChromeStyles& Styles()
+ {
+ return EditorStyle::Get();
+ }
+
+ constexpr float PanelHeaderHeight = 28.0f;
+ constexpr float RowHeight = 24.0f;
+ // Wide enough for the longest label ("Jump Height") - fixed so every row's field/value
+ // column starts at the same X regardless of how long its own label happens to be.
+ constexpr float LabelWidth = 76.0f;
+
+ template
+ AnimationCurve MakeCurve(
+ const TValue& from,
+ const TValue& to,
+ const float delay,
+ const float duration
+ )
+ {
+ AnimationCurve curve;
+ curve.AddKey({ .Time = 0.0f, .Value = from, .Interpolation = EKeyframeInterpolation::EaseOut });
+ if (delay > 0.0f)
+ curve.AddKey({ .Time = delay, .Value = from, .Interpolation = EKeyframeInterpolation::EaseOut });
+ curve.AddKey({ .Time = delay + duration, .Value = to });
+ return curve;
+ }
+
+ std::string FormatFloat(const float value)
+ {
+ char buffer[32];
+ std::snprintf(buffer, sizeof(buffer), "%.2f", value);
+ return buffer;
+ }
+
+ Ref CreateChromeButton(const GUI::SButtonStyle& style)
+ {
+ const auto button = CreateRef();
+ button->SetStyle(style);
+ return button;
+ }
+
+ Ref CreatePanelHeaderButton()
+ {
+ return CreateChromeButton(Styles().PanelHeaderButton);
+ }
+}
+
+Ref ViewportPanel::Build()
+{
+ const auto root = CreateRef();
+ // Neutral placeholder - the real scene render will fill this in later, so there's no
+ // stand-in landscape here, just the floating chrome (toolbar, panels, stats overlay).
+ root->SetStyle(Styles().Transparent);
+
+ BuildToolbar(root);
+ BuildHierarchyPanel(root);
+ BuildInspectorPanel(root);
+ BuildStatsOverlay(root);
+
+ return root;
+}
+
+void ViewportPanel::OnUpdate(const Timestep frameTime)
+{
+ if (m_HierarchyPanelAnimation) m_HierarchyPanelAnimation->Update(frameTime);
+ if (m_HierarchyPanelToggleAnimation) m_HierarchyPanelToggleAnimation->Update(frameTime);
+ if (m_InspectorPanelAnimation) m_InspectorPanelAnimation->Update(frameTime);
+ if (m_InspectorPanelToggleAnimation) m_InspectorPanelToggleAnimation->Update(frameTime);
+}
+
+void ViewportPanel::BuildToolbar(const Ref& root)
+{
+ const auto panel = CreateRef();
+ panel->SetStyle(Styles().Toolbar);
+ panel->SetPadding(GUI::SMargin(4.0f));
+
+ const auto row = CreateRef();
+ panel->AddChild(row);
+
+ // Move / Rotate / Scale mode swatches - clicking one makes it the active tool.
+ const char* toolIconPaths[] = {
+ "./Assets/Icons/move.svg",
+ "./Assets/Icons/rotate.svg",
+ "./Assets/Icons/scale.svg",
+ };
+ m_ToolModeSwatches.clear();
+ m_ToolModeIcons.clear();
+ for (int index = 0; index < 3; ++index)
+ {
+ const auto swatch = CreateRef();
+ swatch->SetSize({ 24.0f, 24.0f });
+ swatch->SetStyle(Styles().ToolbarButtonInactive);
+ row->AddChild(swatch).SetMargin(GUI::SMargin(0.0f, 0.0f, index < 2 ? 2.0f : 0.0f, 0.0f));
+ m_ToolModeSwatches.push_back(swatch);
+
+ const auto icon = CreateRef();
+ icon->SetIcon(IconManager::Load(toolIconPaths[index]));
+ icon->SetSize({ 14.0f, 14.0f });
+ icon->SetStyle(Styles().SecondaryIcon);
+ swatch->AddChild(icon)
+ .SetAnchors(GUI::SAnchors::MiddleCenter())
+ .SetAlignment({ 0.5f, 0.5f })
+ .SetSize({ 14.0f, 14.0f });
+ m_ToolModeIcons.push_back(icon);
+
+ swatch->OnClick([this, index] { SetActiveToolMode(index); });
+ }
+
+ const auto divider1 = CreateRef();
+ divider1->SetSize({ 1.0f, 18.0f });
+ divider1->SetStyle(Styles().PanelHeaderBorder);
+ row->AddChild(divider1).SetMargin(GUI::SMargin(8.0f, 0.0f));
+
+ const auto pivot = CreateRef();
+ pivot->SetStyle(Styles().InspectorField);
+ pivot->SetPadding(GUI::SMargin(8.0f, 3.0f));
+ row->AddChild(pivot);
+
+ const auto pivotLabel = CreateRef("Local");
+ pivotLabel->SetColor(Styles().TextPrimary);
+ pivotLabel->SetFontSize(11.0f);
+ pivot->AddChild(pivotLabel);
+
+ const auto divider2 = CreateRef();
+ divider2->SetSize({ 1.0f, 18.0f });
+ divider2->SetStyle(Styles().PanelHeaderBorder);
+ row->AddChild(divider2).SetMargin(GUI::SMargin(8.0f, 0.0f));
+
+ const auto play = CreateRef();
+ play->SetSize({ 26.0f, 26.0f });
+ play->SetStyle(Styles().PlayButtonInactive);
+ row->AddChild(play).SetMargin(GUI::SMargin(0.0f, 0.0f, 2.0f, 0.0f));
+ m_PlayButton = play;
+ m_PlayIcon = CreateRef();
+ m_PlayIcon->SetIcon(IconManager::Load("./Assets/Icons/play.svg"));
+ m_PlayIcon->SetSize({ 13.0f, 13.0f });
+ m_PlayIcon->SetStyle(Styles().SecondaryIcon);
+ play->AddChild(m_PlayIcon)
+ .SetAnchors(GUI::SAnchors::MiddleCenter())
+ .SetAlignment({ 0.5f, 0.5f })
+ .SetSize({ 13.0f, 13.0f });
+ play->OnClick([this] { SetPlaying(true); });
+
+ const auto pause = CreateRef();
+ pause->SetSize({ 26.0f, 26.0f });
+ pause->SetStyle(Styles().PauseButtonInactive);
+ row->AddChild(pause);
+ m_PauseButton = pause;
+ m_PauseIcon = CreateRef();
+ m_PauseIcon->SetIcon(IconManager::Load("./Assets/Icons/pause.svg"));
+ m_PauseIcon->SetSize({ 13.0f, 13.0f });
+ m_PauseIcon->SetStyle(Styles().SecondaryIcon);
+ pause->AddChild(m_PauseIcon)
+ .SetAnchors(GUI::SAnchors::MiddleCenter())
+ .SetAlignment({ 0.5f, 0.5f })
+ .SetSize({ 13.0f, 13.0f });
+ pause->OnClick([this] { SetPlaying(false); });
+
+ SetActiveToolMode(m_ActiveToolMode);
+ SetPlaying(m_IsPlaying);
+
+ root->AddChild(panel)
+ .SetAnchors(GUI::SAnchors::TopCenter())
+ .SetPosition({ 0.0f, 10.0f })
+ .SetAlignment({ 0.5f, 0.0f });
+}
+
+void ViewportPanel::BuildHierarchyPanel(const Ref& root)
+{
+ const auto panel = CreateRef();
+ panel->SetStyle(Styles().Panel);
+
+ root->AddChild(panel)
+ .SetAnchors(GUI::SAnchors::TopLeft())
+ .SetPosition({ 10.0f, 10.0f })
+ .SetSize({ 220.0f, 260.0f });
+ m_HierarchyPanel = panel;
+
+ const auto column = CreateRef();
+ panel->AddChild(column)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+
+ const auto header = CreatePanelHeaderButton();
+ const auto headerContent = CreateRef();
+ headerContent->SetPadding({ 8.0f, 0.0f });
+ header->SetContent(headerContent)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+ column->AddChild(header)
+ .SetFixedSize(PanelHeaderHeight - 1.0f)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+ header->OnClick([this] { SetHierarchyPanelOpen(false); });
+
+ const auto title = CreateRef("Hierarchy");
+ title->SetColor(Styles().TextPrimary);
+ title->SetFontSize(12.0f);
+ const auto icon = CreateRef();
+ icon->SetIcon(IconManager::Load("./Assets/Icons/hierarchy.svg"));
+ icon->SetSize({ 14.0f, 14.0f });
+ icon->SetStyle(Styles().SecondaryIcon);
+ headerContent->AddChild(icon).SetMargin(GUI::SMargin(0.0f, 0.0f, 6.0f, 0.0f));
+ headerContent->AddChild(title);
+
+ const auto headerSpacer = CreateRef();
+ headerContent->AddChild(headerSpacer).SetFillSize();
+
+ const auto closeIcon = CreateRef();
+ closeIcon->SetIcon(IconManager::Load("./Assets/Icons/close.svg"));
+ closeIcon->SetSize({ 11.0f, 11.0f });
+ closeIcon->SetStyle(Styles().SecondaryIcon);
+ headerContent->AddChild(closeIcon);
+
+ const auto headerBorder = CreateRef();
+ headerBorder->SetStyle(Styles().PanelHeaderBorder);
+ column->AddChild(headerBorder)
+ .SetFixedSize(1.0f)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto scrollBox = CreateRef();
+ scrollBox->SetStyle(Styles().ScrollBar);
+ column->AddChild(scrollBox)
+ .SetFillSize()
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto list = CreateRef();
+ list->SetPadding({ 4.0f, 6.0f });
+ scrollBox->SetContent(list);
+
+ struct SHierarchyItem { std::string Label; float Indent; };
+ const SHierarchyItem items[] = {
+ { "Directional Light", 0.0f },
+ { "Ground", 0.0f },
+ { "Player", 0.0f },
+ { "Mesh", 18.0f },
+ { "Terrain", 0.0f },
+ { "Main Camera", 0.0f },
+ };
+
+ m_HierarchyRows.clear();
+ for (size_t i = 0; i < std::size(items); ++i)
+ {
+ const auto& item = items[i];
+
+ const auto row = CreateRef();
+ row->SetStyle(Styles().Transparent);
+ row->SetPadding(GUI::SMargin(8.0f + item.Indent, 0.0f, 8.0f, 0.0f));
+ list->AddChild(row)
+ .SetFixedSize(22.0f)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto label = CreateRef(item.Label);
+ label->SetFontSize(12.0f);
+ row->AddChild(label).SetHorizontalAlignment(GUI::EHorizontalAlignment::Left);
+
+ m_HierarchyRows.push_back({ row, label });
+
+ const int index = static_cast(i);
+ row->OnClick([this, index] { SetSelectedHierarchyRow(index); });
+ }
+
+ SetSelectedHierarchyRow(m_SelectedHierarchyIndex);
+
+ const auto panelToggle = CreateChromeButton(Styles().PanelToggleButton);
+ root->AddChild(panelToggle)
+ .SetAnchors(GUI::SAnchors::TopLeft())
+ .SetPosition({ 10.0f, 10.0f })
+ .SetSize({ 32.0f, 32.0f });
+ m_HierarchyPanelToggle = panelToggle;
+
+ const auto panelToggleIcon = CreateRef();
+ panelToggleIcon->SetIcon(IconManager::Load("./Assets/Icons/hierarchy.svg"));
+ panelToggleIcon->SetSize({ 14.0f, 14.0f });
+ panelToggleIcon->SetStyle(Styles().SecondaryIcon);
+ panelToggle->SetContent(panelToggleIcon);
+ panelToggle->OnClick([this] { SetHierarchyPanelOpen(true); });
+ m_HierarchyPanelAnimation = CreateScope(m_HierarchyPanel);
+ m_HierarchyPanelToggleAnimation = CreateScope(m_HierarchyPanelToggle);
+ SetHierarchyPanelOpen(true, false);
+}
+
+void ViewportPanel::BuildInspectorPanel(const Ref& root)
+{
+ const auto panel = CreateRef();
+ panel->SetStyle(Styles().Panel);
+
+ // Right-anchored, stretched vertically (top:10, bottom:10 in the mock); horizontal is
+ // non-stretching, so Position/Alignment place the panel's own right edge 10px in from
+ // the viewport's right edge instead.
+ root->AddChild(panel)
+ .SetAnchors({ 1.0f, 0.0f, 1.0f, 1.0f })
+ .SetOffsets(0.0f, 10.0f, 0.0f, -10.0f)
+ .SetPosition({ -10.0f, 0.0f })
+ .SetAlignment({ 1.0f, 0.0f })
+ .SetSize({ 260.0f, 0.0f });
+ m_InspectorPanel = panel;
+
+ const auto column = CreateRef();
+ panel->AddChild(column)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+
+ const auto header = CreatePanelHeaderButton();
+ const auto headerContent = CreateRef();
+ headerContent->SetPadding({ 8.0f, 0.0f });
+ header->SetContent(headerContent)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+ column->AddChild(header)
+ .SetFixedSize(PanelHeaderHeight - 1.0f)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+ header->OnClick([this] { SetInspectorPanelOpen(false); });
+
+ const auto title = CreateRef("Inspector");
+ title->SetColor(Styles().TextPrimary);
+ title->SetFontSize(12.0f);
+ const auto icon = CreateRef();
+ icon->SetIcon(IconManager::Load("./Assets/Icons/inspector.svg"));
+ icon->SetSize({ 13.0f, 13.0f });
+ icon->SetStyle(Styles().SecondaryIcon);
+ headerContent->AddChild(icon).SetMargin(GUI::SMargin(0.0f, 0.0f, 6.0f, 0.0f));
+ headerContent->AddChild(title);
+
+ const auto headerSpacer = CreateRef();
+ headerContent->AddChild(headerSpacer).SetFillSize();
+
+ const auto closeIcon = CreateRef();
+ closeIcon->SetIcon(IconManager::Load("./Assets/Icons/close.svg"));
+ closeIcon->SetSize({ 11.0f, 11.0f });
+ closeIcon->SetStyle(Styles().SecondaryIcon);
+ headerContent->AddChild(closeIcon);
+
+ const auto headerBorder = CreateRef();
+ headerBorder->SetStyle(Styles().PanelHeaderBorder);
+ column->AddChild(headerBorder)
+ .SetFixedSize(1.0f)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto scrollBox = CreateRef();
+ scrollBox->SetStyle(Styles().ScrollBar);
+ column->AddChild(scrollBox)
+ .SetFillSize()
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto list = CreateRef();
+ scrollBox->SetContent(list);
+
+ // --- Object header: editable name field ---
+ {
+ const auto block = CreateRef();
+ block->SetPadding({ 10.0f, 10.0f });
+ list->AddChild(block).SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto nameField = CreateRef(m_HierarchyRows.empty() ? "Player" : m_HierarchyRows[m_SelectedHierarchyIndex].Label->GetText());
+ nameField->SetStyle(Styles().TextField);
+ nameField->SetPadding({ 8.0f, 5.0f });
+ nameField->SetCursorColor(Styles().TextPrimary);
+ nameField->SetSelectionColor({ Styles().Accent.R, Styles().Accent.G, Styles().Accent.B, 0.35f });
+ nameField->SetFontSize(13.0f);
+ block->AddChild(nameField).SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+ }
+ AddInspectorRow(list, "Tag", m_Inspector.Tag);
+ AddInspectorRow(list, "Layer", m_Inspector.Layer);
+
+ // --- Transform ---
+ AddInspectorSectionHeader(list, "Transform");
+ AddInspectorVectorRow(list, "Position", m_Inspector.Position);
+ AddInspectorVectorRow(list, "Rotation", m_Inspector.Rotation);
+ AddInspectorVectorRow(list, "Scale", m_Inspector.Scale);
+
+ // --- Mesh Renderer ---
+ AddInspectorSectionHeader(list, "Mesh Renderer", &m_Inspector.MeshRendererEnabled);
+ AddInspectorRow(list, "Mesh", m_Inspector.Mesh);
+ AddInspectorRow(list, "Material", m_Inspector.Material);
+ AddInspectorToggleRow(list, "Cast Shadows", m_Inspector.CastShadows);
+
+ // --- Rigidbody ---
+ AddInspectorSectionHeader(list, "Rigidbody", &m_Inspector.RigidbodyEnabled);
+ AddInspectorRow(list, "Mass", m_Inspector.Mass, true);
+ AddInspectorToggleRow(list, "Use Gravity", m_Inspector.UseGravity);
+
+ // --- PlayerController (script) ---
+ AddInspectorSectionHeader(list, "PlayerController (Script)", nullptr, "./Assets/Icons/script.svg");
+ AddInspectorRow(list, "Move Speed", m_Inspector.MoveSpeed, true);
+ AddInspectorRow(list, "Jump Height", m_Inspector.JumpHeight, true);
+ AddInspectorToggleRow(list, "Ground Check", m_Inspector.GroundCheck);
+
+ // --- Add Component ---
+ {
+ const auto block = CreateRef();
+ block->SetPadding({ 10.0f, 12.0f });
+ list->AddChild(block).SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto addButton = CreateRef();
+ addButton->SetStyle(Styles().PanelSection);
+ addButton->SetPadding({ 12.0f, 6.0f });
+ block->AddChild(addButton).SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto label = CreateRef("Add Component");
+ label->SetColor(Styles().TextPrimary);
+ label->SetFontSize(13.0f);
+ addButton->AddChild(label);
+
+ const WeakRef addButtonWeak = addButton;
+ addButton->OnMouseDown([addButtonWeak]
+ {
+ if (const auto widget = addButtonWeak.lock())
+ std::static_pointer_cast(widget)->SetStyle(Styles().InspectorField);
+ });
+ addButton->OnMouseUp([addButtonWeak]
+ {
+ if (const auto widget = addButtonWeak.lock())
+ std::static_pointer_cast(widget)->SetStyle(Styles().PanelSection);
+ });
+ }
+
+ const auto panelToggle = CreateChromeButton(Styles().PanelToggleButton);
+ root->AddChild(panelToggle)
+ .SetAnchors(GUI::SAnchors::TopRight())
+ .SetPosition({ -10.0f, 10.0f })
+ .SetAlignment({ 1.0f, 0.0f })
+ .SetSize({ 32.0f, 32.0f });
+ m_InspectorPanelToggle = panelToggle;
+
+ const auto panelToggleIcon = CreateRef();
+ panelToggleIcon->SetIcon(IconManager::Load("./Assets/Icons/inspector.svg"));
+ panelToggleIcon->SetSize({ 13.0f, 13.0f });
+ panelToggleIcon->SetStyle(Styles().SecondaryIcon);
+ panelToggle->SetContent(panelToggleIcon);
+ panelToggle->OnClick([this] { SetInspectorPanelOpen(true); });
+ m_InspectorPanelAnimation = CreateScope(m_InspectorPanel);
+ m_InspectorPanelToggleAnimation = CreateScope(m_InspectorPanelToggle);
+ SetInspectorPanelOpen(true, false);
+}
+
+void ViewportPanel::BuildStatsOverlay(const Ref& root)
+{
+ const auto panel = CreateRef();
+ panel->SetStyle(Styles().StatsOverlay);
+ panel->SetPadding({ 10.0f, 6.0f });
+
+ root->AddChild(panel)
+ .SetAnchors(GUI::SAnchors::BottomLeft())
+ .SetPosition({ 10.0f, -10.0f })
+ .SetAlignment({ 0.0f, 1.0f });
+
+ const auto column = CreateRef();
+ panel->AddChild(column);
+
+ const auto line1 = CreateRef("60 FPS - 4.2ms");
+ line1->SetColor(Styles().TextSecondary);
+ line1->SetFontSize(11.0f);
+ column->AddChild(line1);
+
+ const auto line2 = CreateRef("12,480 tris - 38 draw calls");
+ line2->SetColor(Styles().TextSecondary);
+ line2->SetFontSize(11.0f);
+ column->AddChild(line2);
+}
+
+void ViewportPanel::SetActiveToolMode(const int index)
+{
+ m_ActiveToolMode = index;
+ for (size_t i = 0; i < m_ToolModeSwatches.size(); ++i)
+ {
+ const auto canvas = std::static_pointer_cast(m_ToolModeSwatches[i]);
+ const bool active = static_cast(i) == index;
+ canvas->SetStyle(active ? Styles().ToolbarButtonActive : Styles().ToolbarButtonInactive);
+ m_ToolModeIcons[i]->SetStyle(active ? Styles().PrimaryIcon : Styles().SecondaryIcon);
+ }
+}
+
+void ViewportPanel::SetPlaying(const bool playing)
+{
+ m_IsPlaying = playing;
+ std::static_pointer_cast(m_PlayButton)->SetStyle(
+ playing ? Styles().PlayButtonActive : Styles().PlayButtonInactive
+ );
+ std::static_pointer_cast(m_PauseButton)->SetStyle(
+ playing ? Styles().PauseButtonInactive : Styles().PauseButtonActive
+ );
+ m_PlayIcon->SetStyle(playing ? Styles().PrimaryIcon : Styles().SecondaryIcon);
+ m_PauseIcon->SetStyle(playing ? Styles().SecondaryIcon : Styles().PrimaryIcon);
+}
+
+void ViewportPanel::SetSelectedHierarchyRow(const int index)
+{
+ m_SelectedHierarchyIndex = index;
+ for (size_t i = 0; i < m_HierarchyRows.size(); ++i)
+ {
+ const bool selected = static_cast(i) == index;
+ std::static_pointer_cast(m_HierarchyRows[i].Row)
+ ->SetStyle(selected ? Styles().Selection : Styles().Transparent);
+ m_HierarchyRows[i].Label->SetColor(selected ? Styles().TextPrimary : Styles().TextSecondary);
+ }
+}
+
+void ViewportPanel::SetHierarchyPanelOpen(const bool open, const bool animate)
+{
+ if (animate)
+ {
+ AnimatePanel(m_HierarchyPanel, *m_HierarchyPanelAnimation, open, { -12.0f, 0.0f });
+ AnimatePanelToggle(m_HierarchyPanelToggle, *m_HierarchyPanelToggleAnimation, open);
+ }
+ else
+ {
+ m_HierarchyPanelAnimation->Stop();
+ m_HierarchyPanelToggleAnimation->Stop();
+ m_HierarchyPanel->SetOpacity(1.0f);
+ m_HierarchyPanel->SetRenderOffset({});
+ m_HierarchyPanel->SetVisibility(open ? GUI::EVisibility::Visible : GUI::EVisibility::Collapsed);
+ m_HierarchyPanelToggle->SetOpacity(1.0f);
+ m_HierarchyPanelToggle->SetRenderOffset({});
+ m_HierarchyPanelToggle->SetVisibility(open ? GUI::EVisibility::Collapsed : GUI::EVisibility::Visible);
+ }
+}
+
+void ViewportPanel::SetInspectorPanelOpen(const bool open, const bool animate)
+{
+ if (animate)
+ {
+ AnimatePanel(m_InspectorPanel, *m_InspectorPanelAnimation, open, { 12.0f, 0.0f });
+ AnimatePanelToggle(m_InspectorPanelToggle, *m_InspectorPanelToggleAnimation, open);
+ }
+ else
+ {
+ m_InspectorPanelAnimation->Stop();
+ m_InspectorPanelToggleAnimation->Stop();
+ m_InspectorPanel->SetOpacity(1.0f);
+ m_InspectorPanel->SetRenderOffset({});
+ m_InspectorPanel->SetVisibility(open ? GUI::EVisibility::Visible : GUI::EVisibility::Collapsed);
+ m_InspectorPanelToggle->SetOpacity(1.0f);
+ m_InspectorPanelToggle->SetRenderOffset({});
+ m_InspectorPanelToggle->SetVisibility(open ? GUI::EVisibility::Collapsed : GUI::EVisibility::Visible);
+ }
+}
+
+void ViewportPanel::AnimatePanel(
+ const Ref& panel,
+ GUI::WidgetAnimation& animation,
+ const bool open,
+ const glm::vec2& offset
+)
+{
+ panel->SetVisibility(GUI::EVisibility::HitTestInvisible);
+ animation.ClearTracks();
+ animation.AddTrack(
+ MakeCurve(open ? 0.0f : 1.0f, open ? 1.0f : 0.0f, Styles().PanelTransitionDelay, Styles().PanelTransitionDuration),
+ [](GUI::Widget& widget, const float value) { widget.SetOpacity(value); }
+ );
+ animation.AddTrack(
+ MakeCurve(open ? offset : glm::vec2{}, open ? glm::vec2{} : offset, Styles().PanelTransitionDelay, Styles().PanelTransitionDuration),
+ [](GUI::Widget& widget, const glm::vec2& value) { widget.SetRenderOffset(value); }
+ );
+ animation.OnFinished([panel, open]
+ {
+ panel->SetVisibility(open ? GUI::EVisibility::Visible : GUI::EVisibility::Collapsed);
+ });
+ animation.Play();
+}
+
+void ViewportPanel::AnimatePanelToggle(
+ const Ref& toggle,
+ GUI::WidgetAnimation& animation,
+ const bool panelOpen
+)
+{
+ toggle->SetVisibility(GUI::EVisibility::HitTestInvisible);
+ animation.ClearTracks();
+ const float delay = panelOpen ? 0.0f : Styles().PanelToggleShowDelay;
+ animation.AddTrack(
+ MakeCurve(panelOpen ? 1.0f : 0.0f, panelOpen ? 0.0f : 1.0f, delay, Styles().PanelToggleDuration),
+ [](GUI::Widget& widget, const float value) { widget.SetOpacity(value); }
+ );
+ animation.OnFinished([toggle, panelOpen]
+ {
+ toggle->SetVisibility(panelOpen ? GUI::EVisibility::Collapsed : GUI::EVisibility::Visible);
+ });
+ animation.Play();
+}
+
+void ViewportPanel::AddInspectorSectionHeader(
+ const Ref& list,
+ const std::string& name,
+ bool* enabledValue,
+ const char* iconPath
+)
+{
+ const auto header = CreateRef();
+ header->SetStyle(Styles().PanelSection);
+ header->SetPadding({ 10.0f, 0.0f });
+ list->AddChild(header)
+ .SetFixedSize(PanelHeaderHeight)
+ .SetMargin(GUI::SMargin(0.0f, 4.0f, 0.0f, 2.0f))
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto disclosureIcon = CreateRef();
+ disclosureIcon->SetIcon(IconManager::Load("./Assets/Icons/chevron-right.svg"));
+ disclosureIcon->SetSize({ 9.0f, 9.0f });
+ disclosureIcon->SetStyle(Styles().SecondaryIcon);
+ header->AddChild(disclosureIcon).SetMargin(GUI::SMargin(0.0f, 0.0f, 6.0f, 0.0f));
+
+ if (iconPath)
+ {
+ const auto icon = CreateRef();
+ icon->SetIcon(IconManager::Load(iconPath));
+ icon->SetSize({ 13.0f, 13.0f });
+ icon->SetColor(GUI::EStyleLayer::Normal, { 0.922f, 0.694f, 0.286f, 1.0f });
+ header->AddChild(icon).SetMargin(GUI::SMargin(0.0f, 0.0f, 6.0f, 0.0f));
+ }
+
+ const auto label = CreateRef(name);
+ label->SetColor(Styles().TextPrimary);
+ label->SetFontSize(12.0f);
+ header->AddChild(label);
+
+ if (enabledValue)
+ {
+ const auto spacer = CreateRef();
+ header->AddChild(spacer).SetFillSize();
+
+ const auto checkbox = CreateRef();
+ checkbox->SetSize({ 13.0f, 13.0f });
+ checkbox->SetStyle(Styles().Checkbox);
+ checkbox->SetChecked(*enabledValue);
+ checkbox->OnCheckedChanged([enabledValue](const bool checked) { *enabledValue = checked; });
+ header->AddChild(checkbox).SetVerticalAlignment(GUI::EVerticalAlignment::Center);
+ }
+}
+
+void ViewportPanel::AddInspectorRow(
+ const Ref& list,
+ const std::string& label,
+ std::string& value,
+ const bool monospace
+)
+{
+ const auto row = CreateRef();
+ row->SetPadding({ 10.0f, 0.0f });
+ list->AddChild(row)
+ .SetFixedSize(RowHeight)
+ .SetMargin(GUI::SMargin(0.0f, 2.0f))
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto labelText = CreateRef(label);
+ labelText->SetColor(Styles().TextSecondary);
+ labelText->SetFontSize(11.0f);
+ row->AddChild(labelText)
+ .SetFixedSize(LabelWidth)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Center);
+
+ const auto field = CreateRef(value);
+ field->SetStyle(Styles().TextField);
+ field->SetPadding({ 8.0f, 4.0f });
+ field->SetCursorColor(Styles().TextPrimary);
+ field->SetSelectionColor({ Styles().Accent.R, Styles().Accent.G, Styles().Accent.B, 0.35f });
+ field->SetFontSize(monospace ? 11.0f : 12.0f);
+ field->OnChange([&value](const std::string& text) { value = text; });
+ // Vertical Fill (not Center) so the row's real height wins over TextField's own 30px
+ // minimum desired size - see Widget::AlignVertically, Fill ignores childSize entirely.
+ row->AddChild(field)
+ .SetFillSize()
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+}
+
+void ViewportPanel::AddInspectorToggleRow(const Ref& list, const std::string& label, bool& value)
+{
+ const auto row = CreateRef();
+ row->SetPadding({ 10.0f, 0.0f });
+ list->AddChild(row)
+ .SetFixedSize(RowHeight)
+ .SetMargin(GUI::SMargin(0.0f, 2.0f))
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto checkbox = CreateRef();
+ checkbox->SetSize({ 13.0f, 13.0f });
+ checkbox->SetStyle(Styles().Checkbox);
+ checkbox->SetChecked(value);
+ checkbox->OnCheckedChanged([&value](const bool checked) { value = checked; });
+ row->AddChild(checkbox)
+ .SetMargin(GUI::SMargin(0.0f, 0.0f, 8.0f, 0.0f))
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Center);
+
+ const auto labelText = CreateRef(label);
+ labelText->SetColor(Styles().TextPrimary);
+ labelText->SetFontSize(12.0f);
+ row->AddChild(labelText).SetVerticalAlignment(GUI::EVerticalAlignment::Center);
+}
+
+void ViewportPanel::AddInspectorVectorRow(
+ const Ref& list,
+ const std::string& label,
+ glm::vec3& value
+)
+{
+ const auto row = CreateRef();
+ row->SetPadding({ 10.0f, 0.0f });
+ list->AddChild(row)
+ .SetFixedSize(RowHeight)
+ .SetMargin(GUI::SMargin(0.0f, 2.0f))
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill);
+
+ const auto labelText = CreateRef(label);
+ labelText->SetColor(Styles().TextSecondary);
+ labelText->SetFontSize(11.0f);
+ row->AddChild(labelText)
+ .SetFixedSize(LabelWidth)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Center);
+
+ const auto fields = CreateRef();
+ // Vertical Fill too, not just the default Center: otherwise fields' own desired height
+ // (inflated by the TextField/outline budgeting inside each chip - see the Fill notes
+ // above) wins over the row's real height instead of being clamped to it.
+ row->AddChild(fields)
+ .SetFillSize()
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+
+ const auto addAxis = [&fields](const char* axis, const GUI::SColor& color, float& component)
+ {
+ const auto chip = CreateRef();
+ chip->SetStyle(Styles().InspectorField);
+ // Vertical Fill all the way down (chip -> row -> field) so the field's nested
+ // TextField never inflates anything above the row's actual allocated height - see
+ // the Fill note above AddInspectorRow's field.
+ fields->AddChild(chip)
+ .SetFillSize()
+ .SetMargin(GUI::SMargin(2.0f, 0.0f))
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+
+ const auto row = CreateRef();
+ chip->AddChild(row)
+ .SetHorizontalAlignment(GUI::EHorizontalAlignment::Fill)
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+
+ const auto badge = CreateRef();
+ auto badgeStyle = Styles().Transparent;
+ badgeStyle.Normal.Background.Color = { color.R, color.G, color.B, 0.2f };
+ badge->SetStyle(badgeStyle);
+ badge->SetPadding({ 5.0f, 4.0f });
+ row->AddChild(badge);
+
+ const auto axisLabel = CreateRef(axis);
+ axisLabel->SetColor(color);
+ axisLabel->SetFontSize(11.0f);
+ badge->AddChild(axisLabel);
+
+ // Transparent background: the chip built above already supplies the outline/bg -
+ // this field just needs to be able to take focus and text input over it.
+ const auto field = CreateRef(FormatFloat(component));
+ field->SetStyle(Styles().TransparentTextField);
+ field->SetCursorColor(Styles().TextPrimary);
+ field->SetFontSize(11.0f);
+ field->SetPadding({ 5.0f, 4.0f, 0.0f, 4.0f });
+ field->OnChange([&component](const std::string& text)
+ {
+ try { component = std::stof(text); }
+ catch (...) { /* leave the last valid value in place */ }
+ });
+ row->AddChild(field)
+ .SetFillSize()
+ .SetVerticalAlignment(GUI::EVerticalAlignment::Fill);
+ };
+
+ addAxis("X", Styles().AxisX, value.x);
+ addAxis("Y", Styles().AxisY, value.y);
+ addAxis("Z", Styles().AxisZ, value.z);
+}
diff --git a/Editor/Source/UI/Panels/ViewportPanel.h b/Editor/Source/UI/Panels/ViewportPanel.h
new file mode 100644
index 00000000..fc90df4f
--- /dev/null
+++ b/Editor/Source/UI/Panels/ViewportPanel.h
@@ -0,0 +1,111 @@
+#pragma once
+
+#include "../EditorPanel.h"
+
+#include
+
+#include
+
+// The scene viewport: the floating chrome that would normally sit on top of a real render
+// (Hierarchy, Inspector, a transform toolbar, and a stats readout). The viewport background
+// itself is just a neutral placeholder - the real scene render lands there later.
+//
+// There's no real scene graph behind any of this - m_Inspector and the tool-mode/play/
+// hierarchy-selection state below are plain member variables standing in for a document
+// model, just enough for the panels to be genuinely interactive (click, type, toggle) for
+// demo purposes.
+class ViewportPanel final : public EditorPanel
+{
+public:
+ const char* GetName() const override { return "Viewport"; }
+
+ Ref Build() override;
+ void OnUpdate(Timestep frameTime) override;
+
+private:
+ // root is the panel's own Canvas - every floating piece below anchors into it directly,
+ // the same way EditorUI anchors the menu/tab bars into its own root Canvas.
+ void BuildToolbar(const Ref& root);
+ void BuildHierarchyPanel(const Ref& root);
+ void BuildInspectorPanel(const Ref& root);
+ void BuildStatsOverlay(const Ref& root);
+
+ // Inspector helpers: a section is a collapsible-looking (but not actually collapsible
+ // yet) header bar followed by a handful of label/value rows. Passing a non-null
+ // enabledValue wires the header's own checkbox to that flag (Mesh Renderer/Rigidbody);
+ // passing a value/component reference to the row helpers wires the field itself to be
+ // editable, writing straight back into the referenced member on change.
+ void AddInspectorSectionHeader(
+ const Ref& list,
+ const std::string& name,
+ bool* enabledValue = nullptr,
+ const char* iconPath = nullptr
+ );
+ void AddInspectorRow(const Ref& list, const std::string& label, std::string& value, bool monospace = false);
+ void AddInspectorToggleRow(const Ref& list, const std::string& label, bool& value);
+ void AddInspectorVectorRow(const Ref& list, const std::string& label, glm::vec3& value);
+
+ void SetActiveToolMode(int index);
+ void SetPlaying(bool playing);
+ void SetSelectedHierarchyRow(int index);
+ void SetHierarchyPanelOpen(bool open, bool animate = true);
+ void SetInspectorPanelOpen(bool open, bool animate = true);
+ void AnimatePanel(
+ const Ref& panel,
+ GUI::WidgetAnimation& animation,
+ bool open,
+ const glm::vec2& offset
+ );
+ void AnimatePanelToggle(
+ const Ref& toggle,
+ GUI::WidgetAnimation& animation,
+ bool panelOpen
+ );
+
+ // --- Toolbar state ---
+ std::vector[> m_ToolModeSwatches; // 0 = Move, 1 = Rotate, 2 = Scale
+ std::vector][> m_ToolModeIcons;
+ int m_ActiveToolMode = 1;
+ Ref m_PlayButton;
+ Ref m_PauseButton;
+ Ref m_PlayIcon;
+ Ref m_PauseIcon;
+ bool m_IsPlaying = true;
+
+ // --- Hierarchy state ---
+ Ref m_HierarchyPanel;
+ Ref m_HierarchyPanelToggle;
+ Scope m_HierarchyPanelAnimation;
+ Scope m_HierarchyPanelToggleAnimation;
+ struct SHierarchyRow
+ {
+ Ref Row;
+ Ref Label;
+ };
+ std::vector m_HierarchyRows;
+ int m_SelectedHierarchyIndex = 2; // "Player", matching the mock's initial selection
+
+ // --- Inspector state (stand-in for a real selected-entity data model) ---
+ Ref m_InspectorPanel;
+ Ref m_InspectorPanelToggle;
+ Scope m_InspectorPanelAnimation;
+ Scope m_InspectorPanelToggleAnimation;
+ struct SInspectorState
+ {
+ std::string Tag = "Player";
+ std::string Layer = "Default";
+ glm::vec3 Position{ 0.0f, 1.2f, -3.4f };
+ glm::vec3 Rotation{ 0.0f, 180.0f, 0.0f };
+ glm::vec3 Scale{ 1.0f, 1.0f, 1.0f };
+ bool MeshRendererEnabled = true;
+ std::string Mesh = "PlayerCapsule";
+ std::string Material = "PlayerMat";
+ bool CastShadows = true;
+ bool RigidbodyEnabled = true;
+ std::string Mass = "1.0";
+ bool UseGravity = true;
+ std::string MoveSpeed = "6.5";
+ std::string JumpHeight = "2.2";
+ bool GroundCheck = true;
+ } m_Inspector;
+};
diff --git a/Elixir/Elixir.cmake b/Elixir/Elixir.cmake
index 52186f30..12baa89f 100644
--- a/Elixir/Elixir.cmake
+++ b/Elixir/Elixir.cmake
@@ -108,6 +108,10 @@ set(FASTGLTF_COMPILE_AS_CPP20 ON)
add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/Vendor/simdjson)
add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/Vendor/fastgltf)
+set(LUNASVG_BUILD_EXAMPLES OFF CACHE BOOL "Disable LunaSVG examples" FORCE)
+set(LUNASVG_DISABLE_LOAD_SYSTEM_FONTS ON CACHE BOOL "Disable LunaSVG system font lookup" FORCE)
+add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/Vendor/lunasvg)
+
# Resolve msdf-atlas-gen dependencies (skia, freetype, png) through vcpkg.
# Enabled in CI (-DELIXIR_USE_VCPKG=ON) where those libraries are not installed
# system-wide; defaults OFF so local builds keep using system packages.
@@ -157,6 +161,7 @@ target_link_libraries(${PROJECT_NAME}
spdlog
simdjson
fastgltf
+ lunasvg::lunasvg
msdf-atlas-gen
imgui
glfw
@@ -173,4 +178,4 @@ if (ELIXIR_PROFILE)
endif()
# Testing
-add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/Tests)
\ No newline at end of file
+add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/Tests)
diff --git a/Elixir/Source/Engine.h b/Elixir/Source/Engine.h
index bdf8b6fa..a93a5ed9 100644
--- a/Elixir/Source/Engine.h
+++ b/Elixir/Source/Engine.h
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include
#include
@@ -52,12 +53,17 @@
#include
#include
+#include
+#include
+
#include
#include
#include
#include
#include
+#include
#include
+#include
#include
-#include
\ No newline at end of file
+#include
diff --git a/Elixir/Source/Engine/Core/Animation/AnimationCurve.h b/Elixir/Source/Engine/Core/Animation/AnimationCurve.h
new file mode 100644
index 00000000..704060d7
--- /dev/null
+++ b/Elixir/Source/Engine/Core/Animation/AnimationCurve.h
@@ -0,0 +1,143 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace Elixir
+{
+ /** @brief Selects how a curve moves to its next keyframe. */
+ enum class EKeyframeInterpolation : uint8_t
+ {
+ Constant,
+ Linear,
+ EaseIn,
+ EaseOut,
+ EaseInOut,
+ };
+
+ /** @brief Stores one value and its position on a curve. */
+ template
+ struct SKeyframe
+ {
+ float Time = 0.0f;
+ TValue Value{};
+ EKeyframeInterpolation Interpolation = EKeyframeInterpolation::Linear;
+ };
+
+ /**
+ * @brief Stores keyframes and samples values between them.
+ *
+ * The curve is independent from a playback target. It can describe values for GUI
+ * properties, particle parameters, or any other system that supplies an interpolator.
+ *
+ * @tparam TValue Value stored by each keyframe.
+ */
+ template
+ class AnimationCurve
+ {
+ public:
+ using TInterpolator = std::function;
+
+ /** @brief Create a curve that uses linear interpolation where supported. */
+ AnimationCurve() : m_Interpolator(InterpolateLinearly) {}
+
+ /**
+ * @brief Create a curve with a custom value interpolator.
+ * @param interpolator Function that blends two values using progress in [0, 1].
+ */
+ explicit AnimationCurve(TInterpolator interpolator)
+ : m_Interpolator(std::move(interpolator)) {}
+
+ /**
+ * @brief Add or replace a keyframe at its time.
+ * @param keyframe Value and time to store.
+ */
+ void AddKey(SKeyframe keyframe)
+ {
+ const auto it = std::lower_bound(
+ m_Keys.begin(),
+ m_Keys.end(),
+ keyframe.Time,
+ [](const SKeyframe& item, const float time) { return item.Time < time; }
+ );
+ if (it != m_Keys.end() && it->Time == keyframe.Time)
+ {
+ *it = std::move(keyframe);
+ return;
+ }
+
+ m_Keys.insert(it, std::move(keyframe));
+ }
+
+ /** @brief Remove every keyframe from this curve. */
+ void Clear() { m_Keys.clear(); }
+
+ /** @brief Return true when this curve has no values to sample. */
+ bool IsEmpty() const { return m_Keys.empty(); }
+
+ /** @brief Return the time of the final keyframe, or zero when empty. */
+ float GetDuration() const { return m_Keys.empty() ? 0.0f : m_Keys.back().Time; }
+
+ /**
+ * @brief Sample a value at a time in seconds.
+ * @param time Time to sample. Values before or after the curve use the nearest key.
+ * @return The sampled value.
+ */
+ TValue Sample(const float time) const
+ {
+ EE_CORE_ASSERT(!m_Keys.empty(), "AnimationCurve::Sample requires at least one keyframe");
+ if (m_Keys.empty()) return {};
+ if (time <= m_Keys.front().Time) return m_Keys.front().Value;
+ if (time >= m_Keys.back().Time) return m_Keys.back().Value;
+
+ const auto next = std::upper_bound(
+ m_Keys.begin(),
+ m_Keys.end(),
+ time,
+ [](const float value, const SKeyframe& item) { return value < item.Time; }
+ );
+ const auto previous = std::prev(next);
+ if (previous->Interpolation == EKeyframeInterpolation::Constant)
+ return previous->Value;
+
+ const float progress = (time - previous->Time) / (next->Time - previous->Time);
+ return m_Interpolator(previous->Value, next->Value, ApplyInterpolation(previous->Interpolation, progress));
+ }
+
+ private:
+ static float ApplyInterpolation(const EKeyframeInterpolation interpolation, const float progress)
+ {
+ switch (interpolation)
+ {
+ case EKeyframeInterpolation::EaseIn: return progress * progress;
+ case EKeyframeInterpolation::EaseOut: return 1.0f - (1.0f - progress) * (1.0f - progress);
+ case EKeyframeInterpolation::EaseInOut:
+ return progress < 0.5f
+ ? 2.0f * progress * progress
+ : 1.0f - std::pow(-2.0f * progress + 2.0f, 2.0f) * 0.5f;
+ default: return progress;
+ }
+ }
+
+ static TValue InterpolateLinearly(const TValue& from, const TValue& to, const float progress)
+ {
+ if constexpr (requires { from + (to - from) * progress; })
+ {
+ return from + (to - from) * progress;
+ }
+ else
+ {
+ EE_CORE_ASSERT(false, "AnimationCurve requires a custom interpolator for this value type");
+ return from;
+ }
+ }
+
+ std::vector> m_Keys;
+ TInterpolator m_Interpolator;
+ };
+}
diff --git a/Elixir/Source/Engine/Core/Animation/Animator.cpp b/Elixir/Source/Engine/Core/Animation/Animator.cpp
new file mode 100644
index 00000000..370cbc97
--- /dev/null
+++ b/Elixir/Source/Engine/Core/Animation/Animator.cpp
@@ -0,0 +1,103 @@
+#include "epch.h"
+#include "Animator.h"
+
+namespace Elixir
+{
+ Animator::AnimationId Animator::Bind(
+ const float duration,
+ std::function apply,
+ std::function onComplete
+ )
+ {
+ EE_CORE_ASSERT(apply, "Animator::Bind requires an apply callback");
+
+ const AnimationId id = m_NextId++;
+ if (duration <= 0.0f)
+ {
+ apply(0.0f);
+ if (onComplete) onComplete();
+ return id;
+ }
+
+ apply(0.0f);
+ STrack track{
+ .Id = id,
+ .Duration = duration,
+ .Apply = std::move(apply),
+ .OnComplete = std::move(onComplete),
+ };
+ if (m_IsUpdating)
+ m_PendingTracks.push_back(std::move(track));
+ else
+ m_Tracks.push_back(std::move(track));
+ return id;
+ }
+
+ void Animator::Stop(const AnimationId id)
+ {
+ if (m_IsUpdating)
+ {
+ for (auto& track : m_Tracks)
+ {
+ if (track.Id == id)
+ track.Cancelled = true;
+ }
+ std::erase_if(m_PendingTracks, [id](const STrack& track) { return track.Id == id; });
+ return;
+ }
+
+ std::erase_if(m_Tracks, [id](const STrack& track) { return track.Id == id; });
+ }
+
+ void Animator::StopAll()
+ {
+ if (m_IsUpdating)
+ {
+ for (auto& track : m_Tracks)
+ track.Cancelled = true;
+ m_PendingTracks.clear();
+ return;
+ }
+
+ m_Tracks.clear();
+ }
+
+ void Animator::Update(const Timestep frameTime)
+ {
+ if (m_IsUpdating) return;
+
+ const float delta = std::max(0.0f, frameTime.GetSeconds());
+ m_IsUpdating = true;
+ for (auto& track : m_Tracks)
+ {
+ if (track.Cancelled) continue;
+
+ track.Elapsed = std::min(track.Elapsed + delta, track.Duration);
+ const float elapsed = track.Elapsed;
+ const auto apply = track.Apply;
+ apply(elapsed);
+ if (track.Cancelled || track.Elapsed < track.Duration) continue;
+
+ track.Completed = true;
+ const auto onComplete = std::move(track.OnComplete);
+ if (onComplete) onComplete();
+ }
+
+ m_IsUpdating = false;
+ std::erase_if(m_Tracks, [](const STrack& track) { return track.Cancelled || track.Completed; });
+ m_Tracks.insert(
+ m_Tracks.end(),
+ std::make_move_iterator(m_PendingTracks.begin()),
+ std::make_move_iterator(m_PendingTracks.end())
+ );
+ m_PendingTracks.clear();
+ }
+
+ bool Animator::IsAnimating() const
+ {
+ return !m_PendingTracks.empty() || std::ranges::any_of(m_Tracks, [](const STrack& track)
+ {
+ return !track.Cancelled && !track.Completed;
+ });
+ }
+}
diff --git a/Elixir/Source/Engine/Core/Animation/Animator.h b/Elixir/Source/Engine/Core/Animation/Animator.h
new file mode 100644
index 00000000..bba2656b
--- /dev/null
+++ b/Elixir/Source/Engine/Core/Animation/Animator.h
@@ -0,0 +1,77 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+
+namespace Elixir
+{
+ /** @brief Evaluates one or more keyframe curves over time. */
+ class ELIXIR_API Animator
+ {
+ public:
+ using AnimationId = uint64_t;
+
+ /**
+ * @brief Bind a curve to a value receiver.
+ * @tparam TValue Value sampled from the curve.
+ * @param curve Keyframes to evaluate.
+ * @param apply Receives each sampled value.
+ * @param onComplete Called after the final keyframe is applied.
+ * @return Identifier used to stop this binding.
+ */
+ template
+ AnimationId Bind(
+ const AnimationCurve& curve,
+ std::function apply,
+ std::function onComplete = {}
+ )
+ {
+ EE_CORE_ASSERT(!curve.IsEmpty(), "Animator::Bind requires a curve with keyframes");
+ if (curve.IsEmpty()) return 0;
+
+ return Bind(
+ curve.GetDuration(),
+ [curve, apply = std::move(apply)](const float time) { apply(curve.Sample(time)); },
+ std::move(onComplete)
+ );
+ }
+
+ /** @brief Stop one binding without calling its completion callback. */
+ void Stop(AnimationId id);
+
+ /** @brief Stop every binding without calling completion callbacks. */
+ void StopAll();
+
+ /** @brief Advance every active binding; callbacks bind new tracks for the next frame and nested updates are ignored. */
+ void Update(Timestep frameTime);
+
+ /** @brief Return true while at least one binding is active. */
+ bool IsAnimating() const;
+
+ private:
+ AnimationId Bind(
+ float duration,
+ std::function apply,
+ std::function onComplete
+ );
+
+ struct STrack
+ {
+ AnimationId Id;
+ float Duration = 0.0f;
+ float Elapsed = 0.0f;
+ std::function Apply;
+ std::function OnComplete;
+ bool Cancelled = false;
+ bool Completed = false;
+ };
+
+ std::vector m_Tracks;
+ std::vector m_PendingTracks;
+ AnimationId m_NextId = 1;
+ bool m_IsUpdating = false;
+ };
+}
diff --git a/Elixir/Source/Engine/Core/Application.cpp b/Elixir/Source/Engine/Core/Application.cpp
index 28b65ceb..1f4116e0 100644
--- a/Elixir/Source/Engine/Core/Application.cpp
+++ b/Elixir/Source/Engine/Core/Application.cpp
@@ -4,6 +4,7 @@
#include "Engine/GUI/Button.h"
#include "Engine/GUI/Canvas.h"
#include "Engine/GUI/HorizontalBox.h"
+#include "Engine/Icon/IconManager.h"
#include "Engine/GUI/Overlay.h"
#include "Engine/GUI/TextBlock.h"
#include "Engine/GUI/TextField.h"
@@ -37,6 +38,7 @@ namespace Elixir
TextureLoader::Initialize(m_GraphicsContext.get());
FontManager::Initialize(m_GraphicsContext.get());
+ IconManager::Initialize(m_GraphicsContext.get());
m_GUIManager = CreateScope]