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(); m_GUIManager->Initialize( @@ -51,17 +53,17 @@ namespace Elixir //panel->SetBackground({ 1.0f, 0.0f, 0.0f, 1.0f }); panel->SetPadding({ 10, 20, 10, 10 }); const auto button = CreateRef("Hello World until 2020"); - button->SetCornerRadius(4.0); - button->SetNormalBackground(std::dynamic_pointer_cast(buttonBg)); + button->SetCornerRadius(EStyleLayer::Normal, 4.0); + button->SetBackgroundTexture(EStyleLayer::Normal, std::dynamic_pointer_cast(buttonBg)); button->SetPadding({ 20.0f, 0.0f }); const auto button2 = CreateRef(); - button2->SetNormalColor({ 1.0f, 1.0f, 1.0f, 1.0f }); - button2->SetHoverColor({ 0.8f, 0.8f, 1.0f, 1.0f }); + button2->SetBackgroundColor(EStyleLayer::Normal, { 1.0f, 1.0f, 1.0f, 1.0f }); + button2->SetBackgroundColor(EStyleLayer::Hovered, { 0.8f, 0.8f, 1.0f, 1.0f }); //button2->SetCornerRadius(12); - button2->SetInsetShadow({ 10, 10 , 2, 0.3 }); - button2->SetDropShadow({ 20, 20, 10, 1 }); - button2->SetOutline({ { 1, 1, 0, 1 }, 5.0f }); + button2->SetInsetShadow(EStyleLayer::Normal, { 10, 10 , 2, 0.3 }); + button2->SetDropShadow(EStyleLayer::Normal, { 20, 20, 10, 1 }); + button2->SetOutline(EStyleLayer::Normal, { { 1, 1, 0, 1 }, 5.0f }); button2->OnMouseEnter([&]() { EE_CORE_INFO("Mouse entered button!"); }); button2->OnMouseLeave([&]() { EE_CORE_INFO("Mouse left button!"); }); button2->OnMouseDown([&]() { EE_CORE_INFO("Mouse down on button!"); }); @@ -76,9 +78,9 @@ namespace Elixir .SetMargin({ 10, 20, 10, 10 }); const auto button3 = CreateRef(); - button3->SetNormalColor({ 1.0f, 1.0f, 1.0f, 1.0f }); - button3->SetNormalBackground(std::dynamic_pointer_cast(buttonBg)); - button3->SetCornerRadius(12); + button3->SetBackgroundColor(EStyleLayer::Normal, { 1.0f, 1.0f, 1.0f, 1.0f }); + button3->SetBackgroundTexture(EStyleLayer::Normal, std::dynamic_pointer_cast(buttonBg)); + button3->SetCornerRadius(EStyleLayer::Normal, 12); const auto font2 = FontManager::Load("./Assets/Fonts/PlayfairDisplay-Regular.ttf"); const auto txt = CreateRef("Everyone, A pretty text block.."); @@ -128,6 +130,7 @@ namespace Elixir Application::~Application() { EE_PROFILE_ZONE_SCOPED() + IconManager::Shutdown(); FontManager::Shutdown(); Platform::Shutdown(); m_Running = false; diff --git a/Elixir/Source/Engine/Font/Font.h b/Elixir/Source/Engine/Font/Font.h index 82bb679d..172324b6 100644 --- a/Elixir/Source/Engine/Font/Font.h +++ b/Elixir/Source/Engine/Font/Font.h @@ -38,7 +38,7 @@ namespace Elixir float DescenderY; }; - class Font + class ELIXIR_API Font { friend class FontManager; public: @@ -134,4 +134,4 @@ namespace Elixir float m_AscenderY = 0.0f; float m_DescenderY = 0.0f; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Font/FontManager.cpp b/Elixir/Source/Engine/Font/FontManager.cpp index bab20218..10bb8daa 100644 --- a/Elixir/Source/Engine/Font/FontManager.cpp +++ b/Elixir/Source/Engine/Font/FontManager.cpp @@ -96,9 +96,84 @@ namespace Elixir return font->MeasureText(text, fontSize); } + glm::vec2 FontManager::MeasureWrapped( + const std::string& text, + const Ref& font, + float fontSize, + float maxWidth, + std::vector* lines + ) + { + EE_PROFILE_ZONE_SCOPED() + + std::vector outLines; + std::string line; + float widestLine = 0.0f; + float lineWidth = 0.0f; + + const auto appendLine = [&] + { + widestLine = std::max(widestLine, lineWidth); + outLines.push_back(std::move(line)); + line.clear(); + lineWidth = 0.0f; + }; + + size_t index = 0; + while (index < text.size()) + { + const int charLength = UTF8::UTF8CharLength(text[index]); + const uint32_t codepoint = UTF8::UTF8ToCodepoint(text, (int)index); + + if (codepoint == '\n' || codepoint == '\r') + { + appendLine(); + index += charLength; + if (codepoint == '\r' && index < text.size() && text[index] == '\n') + ++index; + continue; + } + + const std::string character = text.substr(index, charLength); + const auto glyph = font->GetGlyph(codepoint); + const float characterWidth = glyph.has_value() + ? glyph->Advance * font->GetScale() * fontSize + : 0.0f; + if (!line.empty() && lineWidth + characterWidth > maxWidth) + { + const size_t wrapAt = line.find_last_of(" \t"); + if (wrapAt != std::string::npos) + { + std::string remainder = line.substr(wrapAt + 1); + line.erase(wrapAt); + lineWidth = MeasureText(line, font, fontSize).x; + appendLine(); + line = std::move(remainder); + lineWidth = MeasureText(line, font, fontSize).x; + } + else + { + appendLine(); + } + continue; + } + + line += character; + lineWidth += characterWidth; + index += charLength; + } + + appendLine(); + const size_t lineCount = outLines.size(); + if (lines) + *lines = std::move(outLines); + + return { widestLine, GetLineHeight(font, fontSize) * lineCount }; + } + float FontManager::GetLineHeight(const Ref& font, const float fontSize) { EE_PROFILE_ZONE_SCOPED() return font->GetLineHeight(fontSize); } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/Font/FontManager.h b/Elixir/Source/Engine/Font/FontManager.h index c950e9ef..9b11fe0a 100644 --- a/Elixir/Source/Engine/Font/FontManager.h +++ b/Elixir/Source/Engine/Font/FontManager.h @@ -1,7 +1,6 @@ #pragma once -#include "Engine/Graphics/TextureSet.h" - +#include #include #include @@ -42,7 +41,7 @@ namespace Elixir * @param text The text to be measured * @param font The font used to display the text * @param fontSize The font size in pixels - * @return A 2d vector containing the width and height of the rendered text in pixels. + * @return A 2D vector containing the width and height of the rendered text in pixels. */ static glm::vec2 MeasureText( const std::string& text, @@ -50,6 +49,24 @@ namespace Elixir float fontSize ); + /** + * + * @param text The text to wrap and measure. + * @param font The font used to display the text. + * @param fontSize The font size in pixels. + * @param maxWidth The maximum line width, in pixels, before wrapping to the next line. + * @param lines When non-null, receives the text split into wrapped lines. + * @return A 2D vector with the wrapped block's width (<= maxWidth, unless a single + * glyph alone exceeds it) and total height (lineCount * GetLineHeight()). + */ + static glm::vec2 MeasureWrapped( + const std::string& text, + const Ref& font, + float fontSize, + float maxWidth, + std::vector* lines = nullptr + ); + /** * Get the line height in pixels, which is the distance from the baseline of one * line of text. diff --git a/Elixir/Source/Engine/GUI/Button.cpp b/Elixir/Source/Engine/GUI/Button.cpp index fea5da2f..e3c76c1f 100644 --- a/Elixir/Source/Engine/GUI/Button.cpp +++ b/Elixir/Source/Engine/GUI/Button.cpp @@ -8,15 +8,17 @@ namespace Elixir::GUI { Button::Button(const std::string& text) - : m_Text(text) + : m_Text(text), + m_Style(GetDefaultStyles().GetWidgetStyle()) { m_Font = FontManager::GetDefaultFont(); - m_DesiredSize = { 120.0f, 40.0f }; } - glm::vec2 Button::ComputeDesiredSize() + void Button::SetStyle(const SButtonStyle& style) { - return m_DesiredSize; + m_Style = style; + MarkLayoutDirty(); + MarkRenderDirty(); } void Button::SetText(const std::string& text) @@ -27,13 +29,6 @@ namespace Elixir::GUI MarkRenderDirty(); // the drawn text changes even when geometry does not } - void Button::SetTextColor(const SColor& color) - { - if (m_TextColor == color) return; - m_TextColor = color; - MarkRenderDirty(); - } - void Button::SetFont(const Ref& font) { EE_CORE_ASSERT(font, "Button::SetFont called with a null font"); @@ -62,47 +57,43 @@ namespace Elixir::GUI // With content, the background is padding-independent and the child re-renders itself // when its geometry changes during layout. if (!HasContent()) - { MarkRenderDirty(); // padding shifts the label position/clip in BuildDrawCommands - } } - void Button::SetCornerRadius(const glm::vec4& radius) + void Button::SetTextColor(const EStyleLayer layer, const SColor& color) { - m_CornerRadius = radius; + m_Style.Get(layer).Foreground = color; MarkRenderDirty(); } - void Button::SetNormalColor(const SColor& color) + glm::vec2 Button::ComputeDesiredSize(const glm::vec2& availableSize) { - m_NormalColor = color; - MarkRenderDirty(); - } + const glm::vec2 innerAvailable = availableSize - glm::vec2( + m_Padding.GetTotalHorizontal(), + m_Padding.GetTotalVertical() + ); - void Button::SetHoverColor(const SColor& color) - { - m_HoverColor = color; - MarkRenderDirty(); - } + glm::vec2 contentSize{ 0.0f, 0.0f }; - void Button::SetBackgroundBorders(const glm::vec4& borders) - { - m_BackgroundBorders = borders; - MarkRenderDirty(); - } + if (HasContent()) + contentSize = m_ContentSlot->GetWidget()->Measure(innerAvailable); + else if (!m_Text.empty()) + contentSize = MeasureTextSize(m_Text); - void Button::SetNormalBackground(const Ref& texture) - { - m_NormalBackground = texture; - MarkRenderDirty(); + const glm::vec2 desiredSize = contentSize + glm::vec2( + m_Padding.GetTotalHorizontal(), + m_Padding.GetTotalVertical() + ); + + return glm::max(desiredSize, m_MinDesiredSize); } void Button::LayoutChildren(const SRect& allocatedSpace) { if (HasContent()) { - const glm::vec2 childSize = m_ContentSlot->GetWidget()->ComputeDesiredSize(); const SRect innerSpace = ApplyPadding(allocatedSpace, m_Padding); + const glm::vec2 childSize = m_ContentSlot->GetWidget()->Measure(innerSpace.Size); const SRect childRect = AlignChild( childSize, @@ -116,38 +107,10 @@ namespace Elixir::GUI } } - void Button::BuildDrawCommands(RenderBatch& batch, int zOrder) + void Button::BuildDrawCommands(RenderBatch& batch, const int zOrder) { - auto buttonColor = m_NormalColor; - - if (m_Hovered) - { - buttonColor = m_HoverColor; - } - - // Background - if (m_NormalBackground) - { - batch.AddTexture( - m_NormalBackground, - m_Geometry, - m_BackgroundBorders, - buttonColor, - zOrder - ); - } - else - { - batch.AddRect( - m_Geometry, - buttonColor, - m_CornerRadius, - m_InsetShadow, - m_DropShadow, - m_Outline, - zOrder - ); - } + const auto& appearance = (const SButtonAppearance&)GetResolvedAppearance(); + batch.AddBrush(appearance.Background, m_Geometry, zOrder); if (!HasContent() && !m_Text.empty()) { @@ -162,13 +125,23 @@ namespace Elixir::GUI { textPos, textSize }, m_Font, m_FontSize, - m_TextColor, + appearance.Foreground, zOrder + 1, m_Geometry ); } } + const SAppearance& Button::GetResolvedAppearance() const + { + return m_Style.Resolve(GetInteractionState()); + } + + SBrush& Button::GetMutableBackgroundBrush(const EStyleLayer layer) + { + return m_Style.Get(layer).Background; + } + std::string Button::ProcessText(const std::string& text, const float availableWidth) { auto size = FontManager::MeasureText(text, m_Font, m_FontSize); @@ -223,4 +196,20 @@ namespace Elixir::GUI Widget::HandleMouseLeave(); Platform::Get().SetPreviousCursorShape(); } -} \ No newline at end of file + + SInputReply Button::HandleMouseDown(const MouseButtonPressedEvent& event) + { + if (!IsEnabled()) return SInputReply::Unhandled(); + + // Button is unconditionally interactive - it must win the mouse-down bubble even when + // it has no OnClick/OnMouseDown/OnMouseUp callback registered (e.g. a subclass that + // overrides HandleClick() directly instead), and even when its own content (e.g. a + // TextBlock label) sits deeper in the hit path. Duplicates the "handled" branch of + // Widget::HandleMouseDown instead of delegating to it, because that branch is now + // gated on m_On*Callback being set - a gate Button must not depend on. + m_Pressed = true; + MarkRenderDirty(); + if (m_OnMouseDownCallback) m_OnMouseDownCallback(); + return SInputReply::HandledAndCaptured(); + } +} diff --git a/Elixir/Source/Engine/GUI/Button.h b/Elixir/Source/Engine/GUI/Button.h index 644cd092..1d9c154c 100644 --- a/Elixir/Source/Engine/GUI/Button.h +++ b/Elixir/Source/Engine/GUI/Button.h @@ -5,19 +5,33 @@ namespace Elixir::GUI { + /** @brief Button visual data for one interactive state. */ + struct SButtonAppearance : SAppearance + { + SColor Foreground{}; + }; + + /** @brief Complete visual style for Button. */ + struct SButtonStyle final : SStyle, TStateStyles{}; + class ELIXIR_API Button : public ContentWidget { public: + /** + * @brief Construct a button with a copy of the current default button style. + * @param text Initial label. + */ explicit Button(const std::string& text = ""); - glm::vec2 ComputeDesiredSize() override; + /** + * @brief Replace this button's complete style. + * @param style Style to copy. + */ + void SetStyle(const SButtonStyle& style); const std::string& GetText() const { return m_Text; } void SetText(const std::string& text); - SColor GetTextColor() const { return m_TextColor; } - void SetTextColor(const SColor& color); - const Ref& GetFont() const { return m_Font; } void SetFont(const Ref& font); @@ -28,39 +42,16 @@ namespace Elixir::GUI void SetPadding(const SPadding& padding); /** - * Get corner radius for each corner individually. - * @return vector (top-left, top-right, bottom-right, bottom-left) + * @brief Set one legacy layer's text color. + * @param layer Legacy interaction layer to change. + * @param color Text color for that layer. */ - glm::vec4 GetCornerRadius() const { return m_CornerRadius; } + void SetTextColor(EStyleLayer layer, const SColor& color); - /** - * Set the same radius for all corners. - * @param radius corner radius in pixels - */ - void SetCornerRadius(const float radius) - { - SetCornerRadius({ radius, radius, radius, radius }); - } - - /** - * Set a radius for each corner individually. - * @param radius vector (top-left, top-right, bottom-right, bottom-left) - */ - void SetCornerRadius(const glm::vec4& radius); - - SColor GetNormalColor() const { return m_NormalColor; } - void SetNormalColor(const SColor& color); - - SColor GetHoverColor() const { return m_HoverColor; } - void SetHoverColor(const SColor& color); - - const glm::vec4& GetBackgroundBorders() const { return m_BackgroundBorders; } - void SetBackgroundBorders(const glm::vec4& borders); - - const Ref& GetNormalBackground() const { return m_NormalBackground; } - void SetNormalBackground(const Ref& texture); + bool CanHandleMouseInput() const override { return IsEnabled(); } protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; void LayoutChildren(const SRect& allocatedSpace) override; void BuildDrawCommands(RenderBatch& batch, int zOrder) override; @@ -70,27 +61,18 @@ namespace Elixir::GUI void HandleMouseEnter() override; void HandleMouseLeave() override; + SInputReply HandleMouseDown(const MouseButtonPressedEvent& event) override; + + const SAppearance& GetResolvedAppearance() const override; + SBrush& GetMutableBackgroundBrush(EStyleLayer layer) override; private: std::string m_Text; - SColor m_TextColor{1.0f, 0.0f, 0.0f, 1.0f}; Ref m_Font; float m_FontSize = 16.0f; SPadding m_Padding; - - // top-left, top-right, bottom-right, bottom-left - glm::vec4 m_CornerRadius = {0.0f, 0.0f, 0.0f, 0.0f}; - - // Colors for different states - SColor m_NormalColor{0.3f, 0.3f, 0.8f, 1.0f}; - SColor m_HoverColor{1.0f, 0.0f, 0.0f, 1.0f}; - - // When texture is used, this represents the borders of 9-patch texture. - // Border mapping = (left, top, right, bottom). - glm::vec4 m_BackgroundBorders = {30.0f, 30.0f, 30.0f, 30.0f}; - - // Textures for different states - Ref m_NormalBackground; + glm::vec2 m_MinDesiredSize{ 120.0f, 40.0f }; + SButtonStyle m_Style; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Canvas.cpp b/Elixir/Source/Engine/GUI/Canvas.cpp index 54de5938..5e7dde47 100644 --- a/Elixir/Source/Engine/GUI/Canvas.cpp +++ b/Elixir/Source/Engine/GUI/Canvas.cpp @@ -3,21 +3,35 @@ namespace Elixir::GUI { Canvas::Canvas() + : m_Size(100.0f, 100.0f) {} + + CanvasSlot& Canvas::AddChild(const Ref& child) { - m_DesiredSize = { 800.0f, 600.0f }; + // Measure once with no constraint so the slot's default Size (used until an explicit + // SetSize/anchors call) reflects this child's real desired size instead of the zeroed + // cache of a widget that has never been through a Measure pass. Must happen + // before TPanel::AddChild() constructs the CanvasSlot below, since its + // constructor snapshots GetDesiredSize(). + if (child) + child->Measure({ UnconstrainedSize, UnconstrainedSize }); + + return TPanel::AddChild(child); } - CanvasSlot& Canvas::AddChild(const Ref& child) + void Canvas::SetSize(const glm::vec2& size) { - const auto slot = CreateRef(child); - m_Slots.push_back(slot); - AttachChild(child); - return *slot; + if (m_Size == size) return; + m_Size = size; + MarkLayoutDirty(); } - glm::vec2 Canvas::ComputeDesiredSize() + glm::vec2 Canvas::ComputeDesiredSize(const glm::vec2& availableSize) { - return m_DesiredSize; + // Never ask for more than the parent actually offered - same rule ScrollBox follows. + // availableSize carries UnconstrainedSize (infinity) on any axis the parent doesn't + // constrain, and min() against that is a no-op, so this only clamps when the parent + // genuinely has less room than m_Size. + return glm::min(m_Size, availableSize); } void Canvas::LayoutChildren(const SRect& allocatedSpace) @@ -25,16 +39,20 @@ namespace Elixir::GUI // Arrange each child based on its anchors and constraints for (const auto& slot : m_Slots) { - const auto canvasSlot = std::static_pointer_cast(slot); - SRect childGeometry = ComputeChildGeometry(canvasSlot, allocatedSpace.Size); + if (!slot->GetWidget()->TakesSpace()) continue; + + SRect childGeometry = ComputeChildGeometry(*slot, allocatedSpace.Size); slot->GetWidget()->ArrangeChildren(childGeometry); } } - SRect Canvas::ComputeChildGeometry(const Ref& slot, const glm::vec2& canvasSize) const + SRect Canvas::ComputeChildGeometry( + const CanvasSlot& slot, + const glm::vec2& canvasSize + ) const { - const SAnchors& anchors = slot->GetAnchors(); - const SConstraint& constraint = slot->GetConstraint(); + const SAnchors& anchors = slot.GetAnchors(); + const SConstraint& constraint = slot.GetConstraint(); SRect result; diff --git a/Elixir/Source/Engine/GUI/Canvas.h b/Elixir/Source/Engine/GUI/Canvas.h index 1802cee7..7a53aa26 100644 --- a/Elixir/Source/Engine/GUI/Canvas.h +++ b/Elixir/Source/Engine/GUI/Canvas.h @@ -18,7 +18,7 @@ namespace Elixir::GUI CanvasSlot& SetAnchors(const SAnchors& anchors) { m_Anchors = anchors; - if (m_Widget) m_Widget->MarkLayoutDirty(); + InvalidateOwnerLayout(); return *this; } @@ -27,14 +27,14 @@ namespace Elixir::GUI CanvasSlot& SetPosition(const glm::vec2& pos) { m_Constraint.Position = pos; - if (m_Widget) m_Widget->MarkLayoutDirty(); + InvalidateOwnerLayout(); return *this; } CanvasSlot& SetSize(const glm::vec2& size) { m_Constraint.Size = size; - if (m_Widget) m_Widget->MarkLayoutDirty(); + InvalidateOwnerLayout(); return *this; } @@ -46,14 +46,14 @@ namespace Elixir::GUI ) { m_Constraint.Offsets = { left, top, right, bottom }; - if (m_Widget) m_Widget->MarkLayoutDirty(); + InvalidateOwnerLayout(); return *this; } CanvasSlot& SetAlignment(const glm::vec2& alignment) { m_Constraint.Alignment = alignment; - if (m_Widget) m_Widget->MarkLayoutDirty(); + InvalidateOwnerLayout(); return *this; } @@ -62,19 +62,36 @@ namespace Elixir::GUI SConstraint m_Constraint; }; - class ELIXIR_API Canvas final : public Panel + extern template class TPanel; + + class ELIXIR_API Canvas final : public TPanel { public: Canvas(); - CanvasSlot& AddChild(const Ref& child); + CanvasSlot& AddChild(const Ref& child) override; - glm::vec2 ComputeDesiredSize() override; + /** + * @brief Set the Canvas size. + * + * Canvas has no intrinsic content-driven size the way a flow container does - + * children are absolutely positioned, so there is no general way to derive "how big + * this panel wants to be" from them. This is the configured value ComputeDesiredSize + * reports, capped to whatever the parent actually offers (same rule ScrollBox + * follows) - it does not clip or otherwise constrain children placed outside it. + * + * @param size The desired size. + */ + void SetSize(const glm::vec2& size); protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; void LayoutChildren(const SRect& allocatedSpace) override; private: - SRect ComputeChildGeometry(const Ref& slot, const glm::vec2& canvasSize) const; + SRect ComputeChildGeometry(const CanvasSlot& slot, const glm::vec2& canvasSize) const; + + // The size this Canvas wants to occupy in the parent layout if no constraints. + glm::vec2 m_Size; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Checkbox.cpp b/Elixir/Source/Engine/GUI/Checkbox.cpp new file mode 100644 index 00000000..2a7c5329 --- /dev/null +++ b/Elixir/Source/Engine/GUI/Checkbox.cpp @@ -0,0 +1,130 @@ +#include "epch.h" +#include "Checkbox.h" + +#include + +namespace Elixir::GUI +{ + const SAppearance& SCheckboxStyle::Resolve( + const bool checked, + const EInteractionState states + ) const + { + if (!checked) + return TStateStyles::Resolve(states); + + if (states & EInteractionState::Disabled && CheckedDisabled) return *CheckedDisabled; + if (states & EInteractionState::Pressed && CheckedPressed) return *CheckedPressed; + if (states & EInteractionState::Focused && CheckedFocused) return *CheckedFocused; + if (states & EInteractionState::Hovered && CheckedHovered) return *CheckedHovered; + return Checked; + } + + Checkbox::Checkbox() + : m_Style(GetDefaultStyles().GetWidgetStyle()) + { + } + + void Checkbox::SetStyle(const SCheckboxStyle& style) + { + m_Style = style; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void Checkbox::SetChecked(const bool checked) + { + if (m_Checked == checked) return; + m_Checked = checked; + MarkRenderDirty(); + } + + void Checkbox::SetSize(const glm::vec2& size) + { + if (m_Size == size) return; + m_Size = size; + MarkLayoutDirty(); + } + + SColor Checkbox::GetCheckedColor() const + { + return m_Style.Checked.Background.Color; + } + + void Checkbox::SetCheckedColor(const SColor& color) + { + m_Style.Checked.Background.Color = color; + if (m_Style.CheckedHovered) m_Style.CheckedHovered->Background.Color = color; + if (m_Style.CheckedPressed) m_Style.CheckedPressed->Background.Color = color; + if (m_Style.CheckedFocused) m_Style.CheckedFocused->Background.Color = color; + if (m_Style.CheckedDisabled) m_Style.CheckedDisabled->Background.Color = color; + MarkRenderDirty(); + } + + glm::vec2 Checkbox::ComputeDesiredSize(const glm::vec2& availableSize) + { + // Never ask for more than the parent actually offered - same rule Canvas follows. + return glm::min(m_Size, availableSize); + } + + void Checkbox::BuildDrawCommands(RenderBatch& batch, const int zOrder) + { + const auto& appearance = GetResolvedAppearance(); + batch.AddBrush(appearance.Background, m_Geometry, zOrder); + } + + const SAppearance& Checkbox::GetResolvedAppearance() const + { + return m_Style.Resolve(m_Checked, GetInteractionState()); + } + + SBrush& Checkbox::GetMutableBackgroundBrush(const EStyleLayer layer) + { + return m_Style.Get(layer).Background; + } + + void Checkbox::HandleMouseEnter() + { + Widget::HandleMouseEnter(); + if (!IsEnabled()) + { + Platform::Get().SetDefaultCursorShape(); + return; + } + + Platform::Get().SetCursorShape(ECursorShape::Hand); + } + + void Checkbox::HandleMouseLeave() + { + Widget::HandleMouseLeave(); + Platform::Get().SetPreviousCursorShape(); + } + + SInputReply Checkbox::HandleMouseDown(const MouseButtonPressedEvent& event) + { + if (!IsEnabled()) return SInputReply::Unhandled(); + + m_Pressed = true; + MarkRenderDirty(); + if (m_OnMouseDownCallback) m_OnMouseDownCallback(); + return SInputReply::HandledAndCaptured(); + } + + void Checkbox::HandleClick() + { + // Belt-and-braces: HandleMouseDown already refuses the press while disabled, so + // Manager never sets this widget as m_PressedWidget in the common case - but + // SetEnabled(false) can still run in between a real mouse-down and mouse-up on this + // same widget (Manager latches m_PressedWidget at press time), so this guard is what + // actually prevents a toggle from that interleaving, not the one above. + if (!IsEnabled()) return; + + m_Checked = !m_Checked; + MarkRenderDirty(); + if (m_OnCheckedChangedCallback) m_OnCheckedChangedCallback(m_Checked); + + // Still runs the base OnClick callback too, in case a caller wants both. + Widget::HandleClick(); + } +} diff --git a/Elixir/Source/Engine/GUI/Checkbox.h b/Elixir/Source/Engine/GUI/Checkbox.h new file mode 100644 index 00000000..af53172d --- /dev/null +++ b/Elixir/Source/Engine/GUI/Checkbox.h @@ -0,0 +1,126 @@ +#pragma once + +#include + +namespace Elixir::GUI +{ + /** + * @brief Complete visual style for Checkbox. + * + * Checked appearances belong to Checkbox because checked is component data, not a + * generic interaction state. The checkbox renderer chooses between checked and unchecked + * appearances before applying the normal interaction-state priority. + */ + struct SCheckboxStyle final : SStyle, TStateStyles + { + SAppearance Checked; + std::optional CheckedHovered; + std::optional CheckedPressed; + std::optional CheckedFocused; + std::optional CheckedDisabled; + + /** + * @brief Select the appearance for checked state and active interaction states. + * @param checked Whether the checkbox is checked. + * @param states Interaction states active on the checkbox. + * @return The selected complete appearance. + */ + ELIXIR_API const SAppearance& Resolve(bool checked, EInteractionState states) const; + }; + + /** + * @brief A small toggle square with checked and unchecked appearances. + * + * Checkbox selects checked data from SCheckboxStyle itself. This keeps checked out of the + * generic widget state model and leaves the component free to define its own rendering. + */ + class ELIXIR_API Checkbox : public Widget + { + public: + /** @brief Construct a checkbox with a copy of the current default checkbox style. */ + Checkbox(); + + /** + * @brief Replace this checkbox's complete style. + * @param style Style to copy. + */ + void SetStyle(const SCheckboxStyle& style); + + bool GetChecked() const { return m_Checked; } + + /** + * Set the checked state programmatically. Deliberately does NOT invoke + * OnCheckedChanged - that callback fires only from user clicks (HandleClick). + * If SetChecked also fired it, any code that syncs this widget FROM an external + * model (e.g. a callback wired the other way) would immediately echo its own + * write back into that model. + * @param checked the new checked state. + */ + void SetChecked(bool checked); + + /** + * Register a callback invoked when the user toggles this checkbox by clicking it. + * Never invoked by SetChecked - see its doc comment. + * @param callback receives the new checked state. + */ + void OnCheckedChanged(const std::function& callback) { m_OnCheckedChangedCallback = callback; } + + const glm::vec2& GetSize() const { return m_Size; } + + /** + * Set the size this Checkbox asks for, capped to whatever the parent actually + * offers - same convention Canvas::SetSize uses. + * @param size the desired size. + */ + void SetSize(const glm::vec2& size); + + /** + * @brief Get the normal checked background color. + * @return Current checked color. + */ + SColor GetCheckedColor() const; + + /** + * @brief Set one color for every checked appearance. + * + * This compatibility method changes the complete checkbox style. New code should set + * Checked, CheckedHovered and the other checked appearances directly in SCheckboxStyle. + * @param color Background color for checked appearances. + */ + void SetCheckedColor(const SColor& color); + + bool CanHandleMouseInput() const override { return IsEnabled(); } + + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; + void BuildDrawCommands(RenderBatch& batch, int zOrder) override; + + void HandleMouseEnter() override; + void HandleMouseLeave() override; + + // Same override Button uses, and for the same reason: a Checkbox must win the + // mouse-down bubble even with no OnClick/OnMouseDown/OnMouseUp callback registered, + // because it drives its own state from HandleClick() directly rather than through + // those callbacks - Widget::HandleMouseDown's default gate would otherwise return + // Unhandled() for it. + SInputReply HandleMouseDown(const MouseButtonPressedEvent& event) override; + + void HandleClick() override; + + const SAppearance& GetResolvedAppearance() const override; + SBrush& GetMutableBackgroundBrush(EStyleLayer layer) override; + + private: + bool m_Checked = false; + + // Configured size; ComputeDesiredSize never returns more than this on either axis + // (capped to availableSize). 13x13 matches the ad-hoc ViewportPanel::MakeCheckbox + // helper this widget replaces, kept as the default so migrating call sites look + // identical without an explicit SetSize. + glm::vec2 m_Size{ 13.0f, 13.0f }; + + SCheckboxStyle m_Style; + + std::function m_OnCheckedChangedCallback; + }; +} diff --git a/Elixir/Source/Engine/GUI/Definitions.h b/Elixir/Source/Engine/GUI/Definitions.h index e457ed5a..c6a1ecc0 100644 --- a/Elixir/Source/Engine/GUI/Definitions.h +++ b/Elixir/Source/Engine/GUI/Definitions.h @@ -19,6 +19,25 @@ namespace Elixir::GUI return Position.x != -1 && Position.y != -1 && Size.x != -1 && Size.y != -1; } + /** + * @brief Intersect two rects, returning the overlapping region. + * + * Both inputs are assumed to be real geometric rects - callers check IsValid() first, + * same convention IsValid() itself already relies on. + * + * The result's Size is clamped to a minimum of (0, 0) when the rect do not overlap. + * + * @param a First rect. + * @param b Second rect. + * @return The overlapping rect; zero-sized (never negative) when a and b don't overlap. + */ + static SRect Intersect(const SRect& a, const SRect& b) + { + const glm::vec2 min = glm::max(a.Position, b.Position); + const glm::vec2 max = glm::min(a.Position + a.Size, b.Position + b.Size); + return { min, glm::max(max - min, glm::vec2(0.0f)) }; + } + SRect operator*(const float scale) const { return SRect(Position * scale, Size * scale); @@ -68,17 +87,25 @@ namespace Elixir::GUI enum class EHorizontalAlignment : uint8_t { - Left, Center, Right + Left, Center, Right, Fill }; enum class EVerticalAlignment : uint8_t { - Top, Center, Bottom + Top, Center, Bottom, Fill }; + /** + * @brief Controls whether a widget renders, occupies layout space, and receives + * hit-tests. + */ enum class EVisibility : uint8_t { - Visible, Hidden + Visible, + HitTestInvisible, + SelfHitTestInvisible, + Hidden, + Collapsed }; struct SMargin @@ -116,6 +143,31 @@ namespace Elixir::GUI typedef SMargin SPadding; + /** + * @brief How a LayoutSlot sizes its child along the owner's MAIN axis. + * + * VerticalBox: height; + * HorizontalBox: width; + * Overlay: ignores this - it has no main axis. + * + * The cross axis is sized independently, via EHorizontalAlignment::Fill / + * EVerticalAlignment::Fill on the same slot. + */ + struct SSizeParam + { + enum class ERule : uint8_t + { + Auto, Fill, Fixed + }; + + ERule Rule = ERule::Auto; + float Value = 1.0f; // Fill: proportion; Fixed: pixels; Auto: ignored. + + static SSizeParam Auto() { return { ERule::Auto, 0.0f }; } + static SSizeParam Fill(const float ratio = 1.0f) { return { ERule::Fill, ratio }; } + static SSizeParam Fixed(const float pixels) { return { ERule::Fixed, pixels }; } + }; + struct SAnchors { // Normalized positions (0-1) relative to parent. diff --git a/Elixir/Source/Engine/GUI/HorizontalBox.cpp b/Elixir/Source/Engine/GUI/HorizontalBox.cpp index c7b2fb05..88755aeb 100644 --- a/Elixir/Source/Engine/GUI/HorizontalBox.cpp +++ b/Elixir/Source/Engine/GUI/HorizontalBox.cpp @@ -3,48 +3,57 @@ namespace Elixir::GUI { - LayoutSlot& HorizontalBox::AddChild(const Ref& child) + glm::vec2 HorizontalBox::ComputeDesiredSize(const glm::vec2& availableSize) { - const auto slot = CreateRef(child); - m_Slots.push_back(slot); - AttachChild(child); - return *slot; - } + const glm::vec2 innerAvailable = { + UnconstrainedSize, + availableSize.y - m_Padding.GetTotalVertical() + }; - void HorizontalBox::SetStretching(const bool stretching) - { - if (m_Stretching == stretching) return; - m_Stretching = stretching; - MarkLayoutDirty(); - } - - glm::vec2 HorizontalBox::ComputeDesiredSize() - { glm::vec2 totalSize = { 0, 0 }; - for (auto& slot : m_Slots) + for (const auto& slot : m_Slots) { - const auto layoutSlot = std::static_pointer_cast(slot); + if (!slot->GetWidget()->TakesSpace()) continue; - auto childSize = slot->GetWidget()->ComputeDesiredSize(); - const auto margin = layoutSlot->GetMargin(); + const auto margin = slot->GetMargin(); + const auto sizeRule = slot->GetSizeRule(); + const auto minSize = slot->GetMinSize(); + const auto maxSize = slot->GetMaxSize(); - // Add margin - childSize.x += margin.GetTotalHorizontal(); - childSize.y += margin.GetTotalVertical(); + const glm::vec2 childConstraint = { + innerAvailable.x, + innerAvailable.y - margin.GetTotalVertical() + }; - // Width accumulates - totalSize.x += childSize.x; + auto childSize = slot->GetWidget()->Measure(childConstraint); + childSize.y += margin.GetTotalVertical(); // Height is the maximum totalSize.y = std::max(totalSize.y, childSize.y); + + // Width accumulates using the same rule and main-axis constraints as layout. + // Fill has no measured intrinsic width, but its minimum and margins still + // reserve space because LayoutChildren enforces that minimum. + switch (sizeRule.Rule) + { + case SSizeParam::ERule::Fill: + totalSize.x += minSize.x + margin.GetTotalHorizontal(); + break; + case SSizeParam::ERule::Fixed: + totalSize.x += std::max(minSize.x, std::min(maxSize.x, sizeRule.Value)) + margin.GetTotalHorizontal(); + break; + case SSizeParam::ERule::Auto: + default: + totalSize.x += std::max(minSize.x, std::min(maxSize.x, childSize.x)) + margin.GetTotalHorizontal(); + break; + } } // Add panel padding totalSize.x += m_Padding.GetTotalHorizontal(); totalSize.y += m_Padding.GetTotalVertical(); - m_DesiredSize = totalSize; return totalSize; } @@ -53,71 +62,121 @@ namespace Elixir::GUI // Calculate available space after padding const SRect innerSpace = ApplyPadding(allocatedSpace, m_Padding); - // First: calculate fixed sizes - float usedSpace = 0.0f; + // Measure every child exactly once, with its real constraint, and reuse the result in + // both loops below. Fill/Fixed children still get measured on the cross axis (height) + // - their main-axis (width) entry is only actually used below for Auto children. + // Keep measurements indexed by slot. Collapsed children are skipped by both later + // passes, but they still need an unused entry so a preceding collapsed slot cannot + // shift the measurement belonging to a visible sibling. + std::vector childSizes(m_Slots.size()); - for (auto& slot : m_Slots) + for (size_t i = 0; i < m_Slots.size(); ++i) { - const auto layoutSlot = std::static_pointer_cast(slot); + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; - const auto margin = layoutSlot->GetMargin(); - const auto hAlignment = layoutSlot->GetHorizontalAlignment(); + const auto margin = slot->GetMargin(); - if (m_Stretching) - { - const glm::vec2 childSize = slot->GetWidget()->ComputeDesiredSize(); - usedSpace += childSize.x + margin.GetTotalHorizontal(); - } + const glm::vec2 childConstraint = { + UnconstrainedSize, + innerSpace.Size.y - margin.GetTotalVertical() + }; + + childSizes[i] = slot->GetWidget()->Measure(childConstraint); } - // Calculate space available for fill slots - const float availableForFill = std::max(0.0f, innerSpace.Size.x - usedSpace); + // First reserve every constrained size. Fill slots start at their minimum so those + // constraints cannot make the final arrangement overflow after space is divided. + std::vector childWidths(m_Slots.size()); + float occupiedSpace = 0.0f; - // Second: Arrange children - float currentX = innerSpace.Position.x; - - for (auto& slot : m_Slots) + for (size_t i = 0; i < m_Slots.size(); ++i) { - const auto layoutSlot = std::static_pointer_cast(slot); - - const glm::vec2 childSize = slot->GetWidget()->ComputeDesiredSize(); - const auto margin = layoutSlot->GetMargin(); - const auto hAlignment = layoutSlot->GetHorizontalAlignment(); - const auto vAlignment = layoutSlot->GetVerticalAlignment(); - const auto fillRatio = layoutSlot->GetFillRatio(); - const auto minSize = layoutSlot->GetMinSize(); - const auto maxSize = layoutSlot->GetMaxSize(); + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; - // Calculate child width - float childWidth; + const auto margin = slot->GetMargin(); + const auto sizeRule = slot->GetSizeRule(); + const auto minSize = slot->GetMinSize(); + const auto maxSize = slot->GetMaxSize(); - if (m_Stretching && fillRatio > 0.0f) - { - // Proportional fill - childWidth = availableForFill * fillRatio - margin.GetTotalHorizontal(); - } - else + switch (sizeRule.Rule) { - // Use the desired width - childWidth = childSize.x; + case SSizeParam::ERule::Fill: + childWidths[i] = minSize.x; + break; + case SSizeParam::ERule::Fixed: + childWidths[i] = std::max(minSize.x, std::min(maxSize.x, sizeRule.Value)); + break; + case SSizeParam::ERule::Auto: + default: + childWidths[i] = std::max(minSize.x, std::min(maxSize.x, childSizes[i].x)); + break; } - // Clamp to min/max constraints - childWidth = std::max(minSize.x, std::min(maxSize.x, childWidth)); - - // Calculate child height based on alignment - float childHeight; + occupiedSpace += childWidths[i] + margin.GetTotalHorizontal(); + } - if (m_Stretching) + // Divide the remaining space by Fill ratio. A slot that reaches MaxSize is removed + // from later rounds, so its unused share is redistributed to its siblings. + float remainingSpace = std::max(0.0f, innerSpace.Size.x - occupiedSpace); + while (remainingSpace > 0.0f) + { + float activeFillRatio = 0.0f; + for (size_t i = 0; i < m_Slots.size(); ++i) { - childHeight = innerSpace.Size.y - margin.GetTotalVertical(); + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto sizeRule = slot->GetSizeRule(); + if (sizeRule.Rule != SSizeParam::ERule::Fill || sizeRule.Value <= 0.0f) continue; + if (childWidths[i] >= slot->GetMaxSize().x) continue; + + activeFillRatio += sizeRule.Value; } - else + + if (activeFillRatio <= 0.0f) break; + + float distributedSpace = 0.0f; + for (size_t i = 0; i < m_Slots.size(); ++i) { - childHeight = childSize.y; + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto sizeRule = slot->GetSizeRule(); + if (sizeRule.Rule != SSizeParam::ERule::Fill || sizeRule.Value <= 0.0f) continue; + + const float capacity = slot->GetMaxSize().x - childWidths[i]; + if (capacity <= 0.0f) continue; + + const float share = remainingSpace * (sizeRule.Value / activeFillRatio); + const float addedSize = std::min(share, capacity); + childWidths[i] += addedSize; + distributedSpace += addedSize; } - childHeight = std::max(minSize.y, std::min(maxSize.y, childHeight)); + if (distributedSpace <= 0.0f) break; + remainingSpace -= distributedSpace; + } + + // Second: Arrange children + float currentX = innerSpace.Position.x; + + for (size_t i = 0; i < m_Slots.size(); ++i) + { + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto margin = slot->GetMargin(); + const auto vAlignment = slot->GetVerticalAlignment(); + const auto minSize = slot->GetMinSize(); + const auto maxSize = slot->GetMaxSize(); + + const float childWidth = childWidths[i]; + + // Clamp the desired height; EVerticalAlignment::Fill overrides it below with the + // full available height regardless of this value (see Widget::AlignVertically). + const float childHeight = std::max(minSize.y, std::min(maxSize.y, childSizes[i].y)); // Create available space for this child SRect childAvailableSpace; @@ -142,4 +201,4 @@ namespace Elixir::GUI currentX += childWidth + margin.GetTotalHorizontal(); } } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/HorizontalBox.h b/Elixir/Source/Engine/GUI/HorizontalBox.h index 8cc94dab..345530ff 100644 --- a/Elixir/Source/Engine/GUI/HorizontalBox.h +++ b/Elixir/Source/Engine/GUI/HorizontalBox.h @@ -4,18 +4,10 @@ namespace Elixir::GUI { - class ELIXIR_API HorizontalBox final : public Panel + class ELIXIR_API HorizontalBox final : public TPanel { - public: - LayoutSlot& AddChild(const Ref& child); - - bool IsStretching() const { return m_Stretching; } - void SetStretching(bool stretching); - - protected: - glm::vec2 ComputeDesiredSize() override; + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; void LayoutChildren(const SRect& allocatedSpace) override; - - bool m_Stretching = false; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/GUI/Icon.cpp b/Elixir/Source/Engine/GUI/Icon.cpp new file mode 100644 index 00000000..cd482b03 --- /dev/null +++ b/Elixir/Source/Engine/GUI/Icon.cpp @@ -0,0 +1,73 @@ +#include "epch.h" +#include "Icon.h" + +namespace Elixir::GUI +{ + Icon::Icon() + : m_Style(GetDefaultStyles().GetWidgetStyle()) + { + SetVisibility(EVisibility::SelfHitTestInvisible); + } + + void Icon::SetIcon(const Ref<::Elixir::Icon>& icon) + { + if (m_Icon == icon) return; + m_Icon = icon; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void Icon::SetSize(const glm::vec2 size) + { + if (m_Size == size) return; + m_Size = size; + MarkLayoutDirty(); + } + + void Icon::SetStyle(const SIconStyle& style) + { + m_Style = style; + MarkRenderDirty(); + } + + void Icon::SetColor(const EStyleLayer layer, const SColor& color) + { + m_Style.Get(layer).Foreground = color; + MarkRenderDirty(); + } + + glm::vec2 Icon::ComputeDesiredSize(const glm::vec2& availableSize) + { + return glm::min(m_Size, availableSize); + } + + void Icon::BuildDrawCommands(RenderBatch& batch, const int zOrder) + { + if (!m_Icon) return; + + const auto iconSize = m_Icon->GetMetrics().Size; + if (iconSize.x <= 0.0f || iconSize.y <= 0.0f || m_Geometry.Size.x <= 0.0f || + m_Geometry.Size.y <= 0.0f) + return; + + const float scale = glm::min( + m_Geometry.Size.x / iconSize.x, + m_Geometry.Size.y / iconSize.y + ); + const glm::vec2 drawSize = iconSize * scale; + const SRect drawRect{ + m_Geometry.Position + (m_Geometry.Size - drawSize) * 0.5f, + drawSize + }; + + const auto texture = m_Icon->GetTexture(drawSize); + if (!texture) return; + + batch.AddIcon( + texture, + drawRect, + m_Style.Resolve(GetInteractionState()).Foreground, + zOrder + ); + } +} diff --git a/Elixir/Source/Engine/GUI/Icon.h b/Elixir/Source/Engine/GUI/Icon.h new file mode 100644 index 00000000..c6671b4e --- /dev/null +++ b/Elixir/Source/Engine/GUI/Icon.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +namespace Elixir::GUI +{ + /** @brief Describes one icon tint. */ + struct SIconAppearance + { + SColor Foreground{ 1.0f, 1.0f, 1.0f, 1.0f }; + }; + + /** @brief Stores icon tints for interaction states. */ + struct SIconStyle final : SStyle, TStateStyles{}; + + /** + * @brief Draws one tinted icon asset. + * + * Icon is self-hit-test-invisible and preserves its intrinsic aspect ratio. Use it inside + * an interactive widget when the icon is a visual label for another control. + */ + class ELIXIR_API Icon final : public Widget + { + public: + /** @brief Construct an empty icon with the default icon style. */ + Icon(); + + /** + * @brief Set the asset this widget draws. + * @param icon Imported icon asset, or nullptr to draw nothing. + */ + void SetIcon(const Ref<::Elixir::Icon>& icon); + + /** @brief Get the asset this widget draws. */ + const Ref<::Elixir::Icon>& GetIcon() const { return m_Icon; } + + /** + * @brief Set the logical size this icon requests from layout. + * @param size Requested dimensions in GUI units. + */ + void SetSize(glm::vec2 size); + + /** @brief Get the logical size this icon requests from layout. */ + glm::vec2 GetSize() const { return m_Size; } + + /** + * @brief Replace this icon's complete style. + * @param style Style to copy into this icon. + */ + void SetStyle(const SIconStyle& style); + + /** + * @brief Set the tint for one interaction layer. + * @param layer Interaction layer to change. + * @param color New tint. + */ + void SetColor(EStyleLayer layer, const SColor& color); + + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; + void BuildDrawCommands(RenderBatch& batch, int zOrder) override; + + private: + Ref<::Elixir::Icon> m_Icon; + SIconStyle m_Style; + glm::vec2 m_Size{ 16.0f, 16.0f }; + }; +} diff --git a/Elixir/Source/Engine/GUI/Manager.cpp b/Elixir/Source/Engine/GUI/Manager.cpp index f8536e66..b48ef146 100644 --- a/Elixir/Source/Engine/GUI/Manager.cpp +++ b/Elixir/Source/Engine/GUI/Manager.cpp @@ -24,10 +24,22 @@ namespace Elixir::GUI void Manager::ArrangeLayout(const Extent2D& extent) const { - if (m_RootWidget) + m_LastExtent = extent; + + if (m_Layers.empty() || !m_Layers[0].Root) + return; + + const SRect screenRect = { { 0, 0 }, { extent.Width, extent.Height } }; + m_Layers[0].Root->ArrangeChildren(screenRect); + + for (size_t i = 1; i < m_Layers.size(); ++i) { - const SRect rootGeometry = { { 0, 0 }, { extent.Width, extent.Height } }; - m_RootWidget->ArrangeChildren(rootGeometry); + const auto& layer = m_Layers[i]; + if (!layer.Root) continue; + + const glm::vec2 desiredSize = layer.Root->Measure({ UnconstrainedSize, UnconstrainedSize }); + const SRect popupRect = ComputePopupRect(layer.Anchor, desiredSize, screenRect); + layer.Root->ArrangeChildren(popupRect); } } @@ -35,13 +47,17 @@ namespace Elixir::GUI { ProcessInput(); - if (m_RootWidget) - m_RootWidget->Update(frameTime); + for (const auto& layer : m_Layers) + { + if (layer.Root) + layer.Root->Update(frameTime); + } } void Manager::Render() { - if (!m_RootWidget || !m_RootWidget->IsVisible()) return; + if (m_Layers.empty() || !m_Layers[0].Root || !m_Layers[0].Root->IsRenderVisible()) + return; if (NeedsRebuild()) { @@ -59,32 +75,103 @@ namespace Elixir::GUI dispatcher.Dispatch(EE_BIND_EVENT_FN(Manager::HandleFramebufferResize)); dispatcher.Dispatch(EE_BIND_EVENT_FN(Manager::HandleKeyPressed)); dispatcher.Dispatch(EE_BIND_EVENT_FN(Manager::HandleKeyTyped)); + dispatcher.Dispatch(EE_BIND_EVENT_FN(Manager::HandleMouseScrolled)); + } + + void Manager::SetRoot(const Ref& root) + { + if (m_Layers.empty()) + m_Layers.push_back({ root, {}, false }); + else + { + if (m_Layers[0].Root != root) + ResetInputRouting(); + m_Layers[0] = { root, {}, false }; + } + + ++m_LayerStackVersion; + } + + void Manager::PushPopup(const Ref& widget, const SRect& anchor) + { + m_Layers.push_back({ widget, anchor, true }); + ++m_LayerStackVersion; + + if (widget) + { + const SRect screenRect = { { 0, 0 }, { m_LastExtent.Width, m_LastExtent.Height } }; + const glm::vec2 desiredSize = widget->Measure({ UnconstrainedSize, UnconstrainedSize }); + const SRect popupRect = ComputePopupRect(anchor, desiredSize, screenRect); + widget->ArrangeChildren(popupRect); + } + } + + void Manager::PopPopup() + { + if (m_Layers.size() <= 1) return; + + m_Layers.pop_back(); + ResetInputRouting(); + ++m_LayerStackVersion; + } + + void Manager::ClearPopups() + { + if (m_Layers.size() <= 1) return; + + m_Layers.resize(1); + ResetInputRouting(); + ++m_LayerStackVersion; + } + + bool Manager::WantsMouse() const + { + return !m_MouseCapture.expired() || std::ranges::any_of( + m_HoverPath, + [](const Ref& widget) { return widget->CanHandleMouseInput(); } + ); } void Manager::AssembleFrame() { m_RenderBatch.Clear(); - if (m_RootWidget && m_RootWidget->IsVisible()) + int zCursor = 0; + bool rebuilt = false; + + // Same zCursor continuing across layers: layer 0 occupies the low z-bands, and each + // popup above it starts its own CollectDrawCommands walk above everything the layers + // below it used — reusing the existing per-subtree z-banding, so popups always end + // up on top without a magic z offset. + for (const auto& layer : m_Layers) { - int zCursor = 0; - bool rebuilt = false; - m_RootWidget->CollectDrawCommands(m_RenderBatch, zCursor, rebuilt); + if (layer.Root && layer.Root->IsRenderVisible()) + layer.Root->CollectDrawCommands( + m_RenderBatch, + zCursor, + rebuilt, + {{ -1, -1 }, { -1, -1 }} + ); } + // No default focus visual here on purpose: a widget's own IsFocused() is already + // enough for it to render its own focus state - color swap, outline, background + // texture, whatever fits - inside its own BuildDrawCommands, the same way Button + // already reacts to m_Hovered. A widget that doesn't opt in just has no focus visual, + // rather than the Manager imposing a generic ring on every widget regardless of type. m_RenderBatch.Sort(); } bool Manager::NeedsRebuild() const { return Widget::CurrentDirtyEpoch() != m_LastRenderedEpoch - || m_LastRenderedRoot.lock() != m_RootWidget; + || m_LayerStackVersion != m_LastRenderedLayerVersion; } void Manager::MarkRebuilt() { m_LastRenderedEpoch = Widget::CurrentDirtyEpoch(); - m_LastRenderedRoot = m_RootWidget; + m_LastRenderedLayerVersion = m_LayerStackVersion; } bool Manager::HandleFramebufferResize(const FramebufferResizeEvent& event) const @@ -95,24 +182,77 @@ namespace Elixir::GUI return true; } - bool Manager::HandleKeyPressed(const KeyPressedEvent& event) const + bool Manager::HandleKeyPressed(const KeyPressedEvent& event) { - if (m_FocusedWidget) + if (event.GetKeyCode() == EE_KEY_TAB) { - ProcessKeyPressedRecursive(m_FocusedWidget, event); + if (event.IsShiftPressed()) + FocusPrevious(); + else + FocusNext(); + + return true; } - return true; + if (event.GetKeyCode() == EE_KEY_ESCAPE) + { + SetFocusedWidget(nullptr); + return true; + } + + // SetEnabled(false) has no way to clear m_FocusedWidget itself - Widget has no + // back-reference to the Manager - so a widget disabled while focused stays focused, + // just Disabled-styled. Its own contract ("stops accepting input events") still has + // to hold for the keyboard, not only for HandleMouseDown/HandleClick, or a disabled + // TextField that was focused before being disabled would keep taking keystrokes. + if (m_FocusedWidget && !m_FocusedWidget->IsEnabled()) + return false; + + for (auto widget = m_FocusedWidget; widget; widget = widget->GetParent()) + { + if (widget->HandleKeyPressed(event).EventHandled) + return true; + } + + return false; } bool Manager::HandleKeyTyped(const KeyTypedEvent& event) const { - if (m_FocusedWidget) + // See the matching guard in HandleKeyPressed for why this checks IsEnabled() at all. + if (m_FocusedWidget && !m_FocusedWidget->IsEnabled()) + return false; + + for (auto widget = m_FocusedWidget; widget; widget = widget->GetParent()) { - ProcessKeyTypedRecursive(m_FocusedWidget, event); + if (widget->HandleKeyTyped(event).EventHandled) + return true; } - return true; + return false; + } + + bool Manager::HandleMouseScrolled(const MouseScrolledEvent& event) + { + const auto [x, y] = InputManager::GetMousePosition(); + const auto hitPath = GetHitPath({ x, y }); + UpdateHoverPath(hitPath); + + for (auto it = hitPath.rbegin(); it != hitPath.rend(); ++it) + { + if ((*it)->HandleMouseScrolled(event).EventHandled) + return true; + } + + return false; + } + + void Manager::ResetInputRouting() + { + SetFocusedWidget(nullptr); + m_PressedWidget.reset(); + m_MouseCapture.reset(); + UpdateHoverPath({}); } void Manager::ProcessInput() @@ -128,109 +268,251 @@ namespace Elixir::GUI m_MouseReleased = !isMouseDown && m_WasMouseDown; m_WasMouseDown = isMouseDown; - if (m_RootWidget) - { - ProcessInputRecursive(m_RootWidget); + if (m_Layers.empty()) return; - if (m_MouseReleased && m_PressedWidget) - { - const auto event = MouseButtonReleasedEvent(EE_MOUSE_BUTTON_LEFT, m_MousePos); - m_PressedWidget->HandleMouseUp(event); - m_PressedWidget = nullptr; - } + if (m_MousePressed) + DismissPopupsOutside(m_MousePos); - // If user clicked but nothing captured focus, clear it - if (m_MousePressed && !m_PressedWidget && m_FocusedWidget) - { - m_FocusedWidget->HandleLostFocus(); - m_FocusedWidget = nullptr; - } + const auto hitPath = GetHitPath(m_MousePos); - // Mouse move, notify pressed widget (for dragging/selection) - if (m_MouseMoved && m_PressedWidget) - { - const auto event = MouseMovedEvent(m_MousePos); - m_PressedWidget->HandleMouseMove(event); - } - } + UpdateHoverPath(hitPath); + + if (m_MousePressed) + ProcessMousePress(hitPath); + + if (m_MouseReleased) + ProcessMouseRelease(hitPath); + + if (m_MouseMoved) + ProcessMouseMove(hitPath); } - void Manager::ProcessWidget(const Ref& widget) + std::vector> Manager::GetHitPath(const glm::vec2& point) const { - if (!widget || !widget->IsVisible()) return; + std::vector> hitPath; + if (m_Layers.empty()) return hitPath; - const auto geometry = widget->GetGeometry(); - const bool isOver = geometry.Contains(m_MousePos); + const Ref& activeRoot = GetTopmostHitLayer(point).Root; + if (activeRoot) + activeRoot->HitTest(point, hitPath); - // Hover - if (isOver && !widget->IsHovered()) + return hitPath; + } + + void Manager::UpdateHoverPath(const std::vector>& path) + { + // Leave widgets that were hovered but fell out of the path, deepest (leaf) first. + for (auto it = m_HoverPath.rbegin(); it != m_HoverPath.rend(); ++it) { - widget->HandleMouseEnter(); + if (std::ranges::find(path, *it) == path.end()) + (*it)->HandleMouseLeave(); } - else if (!isOver && widget->IsHovered()) + + // Enter widgets newly under the cursor, root first. + for (const auto& widget : path) { - widget->HandleMouseLeave(); + if (std::ranges::find(m_HoverPath, widget) == m_HoverPath.end()) + widget->HandleMouseEnter(); } - // Press + Focus - if (isOver && m_MousePressed) + m_HoverPath = path; + } + + void Manager::ProcessMousePress(const std::vector>& path) + { + const auto event = MouseButtonPressedEvent(EE_MOUSE_BUTTON_LEFT, m_MousePos); + + for (auto it = path.rbegin(); it != path.rend(); ++it) { - const auto event = MouseButtonPressedEvent(EE_MOUSE_BUTTON_LEFT, m_MousePos); - widget->HandleMouseDown(event); - m_PressedWidget = widget; + const auto& widget = *it; + const SInputReply reply = widget->HandleMouseDown(event); - // Focus: only change if clicking a different widget - if (m_FocusedWidget != widget) + if (reply.EventHandled) { - if (m_FocusedWidget) - m_FocusedWidget->HandleLostFocus(); + if (reply.CaptureMouse) + m_MouseCapture = widget; - m_FocusedWidget = widget; - m_FocusedWidget->HandleFocus(); + m_PressedWidget = widget; + SetFocusedWidget(widget); + return; } } - // Click - if (isOver && m_MouseReleased) - { - const auto event = MouseButtonReleasedEvent(EE_MOUSE_BUTTON_LEFT, m_MousePos); - widget->HandleMouseUp(event); + // Nobody under the cursor wanted the press: treat it as "clicked outside". + SetFocusedWidget(nullptr); + } - if (widget->IsPressed() && m_PressedWidget == widget) - widget->HandleClick(); + void Manager::ProcessMouseRelease(const std::vector>& path) + { + const auto event = MouseButtonReleasedEvent(EE_MOUSE_BUTTON_LEFT, m_MousePos); + + if (const auto captured = m_MouseCapture.lock()) + { + captured->HandleMouseUp(event); + } + else + { + for (auto it = path.rbegin(); it != path.rend(); ++it) + if ((*it)->HandleMouseUp(event).EventHandled) + break; } + + if (m_PressedWidget && std::ranges::find(path, m_PressedWidget) != path.end()) + m_PressedWidget->HandleClick(); + + m_MouseCapture.reset(); + m_PressedWidget = nullptr; } - void Manager::ProcessInputRecursive(const Ref& widget) + void Manager::ProcessMouseMove(const std::vector>& path) { - ProcessWidget(widget); + const auto event = MouseMovedEvent(m_MousePos); - widget->ForEachChild([this](const Ref& child) + if (const auto captured = m_MouseCapture.lock()) { - ProcessInputRecursive(child); - }); + captured->HandleMouseMove(event); + return; + } + + for (auto it = path.rbegin(); it != path.rend(); ++it) + { + if ((*it)->HandleMouseMove(event).EventHandled) + break; + } + } + + void Manager::SetFocusedWidget(const Ref& widget) + { + if (m_FocusedWidget == widget) return; + + if (m_FocusedWidget) + m_FocusedWidget->HandleLostFocus(); + + m_FocusedWidget = widget; + + if (m_FocusedWidget) + m_FocusedWidget->HandleFocus(); } - void Manager::ProcessKeyPressedRecursive( + void Manager::CollectFocusOrder( const Ref& widget, - const KeyPressedEvent& event + std::vector>& out ) { - widget->HandleKeyPressed(event); - - widget->ForEachChild([&event](const Ref& child) + if (!widget) return; + + const auto visibility = widget->GetVisibility(); + if (visibility == EVisibility::HitTestInvisible || + visibility == EVisibility::Hidden || + visibility == EVisibility::Collapsed) + return; + + // Excludes a SelfHitTestVisible widget from the order itself while still walking + // into its children - same treatment for a disabled one: Tab must not be able to + // land somewhere a mouse click already can't (Widget::HandleMouseDown's own + // IsEnabled() check), even though a widget already focused before being disabled + // stays in m_FocusedWidget (see the guard in Manager::HandleKeyPressed). + if (widget->IsFocusable() && widget->IsSelfHitTestVisible() && widget->IsEnabled()) + out.push_back(widget); + + widget->ForEachChild([&](const Ref& child) { - ProcessKeyPressedRecursive(child, event); + CollectFocusOrder(child, out); }); } - void Manager::ProcessKeyTypedRecursive(const Ref& widget, const KeyTypedEvent& event) + const std::vector>& Manager::GetFocusOrder() { - widget->HandleKeyTyped(event); + const uint64_t epoch = Widget::CurrentDirtyEpoch(); + if (epoch == m_FocusOrderEpoch && m_LayerStackVersion == m_FocusOrderLayerVersion) + return m_FocusOrder; + + m_FocusOrder.clear(); + + if (!m_Layers.empty()) + CollectFocusOrder(m_Layers.back().Root, m_FocusOrder); + + m_FocusOrderEpoch = epoch; + m_FocusOrderLayerVersion = m_LayerStackVersion; - widget->ForEachChild([&event](const Ref& child) + return m_FocusOrder; + } + + void Manager::FocusNext() + { + const auto& order = GetFocusOrder(); + + // Nothing to Tab to: leave m_FocusedWidget exactly as it is. + if (order.empty()) return; + + const auto it = std::ranges::find(order, m_FocusedWidget); + const size_t nextIndex = (it == order.end()) + ? 0 + : (size_t(it - order.begin()) + 1) % order.size(); + + SetFocusedWidget(order[nextIndex]); + } + + void Manager::FocusPrevious() + { + const auto& order = GetFocusOrder(); + + // Nothing to back focus to: leave m_FocusedWidget exactly as it is. + if (order.empty()) return; + + const auto it = std::ranges::find(order, m_FocusedWidget); + const size_t currIndex = (it == order.end()) ? 0 : size_t(it - order.begin()); + const size_t prevIndex = (currIndex == 0) ? order.size() - 1 : currIndex - 1; + + SetFocusedWidget(order[prevIndex]); + } + + const SLayer& Manager::GetTopmostHitLayer(const glm::vec2& point) const + { + for (size_t i = m_Layers.size(); i-- > 1;) { - ProcessKeyTypedRecursive(child, event); - }); + if (m_Layers[i].Root && m_Layers[i].Root->GetGeometry().Contains(point)) + return m_Layers[i]; + } + + return m_Layers[0]; // the UI root always hits + } + + void Manager::DismissPopupsOutside(const glm::vec2& point) + { + while (m_Layers.size() > 1) + { + const auto& top = m_Layers.back(); + if (!top.DismissOnClickOutside) break; + if (top.Root && top.Root->GetGeometry().Contains(point)) break; + + PopPopup(); + } + } + + SRect Manager::ComputePopupRect( + const SRect& anchor, + const glm::vec2& desiredSize, + const SRect& screenRect + ) + { + // Default: flush against the anchor's left edge, opening below it. + glm::vec2 position = { anchor.Position.x, anchor.Position.y + anchor.Size.y }; + + // Doesn't fit below -> flip above the anchor. + if (position.y + desiredSize.y > screenRect.Position.y + screenRect.Size.y) + position.y = anchor.Position.y - desiredSize.y; + + // Clamp fully on-screen as a last resort (flipping alone doesn't help when the + // screen itself is smaller than the popup, or the anchor is near the top with + // nothing to flip into). + const glm::vec2 maxPosition = glm::max( + screenRect.Position, + screenRect.Position + screenRect.Size - desiredSize + ); + + position = glm::clamp(position, screenRect.Position, maxPosition); + + return { position, desiredSize }; } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Manager.h b/Elixir/Source/Engine/GUI/Manager.h index e4d69ba1..7fb9a215 100644 --- a/Elixir/Source/Engine/GUI/Manager.h +++ b/Elixir/Source/Engine/GUI/Manager.h @@ -7,6 +7,21 @@ namespace Elixir::GUI { + /** + * @brief One stacked layer of the UI. + * + * Index 0 in Manager::m_Layers is the always-present UI root (screen sized, filled by + * SetRoot); anything above it is a popup (dropdown menu, tooltip, modal, ...) anchored + * to a rect from the layer below it and rendered, hit-tested and dismissed independently + * of it. + */ + struct SLayer + { + Ref Root; + SRect Anchor; + bool DismissOnClickOutside = true; + }; + class ELIXIR_API Manager { public: @@ -23,10 +38,48 @@ namespace Elixir::GUI void ProcessEvent(Event& event); - void SetRoot(const Ref& root) - { - m_RootWidget = root; - } + void SetRoot(const Ref& root); + + /** + * @brief Push a new popup layer on top of the stack, anchored to a screen-space rect. + * + * (Typically the geometry of the widget that opened it, e.g. a menu bar button). + * + * Arranged immediately against the last extent ArrangeLayout ran with, so it has + * correct geometry even before the next ArrangeLayout call. + * + * @param widget Root widget of the popup's own subtree. + * @param anchor Screen-space rect the popup is positioned relative to. + */ + void PushPopup(const Ref& widget, const SRect& anchor); + + /** + * @brief Pop the topmost popup layer. + * + * No-op when there are no popups - layer 0, the UI root, is never popped this way. + */ + void PopPopup(); + + /** + * @brief Pop every popup layers, leaving only the UI root. + */ + void ClearPopups(); + + /** + * @brief Get the number of stacked popup layers above the root layer. + * @return Number of popup layers currently stacked above the root layer (0 if none). + */ + size_t GetPopupCount() const { return m_Layers.empty() ? 0 : m_Layers.size() - 1; } + + /** + * @brief True if an interactive widget is hovered or capturing the mouse. + * + * Lets a consumer (e.g. an editor camera controller polling its own mouse input) skips + * its own handling while the user is interacting with the GUI instead. + * + * @return True if the GUI currently wants mouse input. + */ + bool WantsMouse() const; const RenderBatch& GetRenderBatch() const { return m_RenderBatch; } @@ -36,25 +89,120 @@ namespace Elixir::GUI bool NeedsRebuild() const; void MarkRebuilt(); + // Not const: Tab/Shift+Tab/Escape are intercepted here, before the bubble to + // m_FocusedWidget, and moving/clearing focus mutates m_FocusedWidget and the cached + // focus order. See the ordering rationale on the .cpp definition. Protected (not + // private) so tests can drive it directly - see ManagerTestUtils.h. + bool HandleKeyPressed(const KeyPressedEvent& event); + + // Bubbles a mouse-down left -> root over path until a widget handles it; that widget + // becomes m_PressedWidget (and, if it asked, m_MouseCapture) and gains focus. If + // nobody handles it, treats the press as "clicked outside and clears focus. Protected + // (not private) so tests can drive it directly - see ManagerTestUtils.h. + void ProcessMousePress(const std::vector>& path); + + // Common focus-change plumbing: fires HandleLostFocus/HandleFocus only when the + // focused widget actually changes; widget may be nullptr to clear focus. Protected + // (not private) so tests can drive it directly - see ManagerTestUtils.h. + void SetFocusedWidget(const Ref& widget); + private: bool HandleFramebufferResize(const FramebufferResizeEvent& event) const; - bool HandleKeyPressed(const KeyPressedEvent& event) const; bool HandleKeyTyped(const KeyTypedEvent& event) const; + // Hit-tests the current pointer position before bubbling a wheel tick leaf -> root, + // stopping at the first widget whose HandleMouseScrolled reports EventHandled. + bool HandleMouseScrolled(const MouseScrolledEvent& event); + + // Clears focus, press, capture and hover state that can otherwise keep a removed + // layer's subtree alive and receiving input. + void ResetInputRouting(); + + // Returns the topmost root-to-leaf hit path at point. Used by both per-frame mouse + // processing and wheel routing, because a scroll event can arrive before the next + // ProcessInput refreshes m_HoverPath. + std::vector> GetHitPath(const glm::vec2& point) const; + void ProcessInput(); - void ProcessWidget(const Ref& widget); - void ProcessInputRecursive(const Ref& widget); - static void ProcessKeyPressedRecursive(const Ref& widget, const KeyPressedEvent& event); - static void ProcessKeyTypedRecursive(const Ref& widget, const KeyTypedEvent& event); + // Diffs the freshly hit-tested path against m_HoverPath, firing HandleMouseLeave + // (leaf -> root) on widgets that fell out and HandleMouseEnter (root -> leaf) on + // widgets that newly entered, then stores path as the new m_HoverPath. + void UpdateHoverPath(const std::vector>& path); + + // Routes mouse-up to m_MouseCapture if set, otherwise bubbles over path; then + // synthesizes HandleClick on m_PressedWidget if it is still present in path. + void ProcessMouseRelease(const std::vector>& path); + + // Routes mouse-move to m_MouseCapture if set, otherwise bubbles over path. + void ProcessMouseMove(const std::vector>& path); + + // Depth-first, first-child-first walk collecting every focusable + // (Widget::IsFocusable) and keyboard-reachable widget under widget, in traversal + // order. Prunes the same HitTestInvisible/Hidden/Collapsed branches Widget::HitTest + // prunes, and likewise skips (without excluding descendants of) a + // SelfHitTestInvisible widget - same visibility contract, reused rather than reinvented, + // just walked root -> leaf instead of HitTest's leaf-seeking back-to-front order, + // since Tab order is reading order, not z-order. + static void CollectFocusOrder(const Ref& widget, std::vector>& out); + + // Lazily rebuilds the cached focus order. + const std::vector>& GetFocusOrder(); + + // Move focus to the next entry in GetFocusOrder(). + void FocusNext(); + + // Move focus to the previous entry in GetFocusOrder(). + void FocusPrevious(); + + // Topmost layer whose geometry contains point; falls back to layer 0 (the UI root + // always "hits" - its geometry covers the whole screen). + const SLayer& GetTopmostHitLayer(const glm::vec2& point) const; + + // Pops layers from the top while DismissOnClickOutside is set and the layer's + // geometry does not contain point. Stops at the first layer that either contains + // the point or opted out of dismiss-on-click-outside. + void DismissPopupsOutside(const glm::vec2& point); + + // anchor + a popup's own desired size -> a rect that fits on screen: opens below + // the anchor by default, flips above when it wouldn't fit below, and is finally + // clamped fully inside screenRect as a last resort. + static SRect ComputePopupRect( + const SRect& anchor, + const glm::vec2& desiredSize, + const SRect& screenRect + ); Scope m_Renderer; RenderBatch m_RenderBatch; - Ref m_RootWidget; + // Index 0 is the UI root; anything above it is a popup, topmost last. + std::vector m_Layers; + + // Widgets currently under the cursor, root -> leaf. Diffed every frame in + // UpdateHoverPath to drive HandleMouseEnter/HandleMouseLeave. + std::vector> m_HoverPath; + + // Widget that captured the mouse on press, if any. + // While set, mouse move/up go straight to it regardless of the hover path. + WeakRef m_MouseCapture; + + // Widget that consumed the last mouse-down (the "down" target), kept until the + // matching release purely to synthesize HandleClick when the release still lands + // on it - it is NOT a second, hand-rolled capture path. Ref m_PressedWidget; + Ref m_FocusedWidget; + // Cache behind GetFocusOrder: the widgets currently eligible for focus, + // in traversal order, scoped to the topmost layer at the time of the last rebuild. + // Keyed the same way NeedsRebuild keys the render batch - epoch + layer stack + // version - and rebuilt lazily on the next FocusNext/FocusPrevious call, not eagerly + // on every mutation. + std::vector> m_FocusOrder; + uint64_t m_FocusOrderEpoch = 0; + uint64_t m_FocusOrderLayerVersion = 0; + glm::vec2 m_MousePos{}; glm::vec2 m_LastMousePos{}; bool m_WasMouseDown = false; @@ -62,13 +210,22 @@ namespace Elixir::GUI bool m_MouseReleased = false; bool m_MouseMoved = false; + // Last extent passed to ArrangeLayout, so a popup pushed mid-frame (after this + // frame's ArrangeLayout already ran) can still be arranged immediately instead of + // rendering at a stale {0,0} geometry for one frame. + mutable Extent2D m_LastExtent{}; + // Dirty epoch of the last frame we assembled + uploaded. When it still matches the // current epoch, the batch and GPU buffers are reused and only the draws are re-issued. uint64_t m_LastRenderedEpoch = 0; - // Tracks the last rendered panel, so when changed, can rebuild the render batch. - WeakRef m_LastRenderedRoot; + // Bumped on every layer stack mutation (SetRoot, PushPopup, PopPopup, ClearPopups). + // A layer change doesn't necessarily bump Widget::CurrentDirtyEpoch - a freshly built + // popup subtree starts dirty by construction, without ever calling MarkLayoutDirty - + // so the epoch comparison alone can't detect "a popup was opened"; this can. + uint64_t m_LayerStackVersion = 0; + uint64_t m_LastRenderedLayerVersion = 0; bool m_Initialized = false; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Overlay.cpp b/Elixir/Source/Engine/GUI/Overlay.cpp index 5bee952b..7dae9695 100644 --- a/Elixir/Source/Engine/GUI/Overlay.cpp +++ b/Elixir/Source/Engine/GUI/Overlay.cpp @@ -3,31 +3,27 @@ namespace Elixir::GUI { - LayoutSlot& Overlay::AddChild(const Ref& child) + glm::vec2 Overlay::ComputeDesiredSize(const glm::vec2& availableSize) { - const auto slot = CreateRef(child); - m_Slots.push_back(slot); - AttachChild(child); - return *slot; - } - - void Overlay::SetStretching(const bool stretching) - { - if (m_Stretching == stretching) return; - m_Stretching = stretching; - MarkLayoutDirty(); - } + const glm::vec2 innerAvailable = { + availableSize.x - m_Padding.GetTotalHorizontal(), + availableSize.y - m_Padding.GetTotalVertical() + }; - glm::vec2 Overlay::ComputeDesiredSize() - { glm::vec2 totalSize = { 0, 0 }; - for (auto& slot : m_Slots) + for (const auto& slot : m_Slots) { - const auto layoutSlot = std::static_pointer_cast(slot); + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto margin = slot->GetMargin(); - auto childSize = slot->GetWidget()->ComputeDesiredSize(); - const auto margin = layoutSlot->GetMargin(); + const glm::vec2 childConstraint = { + innerAvailable.x - margin.GetTotalHorizontal(), + innerAvailable.y - margin.GetTotalVertical() + }; + + auto childSize = slot->GetWidget()->Measure(childConstraint); // Add margin childSize.x += margin.GetTotalHorizontal(); @@ -42,7 +38,6 @@ namespace Elixir::GUI totalSize.x += m_Padding.GetTotalHorizontal(); totalSize.y += m_Padding.GetTotalVertical(); - m_DesiredSize = totalSize; return totalSize; } @@ -51,26 +46,27 @@ namespace Elixir::GUI // Calculate available space after padding const SRect innerSpace = ApplyPadding(allocatedSpace, m_Padding); - for (auto& slot : m_Slots) + // Overlay has no main axis - every child gets the full inner space and is placed by + // alignment alone. EHorizontalAlignment::Fill / EVerticalAlignment::Fill stretch a + // child across that space; SSizeParam does not apply here (see LayoutSlot). + for (const auto& slot : m_Slots) { - const auto layoutSlot = std::static_pointer_cast(slot); + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto margin = slot->GetMargin(); + const auto hAlignment = slot->GetHorizontalAlignment(); + const auto vAlignment = slot->GetVerticalAlignment(); - const glm::vec2 childSize = slot->GetWidget()->ComputeDesiredSize(); - const auto margin = layoutSlot->GetMargin(); - const auto hAlignment = layoutSlot->GetHorizontalAlignment(); - const auto vAlignment = layoutSlot->GetVerticalAlignment(); + const glm::vec2 childConstraint = { + innerSpace.Size.x - margin.GetTotalHorizontal(), + innerSpace.Size.y - margin.GetTotalVertical() + }; - // Handle fill alignment - const float childWidth = m_Stretching - ? innerSpace.Size.x - margin.GetTotalHorizontal() - : childSize.x; - const float childHeight = m_Stretching - ? innerSpace.Size.y - margin.GetTotalVertical() - : childSize.y; + const glm::vec2 childSize = slot->GetWidget()->Measure(childConstraint); // Align within the overlay space SRect childGeometry = AlignChild( - glm::vec2(childWidth, childHeight), + childSize, innerSpace, hAlignment, vAlignment, diff --git a/Elixir/Source/Engine/GUI/Overlay.h b/Elixir/Source/Engine/GUI/Overlay.h index ca669e2d..9de8c21c 100644 --- a/Elixir/Source/Engine/GUI/Overlay.h +++ b/Elixir/Source/Engine/GUI/Overlay.h @@ -4,18 +4,10 @@ namespace Elixir::GUI { - class ELIXIR_API Overlay final : public Panel + class ELIXIR_API Overlay final : public TPanel { - public: - LayoutSlot& AddChild(const Ref& child); - - bool IsStretching() const { return m_Stretching; } - void SetStretching(bool stretching); - - protected: - glm::vec2 ComputeDesiredSize() override; + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; void LayoutChildren(const SRect& allocatedSpace) override; - - bool m_Stretching = false; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/GUI/Panel.cpp b/Elixir/Source/Engine/GUI/Panel.cpp index 1200fd2d..aae16600 100644 --- a/Elixir/Source/Engine/GUI/Panel.cpp +++ b/Elixir/Source/Engine/GUI/Panel.cpp @@ -1,42 +1,49 @@ #include "epch.h" #include "Panel.h" +#include + namespace Elixir::GUI { - void Panel::Update(const Timestep frameTime) + namespace { - for (const auto& slot : m_Slots) + bool HasShadow(const glm::vec4& shadow) { - if (slot->IsVisible()) - { - slot->GetWidget()->Update(frameTime); - } + return shadow.w > 0.0f && (shadow.x != 0.0f || shadow.y != 0.0f); + } + + bool HasVisualOutput(const SBrush& brush) + { + return brush.Color.A > 0.0f || + brush.Texture || + (brush.Outline.Thickness > 0.0f && brush.Outline.Color.A > 0.0f) || + HasShadow(brush.InsetShadow) || + HasShadow(brush.DropShadow); } } - void Panel::RemoveChild(const Ref& child) + void Panel::Update(const Timestep frameTime) { - if (!child) return; - - const auto it = std::ranges::find_if( - m_Slots, - [&](const Ref& slot) { return slot->GetWidget() == child; } - ); - - if (it == m_Slots.end()) return; - - m_Slots.erase(it); - DetachChild(child); + Widget::Update(frameTime); + for (size_t i = 0; i < GetSlotCount(); ++i) + { + if (const Slot* slot = GetSlotAt(i)) + { + const auto child = slot->GetWidget(); + if (child && child->TakesSpace()) + child->Update(frameTime); + } + } } void Panel::ClearChildren() { - if (m_Slots.empty()) return; + if (GetSlotCount() == 0) return; - for (const auto& slot : m_Slots) - DetachChild(slot->GetWidget()); + for (size_t i = 0; i < GetSlotCount(); ++i) + DetachChild(GetSlotAt(i)->GetWidget()); - m_Slots.clear(); + ClearSlots(); MarkLayoutDirty(); } @@ -47,41 +54,35 @@ namespace Elixir::GUI MarkLayoutDirty(); } - void Panel::SetBackground(const SColor& color) + Ref Panel::GetChildAt(const size_t index) const { - m_Background = color; - MarkRenderDirty(); + if (index >= GetSlotCount()) return nullptr; + return GetSlotAt(index)->GetWidget(); } - void Panel::SetCornerRadius(const glm::vec4& radius) + void Panel::RemoveChild(const Ref& child) { - m_CornerRadius = radius; - MarkRenderDirty(); - } + if (!child) return; - void Panel::ForEachChild(const std::function&)>& fn) const - { - for (const auto& slot : m_Slots) + for (size_t i = 0; i < GetSlotCount(); ++i) { - if (slot->IsVisible()) - if (const auto& child = slot->GetWidget()) - fn(child); + if (GetSlotAt(i)->GetWidget() == child) + { + DetachChild(child); + RemoveSlotAt(i); + break; + } } } void Panel::BuildDrawCommands(RenderBatch& batch, const int zOrder) { - if (m_Background.A > 0.0f) - { - batch.AddRect( - m_Geometry, - m_Background, - m_CornerRadius, - m_InsetShadow, - m_DropShadow, - m_Outline, - zOrder - ); - } + const SBrush& brush = GetResolvedAppearance().Background; + + if (HasVisualOutput(brush)) + batch.AddBrush(brush, m_Geometry, zOrder); } + + template class ELIXIR_API TPanel; + template class ELIXIR_API TPanel; } diff --git a/Elixir/Source/Engine/GUI/Panel.h b/Elixir/Source/Engine/GUI/Panel.h index c905c39f..dc82bcac 100644 --- a/Elixir/Source/Engine/GUI/Panel.h +++ b/Elixir/Source/Engine/GUI/Panel.h @@ -8,17 +8,9 @@ namespace Elixir::GUI class ELIXIR_API Panel : public Widget { - public: + public: void Update(Timestep frameTime) override; - /** - * Remove a child widget from this panel: drops the slot holding it and clears the - * child's parent back-pointer (via DetachChild), marking layout dirty. No-op if the - * widget is not a child of this panel. Promotes the Widget hook to public API. - * @param child the widget to remove. - */ - void RemoveChild(const Ref& child) override; - /** * Remove all children from this panel, detaching each, and mark layout dirty. */ @@ -27,49 +19,83 @@ namespace Elixir::GUI SPadding GetPadding() const { return m_Padding; } void SetPadding(const SPadding& padding); - SColor GetBackground() const { return m_Background; } - void SetBackground(const SColor& color); - /** - * Get corner radius for each corner individually. - * @return vector(top-left, top-right, bottom-right, bottom-left) + * @brief Get the number of slots currently owned by this panel. + * @return The number of slots currently owned by this panel. */ - glm::vec4 GetCornerRadius() const { return m_CornerRadius; } + virtual size_t GetSlotCount() const = 0; /** - * Set same radius for all corners. - * @param radius corner radius in pixels + * @brief Get the slot at the specified index. + * @param index The index of the slot to retrieve. + * @return Non-owning pointer to the slot; valid until the next structural change + * (AddChild/RemoveChild/ClearChildren) to this panel. */ - void SetCornerRadius(const float radius) - { - SetCornerRadius({ radius, radius, radius, radius }); - } + virtual Slot* GetSlotAt(size_t index) const = 0; - /** - * Set radius for each corner individually. - * @param radius vector(top-left, top-right, bottom-right, bottom-left) - */ - void SetCornerRadius(const glm::vec4& radius); + protected: + size_t GetChildCount() const override { return GetSlotCount(); } - const std::vector>& GetSlots() const { return m_Slots; } + Ref GetChildAt(size_t index) const override; - protected: - /** - * Invoke the fn for each direct child of this widget. - * Container widgets override this to expose their children; leaf widgets keep the - * default no-op. Lets callers walk the widget tree without knowing concrete types. - * @param fn callback invoked once per child widget. - */ - void ForEachChild(const std::function&)>& fn) const override; + // Remove a child widget from this panel: drops the slot holding it and clears the + // child's parent back-pointer (via DetachChild), marking layout dirty. + void RemoveChild(const Ref& child) override; void BuildDrawCommands(RenderBatch& batch, int zOrder) override; + // Erase the slot at index. Does not touch the child's parent back-pointer or mark + // anything dirty - callers (RemoveChild) are responsible for that. Implemented by + // TPanel, the only place that knows the concrete slot vector. + virtual void RemoveSlotAt(size_t index) = 0; + + // Erase every slot. Does not touch any child's parent back-pointer or mark anything + // dirty - callers (ClearChildren) are responsible for that. Implemented by + // TPanel, the only place that knows the concrete slot vector. + virtual void ClearSlots() = 0; + SPadding m_Padding; - SColor m_Background; + }; - // top-le ft, top-right, bottom-right, bottom-left - glm::vec4 m_CornerRadius = {0.0f, 0.0f, 0.0f, 0.0f}; + /** + * Typed panel: the only place that constructs TSlot, so a container can never end up + * holding the wrong slot type (e.g. a CanvasSlot inside a VerticalBox). + * @tparam TSlot The type of slot this panel uses. + */ + template + class TPanel : public Panel + { + public: + TPanel() = default; + TPanel(const TPanel&) = delete; + TPanel& operator=(const TPanel&) = delete; + + virtual TSlot& AddChild(const Ref& child) + { + auto slot = CreateScope(child); + TSlot& ref = *slot; + + m_Slots.push_back(std::move(slot)); + AttachChild(child); + + return ref; + } + + size_t GetSlotCount() const override { return m_Slots.size(); } + + Slot* GetSlotAt(size_t index) const override { return m_Slots[index].get(); } + + protected: + void RemoveSlotAt(const size_t index) override + { + m_Slots.erase(m_Slots.begin() + static_cast(index)); + } + + void ClearSlots() override + { + m_Slots.clear(); + } - std::vector> m_Slots; + std::vector> m_Slots; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.cpp b/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.cpp index 7f6cb604..019cf2ee 100644 --- a/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.cpp +++ b/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.cpp @@ -18,33 +18,48 @@ namespace Elixir::GUI BindShaderParameters(); } - void DebugRenderPass::GenerateDrawCommands(const RenderBatch& batch) + void DebugRenderPass::BeginFrame() { m_Vertices.clear(); + } - for (const auto& drawCmd : batch.GetCommands()) - { - switch (drawCmd.Type) - { - case SDrawCommand::EType::DebugRect: - BuildDebugRectGeometry(drawCmd); - break; - default: - break; - } - } - + void DebugRenderPass::EndFrame() + { if (!m_Vertices.empty()) { - m_VertexBuffer->UpdateData(m_Vertices.data(), m_Vertices.size() * sizeof(SVertex)); + EnsureVertexBufferCapacity(m_Vertices.size()); + m_VertexBuffer->UpdateData( + m_Vertices.data(), + m_Vertices.size() * sizeof(SVertex) + ); } } - void DebugRenderPass::Render(const Ref& cmd) + uint32_t DebugRenderPass::AppendRange(std::span commands) + { + const auto firstVertex = (uint32_t)m_Vertices.size(); + + for (const auto& drawCmd : commands) + BuildDebugRectGeometry(drawCmd); + + return firstVertex; + } + + void DebugRenderPass::Bind(const Ref& cmd) { m_Pipeline->Bind(cmd); m_VertexBuffer->Bind(cmd); - cmd->Draw(m_Vertices.size()); + } + + void DebugRenderPass::Render( + const Ref& cmd, + const uint32_t firstInstance, + const uint32_t instanceCount + ) + { + // Non-instanced LineList draw: reinterpret the generic firstInstance/instanceCount + // range as firstVertex/vertexCount, matching what AppendRange produced above. + cmd->Draw(instanceCount, 1, firstInstance, 0); } bool DebugRenderPass::HasData() const @@ -57,6 +72,16 @@ namespace Elixir::GUI m_Vertices.clear(); } + uint32_t DebugRenderPass::GetInstanceCount() const + { + return (uint32_t)m_Vertices.size(); + } + + EDrawCommandType DebugRenderPass::GetHandleType() const + { + return EDrawCommandType::DebugRect; + } + void DebugRenderPass::InitRenderPass(const ShaderLoader* shaderLoader) { const BufferLayout bufferLayout({ @@ -84,6 +109,7 @@ namespace Elixir::GUI constexpr auto vertexCount = MAX_LINES * 2; m_VertexBuffer = DynamicVertexBuffer::Create(m_GraphicsContext, vertexCount * sizeof(SVertex)); m_VertexBuffer->SetLayout(bufferLayout); + m_VertexCapacity = vertexCount; } void DebugRenderPass::BindShaderParameters() const @@ -91,12 +117,33 @@ namespace Elixir::GUI m_Shader->BindConstantBuffer("cbPerFrame", m_PerFrameConstantBuffer); } + void DebugRenderPass::EnsureVertexBufferCapacity(const size_t requiredCapacity) + { + if (requiredCapacity <= m_VertexCapacity) + return; + + const size_t newCapacity = GrowBufferCapacity(m_VertexCapacity, requiredCapacity); + auto buffer = DynamicVertexBuffer::Create(m_GraphicsContext, newCapacity * sizeof(SVertex)); + buffer->SetLayout(m_VertexBuffer->GetLayout()); + + m_RetiredVertexBuffers.push_back(std::move(m_VertexBuffer)); + m_VertexBuffer = std::move(buffer); + m_VertexCapacity = newCapacity; + } + void DebugRenderPass::BuildDebugRectGeometry(const SDrawCommand& cmd) { - const auto topLeft = cmd.Geometry.Position; - const auto topRight = (cmd.Geometry.Position + glm::vec2(cmd.Geometry.Size.x, 0)); - const auto bottomLeft = (cmd.Geometry.Position + glm::vec2(0, cmd.Geometry.Size.y)); - const auto bottomRight = (cmd.Geometry.Position + cmd.Geometry.Size); + // cmd.Geometry arrives in logical points, same as every other pass - QuadRenderPass + // and TextRenderPass both scale Position/Size by m_DPIScale before building vertex + // data (see QuadRenderPass.cpp:152-153, TextRenderPass.cpp:198-199); this pass has to + // match, or its rects land at half the intended screen position on a 2x display. + const auto position = cmd.Geometry.Position * m_DPIScale; + const auto size = cmd.Geometry.Size * m_DPIScale; + + const auto topLeft = position; + const auto topRight = (position + glm::vec2(size.x, 0)); + const auto bottomLeft = (position + glm::vec2(0, size.y)); + const auto bottomRight = (position + size); m_Vertices.push_back({ topLeft, cmd.Color }); m_Vertices.push_back({ topRight, cmd.Color }); @@ -110,4 +157,4 @@ namespace Elixir::GUI m_Vertices.push_back({ bottomLeft, cmd.Color }); m_Vertices.push_back({ topLeft, cmd.Color }); } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.h b/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.h index f432964c..17331692 100644 --- a/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.h +++ b/Elixir/Source/Engine/GUI/Renderer/DebugRenderPass.h @@ -19,14 +19,29 @@ namespace Elixir::GUI const Ref& perFrameCB ); - void GenerateDrawCommands(const RenderBatch& batch) override; - void Render(const Ref& cmd) override; + void BeginFrame() override; + void EndFrame() override; + + uint32_t AppendRange(std::span commands) override; + + void Bind(const Ref& cmd) override; + void Render( + const Ref& cmd, + uint32_t firstInstance, + uint32_t instanceCount + ) override; + bool HasData() const override; void Clear() override; + uint32_t GetInstanceCount() const override; + + EDrawCommandType GetHandleType() const override; + private: void InitRenderPass(const ShaderLoader* shaderLoader); void BindShaderParameters() const; + void EnsureVertexBufferCapacity(size_t requiredCapacity); void BuildDebugRectGeometry(const SDrawCommand& cmd); @@ -41,9 +56,11 @@ namespace Elixir::GUI Ref m_Shader; Ref m_Pipeline; Ref m_VertexBuffer; + std::vector> m_RetiredVertexBuffers; float m_DPIScale; Ref m_PerFrameConstantBuffer; const GraphicsContext* m_GraphicsContext; + size_t m_VertexCapacity = 0; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.cpp b/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.cpp index fa55be35..b08d0a61 100644 --- a/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.cpp +++ b/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.cpp @@ -27,33 +27,43 @@ namespace Elixir::GUI m_WhiteTexture.reset(); } - void QuadRenderPass::GenerateDrawCommands(const RenderBatch& batch) + void QuadRenderPass::BeginFrame() { m_Quads.clear(); + } - for (const auto& drawCmd : batch.GetCommands()) - { - switch (drawCmd.Type) - { - case SDrawCommand::EType::Rect: - BuildRectGeometry(drawCmd); - break; - default: - break; - } - } - + void QuadRenderPass::EndFrame() + { if (!m_Quads.empty()) { + EnsureQuadBufferCapacity(m_Quads.size()); m_QuadBuffer->UpdateData(m_Quads.data(), m_Quads.size() * sizeof(SQuad)); } } - void QuadRenderPass::Render(const Ref& cmd) + uint32_t QuadRenderPass::AppendRange(const std::span commands) + { + const auto firstInstance = (uint32_t)m_Quads.size(); + + for (const auto& drawCmd : commands) + BuildRectGeometry(drawCmd); + + return firstInstance; + } + + void QuadRenderPass::Bind(const Ref& cmd) { m_Pipeline->Bind(cmd); m_QuadBuffer->Bind(cmd); - cmd->Draw(6, m_Quads.size()); + } + + void QuadRenderPass::Render( + const Ref& cmd, + const uint32_t firstInstance, + const uint32_t instanceCount + ) + { + cmd->Draw(6, instanceCount, 0, firstInstance); } bool QuadRenderPass::HasData() const @@ -66,6 +76,16 @@ namespace Elixir::GUI m_Quads.clear(); } + uint32_t QuadRenderPass::GetInstanceCount() const + { + return (uint32_t)m_Quads.size(); + } + + EDrawCommandType QuadRenderPass::GetHandleType() const + { + return EDrawCommandType::Rect; + } + void QuadRenderPass::InitRenderPass(const ShaderLoader* shaderLoader) { const BufferLayout bufferLayout({ @@ -80,6 +100,7 @@ namespace Elixir::GUI { EDataType::Vec4, "OutlineColor" }, { EDataType::Float, "OutlineThickness" }, { EDataType::UInt, "TextureIndex" }, + { EDataType::UInt, "TextureMapping" }, { EDataType::Vec4, "ScissorRect" }, }, EInputRate::Instance @@ -102,6 +123,7 @@ namespace Elixir::GUI m_Quads.reserve(MAX_QUADS); m_QuadBuffer = DynamicVertexBuffer::Create(m_GraphicsContext, MAX_QUADS * sizeof(SQuad)); m_QuadBuffer->SetLayout(bufferLayout); + m_QuadCapacity = MAX_QUADS; m_WhiteTexture = Texture2D::Create( m_GraphicsContext, @@ -127,6 +149,20 @@ namespace Elixir::GUI m_Shader->BindSampler("samplerState", sampler); } + void QuadRenderPass::EnsureQuadBufferCapacity(const size_t requiredCapacity) + { + if (requiredCapacity <= m_QuadCapacity) + return; + + const size_t newCapacity = GrowBufferCapacity(m_QuadCapacity, requiredCapacity); + auto buffer = DynamicVertexBuffer::Create(m_GraphicsContext, newCapacity * sizeof(SQuad)); + buffer->SetLayout(m_QuadBuffer->GetLayout()); + + m_RetiredQuadBuffers.push_back(std::move(m_QuadBuffer)); + m_QuadBuffer = std::move(buffer); + m_QuadCapacity = newCapacity; + } + void QuadRenderPass::BuildRectGeometry(const SDrawCommand& cmd) { const SQuad quad = { @@ -141,6 +177,7 @@ namespace Elixir::GUI .TextureIndex = cmd.Texture ? m_TextureSet->AddTexture(cmd.Texture).Index : m_WhiteTextureHandle.Index, + .TextureMapping = (uint32_t)cmd.TextureMapping, .ScissorRect = cmd.ScissorRect.IsValid() ? cmd.ScissorRect * m_DPIScale : cmd.ScissorRect @@ -148,4 +185,4 @@ namespace Elixir::GUI m_Quads.push_back(quad); } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.h b/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.h index 34a41eeb..f01a8752 100644 --- a/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.h +++ b/Elixir/Source/Engine/GUI/Renderer/QuadRenderPass.h @@ -22,14 +22,29 @@ namespace Elixir::GUI ~QuadRenderPass() override; - void GenerateDrawCommands(const RenderBatch& batch) override; - void Render(const Ref& cmd) override; + void BeginFrame() override; + void EndFrame() override; + + uint32_t AppendRange(std::span commands) override; + + void Bind(const Ref& cmd) override; + void Render( + const Ref& cmd, + uint32_t firstInstance, + uint32_t instanceCount + ) override; + bool HasData() const override; void Clear() override; + uint32_t GetInstanceCount() const override; + + EDrawCommandType GetHandleType() const override; + private: void InitRenderPass(const ShaderLoader* shaderLoader); void BindShaderParameters() const; + void EnsureQuadBufferCapacity(size_t requiredCapacity); void BuildRectGeometry(const SDrawCommand& cmd); @@ -63,6 +78,7 @@ namespace Elixir::GUI float OutlineThickness = 0.0f; uint32_t TextureIndex = 0; + uint32_t TextureMapping = 0; SRect ScissorRect; }; @@ -72,6 +88,7 @@ namespace Elixir::GUI Ref m_Shader; Ref m_Pipeline; Ref m_QuadBuffer; + std::vector> m_RetiredQuadBuffers; Ref m_TextureSet; Ref m_WhiteTexture; @@ -80,5 +97,6 @@ namespace Elixir::GUI float m_DPIScale; Ref m_PerFrameConstantBuffer; const GraphicsContext* m_GraphicsContext; + size_t m_QuadCapacity = 0; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/RenderBatch.cpp b/Elixir/Source/Engine/GUI/Renderer/RenderBatch.cpp index c87f0df5..5678cc68 100644 --- a/Elixir/Source/Engine/GUI/Renderer/RenderBatch.cpp +++ b/Elixir/Source/Engine/GUI/Renderer/RenderBatch.cpp @@ -3,14 +3,57 @@ namespace Elixir::GUI { - void RenderBatch::Append(const RenderBatch& other, const int zOffset) + namespace + { + SColor ApplyOpacity(SColor color, const float opacity) + { + color.A *= opacity; + return color; + } + + void ApplyPresentation( + SDrawCommand& command, + const int zOffset, + const SRect& clipRect, + const glm::vec2& offset, + const float opacity + ) + { + command.Geometry.Position += offset; + command.ZOrder += zOffset; + command.Color = ApplyOpacity(command.Color, opacity); + command.Outline.Color = ApplyOpacity(command.Outline.Color, opacity); + command.InsetShadow.w *= opacity; + command.DropShadow.w *= opacity; + + if (command.ScissorRect.IsValid()) + { + command.ScissorRect.Position += offset; + if (clipRect.IsValid()) + command.ScissorRect = SRect::Intersect(command.ScissorRect, clipRect); + } + else if (clipRect.IsValid()) + { + command.ScissorRect = clipRect; + } + } + + } + + void RenderBatch::Append( + const RenderBatch& other, + const int zOffset, + const SRect& clipRect, + const glm::vec2& offset, + const float opacity + ) { m_Commands.reserve(m_Commands.size() + other.m_Commands.size()); for (const auto& command : other.m_Commands) { m_Commands.push_back(command); auto& cmd = m_Commands.back(); - cmd.ZOrder += zOffset; + ApplyPresentation(cmd, zOffset, clipRect, offset, opacity); } } @@ -20,24 +63,63 @@ namespace Elixir::GUI m_Commands, [](const SDrawCommand& a, const SDrawCommand& b) { - return a.ZOrder < b.ZOrder; + const bool aIsDebug = a.Type == EDrawCommandType::DebugRect; + const bool bIsDebug = b.Type == EDrawCommandType::DebugRect; + if (aIsDebug != bIsDebug) + return !aIsDebug; + + if (a.ZOrder != b.ZOrder) + return a.ZOrder < b.ZOrder; + + return a.Type < b.Type; } ); + + BuildRuns(); } void RenderBatch::Clear() { m_Commands.clear(); + m_Runs.clear(); } int RenderBatch::LayerSpan() const { int maxZ = -1; for (const auto& command : m_Commands) - maxZ = std::max(maxZ, command.ZOrder); + { + if (command.Type != EDrawCommandType::DebugRect) + maxZ = std::max(maxZ, command.ZOrder); + } return maxZ + 1; } + void RenderBatch::AddBrush( + const SBrush& brush, + const SRect& rect, + const int zOrder, + const SRect& scissorRect + ) + { + if (brush.Texture) + { + AddTexture(brush.Texture, rect, brush.Borders, brush.Color, zOrder, scissorRect); + return; + } + + AddRect( + rect, + brush.Color, + brush.CornerRadius, + brush.InsetShadow, + brush.DropShadow, + brush.Outline, + zOrder, + scissorRect + ); + } + void RenderBatch::AddRect( const SRect& rect, const SColor& color, @@ -50,7 +132,7 @@ namespace Elixir::GUI ) { SDrawCommand cmd; - cmd.Type = SDrawCommand::EType::Rect; + cmd.Type = EDrawCommandType::Rect; cmd.Geometry = rect; cmd.Color = color; cmd.Border = cornerRadius; @@ -74,7 +156,7 @@ namespace Elixir::GUI ) { SDrawCommand cmd; - cmd.Type = SDrawCommand::EType::Text; + cmd.Type = EDrawCommandType::Text; cmd.Geometry = rect; cmd.Color = color; cmd.Text = text; @@ -96,11 +178,34 @@ namespace Elixir::GUI ) { SDrawCommand cmd; - cmd.Type = SDrawCommand::EType::Rect; + cmd.Type = EDrawCommandType::Rect; cmd.Geometry = rect; cmd.Color = tint; cmd.Texture = texture; cmd.Border = borders; + cmd.TextureMapping = ETextureMapping::NineSlice; + cmd.ZOrder = zOrder; + cmd.ScissorRect = scissorRect; + + m_Commands.push_back(cmd); + } + + void RenderBatch::AddIcon( + const Ref& texture, + const SRect& rect, + const SColor& color, + const int zOrder, + const SRect& scissorRect + ) + { + if (!texture) return; + + SDrawCommand cmd; + cmd.Type = EDrawCommandType::Rect; + cmd.Geometry = rect; + cmd.Color = color; + cmd.Texture = texture; + cmd.TextureMapping = ETextureMapping::Stretch; cmd.ZOrder = zOrder; cmd.ScissorRect = scissorRect; @@ -110,10 +215,28 @@ namespace Elixir::GUI void RenderBatch::AddDebugRect(const SRect& rect, const SColor& color) { SDrawCommand cmd; - cmd.Type = SDrawCommand::EType::DebugRect; + cmd.Type = EDrawCommandType::DebugRect; cmd.Geometry = rect; cmd.Color = color; m_Commands.push_back(cmd); } -} \ No newline at end of file + + void RenderBatch::BuildRuns() + { + m_Runs.clear(); + + uint32_t i = 0; + while (i < m_Commands.size()) + { + const auto type = m_Commands[i].Type; + uint32_t count = 1; + + while (i + count < m_Commands.size() && m_Commands[i + count].Type == type) + ++count; + + m_Runs.push_back({ type, i, count }); + i += count; + } + } +} diff --git a/Elixir/Source/Engine/GUI/Renderer/RenderBatch.h b/Elixir/Source/Engine/GUI/Renderer/RenderBatch.h index f8dbbf99..68652e7c 100644 --- a/Elixir/Source/Engine/GUI/Renderer/RenderBatch.h +++ b/Elixir/Source/Engine/GUI/Renderer/RenderBatch.h @@ -2,18 +2,26 @@ #include #include +#include #include namespace Elixir::GUI { - struct SDrawCommand + enum class EDrawCommandType : uint8_t { - enum class EType : uint8_t - { - Rect, Text, DebugRect - }; + Rect, Text, DebugRect + }; + + /** @brief Controls how a textured quad maps its texture coordinates. */ + enum class ETextureMapping : uint8_t + { + Stretch, + NineSlice, + }; - EType Type; + struct SDrawCommand + { + EDrawCommandType Type; SRect Geometry; SColor Color; @@ -46,34 +54,85 @@ namespace Elixir::GUI // For texture rendering Ref Texture; SRect TexCoords; + ETextureMapping TextureMapping = ETextureMapping::Stretch; // Z-order for sorting int ZOrder = 0; // Scissor rect for clipping (optional) - SRect ScissorRect; + SRect ScissorRect{ { -1.0f, -1.0f }, { -1.0f, -1.0f } }; + }; + + /** + * @brief A maximal contiguous slice of same-type commands inside an already + * z-sorted RenderBatch. + * + * [First, First + Count) indexes into RenderBatch::GetCommands(). + */ + struct SBatchRun + { + EDrawCommandType Type; + uint32_t First; + uint32_t Count; }; class ELIXIR_API RenderBatch final { public: /** - * Append another batch's commands to this one, offsetting each command's z-order. + * @brief Append another batch's commands to this one. + * + * Offsets each command's z-order and applies the ancestor clip rect inherited + * from the caller's position in the widget tree. A command that already carries its + * own valid ScissorRect (Button/TextField's ad-hoc self-clip) gets that rect + * intersected with clipRect; a command with no ScissorRect of its own adopts clipRect + * verbatim, when clipRect itself is valid. + * * Used to assemble the per-widget command caches into the frame batch. - * @param other batch whose commands are copied in. - * @param zOffset value added to each appended command's ZOrder. + * + * @param other Batch whose commands are copied in. + * @param zOffset Value added to each appended command's ZOrder. + * @param clipRect Ancestor clip inherit from the caller; pass the invalid + * {-1, -1}/{-1, -1} sentinel when there is no active clip (see SRect::IsValid). + * @param offset Presentation displacement inherited from ancestors. + * @param opacity Presentation opacity inherited from ancestors. */ - void Append(const RenderBatch& other, int zOffset); + void Append( + const RenderBatch& other, + int zOffset, + const SRect& clipRect, + const glm::vec2& offset = {}, + float opacity = 1.0f + ); void Sort(); void Clear(); /** - * Number of distinct z-layers these commands occupy: max ZOrder + 1, or 0 if empty. - * Used to advance the layer cursor past a widget's own commands during collection. + * Number of distinct non-debug z-layers these commands occupy: max ZOrder + 1, or 0 + * when they contain only debug commands. Used to advance the layer cursor past a + * widget's own commands during collection. */ int LayerSpan() const; + /** + * @brief Add the command needed to draw a brush. + * + * A brush with Texture becomes a nine-patch texture command. Otherwise it becomes a + * solid rectangle command with the brush's radius, outline and shadows. + * + * @param brush Surface description to draw. + * @param rect Destination rectangle. + * @param zOrder Draw layer for the command. + * @param scissorRect Optional clip rectangle. + */ + void AddBrush( + const SBrush& brush, + const SRect& rect, + int zOrder = 0, + const SRect& scissorRect = {{ -1, -1 }, { -1, -1 }} + ); + void AddRect( const SRect& rect, const SColor& color, @@ -104,11 +163,45 @@ namespace Elixir::GUI const SRect& scissorRect = {{ -1, -1 }, { -1, -1 }} ); + /** + * @brief Add a tinted icon texture without nine-slice mapping. + * + * Icon textures use their complete image area. The caller supplies the tint through + * color, so one alpha mask can render every interaction state. + * + * @param texture Rasterized icon texture. + * @param rect Destination rectangle. + * @param color Icon tint. + * @param zOrder Draw layer for the command. + * @param scissorRect Optional clip rectangle. + */ + void AddIcon( + const Ref& texture, + const SRect& rect, + const SColor& color, + int zOrder = 0, + const SRect& scissorRect = {{ -1, -1 }, { -1, -1 }} + ); + void AddDebugRect(const SRect& rect, const SColor& color = { 1.0f, 0.0f, 0.0f, 1.0f }); const std::vector& GetCommands() const { return m_Commands; } + /** + * @brief Contiguous same-type runs over GetCommands(), in z order. + * + * Rebuilt by Sort(); stale (from the previous sort) until Sort() runs again. + * + * @return A vector of runs. + */ + const std::vector& GetRuns() const { return m_Runs; } + private: + // Scans the (already z-sorted) commands and groups neighboring same-type + // commands into runs. Called by Sort(), right after the stable_sort. + void BuildRuns(); + std::vector m_Commands; + std::vector m_Runs; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/RenderPass.h b/Elixir/Source/Engine/GUI/Renderer/RenderPass.h index 4d322362..83077f8c 100644 --- a/Elixir/Source/Engine/GUI/Renderer/RenderPass.h +++ b/Elixir/Source/Engine/GUI/Renderer/RenderPass.h @@ -1,19 +1,98 @@ #pragma once #include +#include + +#include +#include namespace Elixir::GUI { - class RenderBatch; + constexpr size_t GrowBufferCapacity(const size_t capacity, const size_t requiredCapacity) + { + if (requiredCapacity <= capacity) + return capacity; + + if (capacity > std::numeric_limits::max() / 2) + return requiredCapacity; + + return std::max(requiredCapacity, capacity * 2); + } class RenderPass { public: virtual ~RenderPass() = default; - virtual void GenerateDrawCommands(const RenderBatch& batch) = 0; - virtual void Render(const Ref& cmd) = 0; + /** + * @brief Discards the geometry accumulated last frame. + * Called once per frame, on every registered pass, before any AppendRange call. + */ + virtual void BeginFrame() = 0; + + /** + * @brief Uploads the geometry accumulated across this frame's AppendRange calls + * to the GPU in a single call. + * + * Called once per frame, on every registered pass, after the last AppendRange. + */ + virtual void EndFrame() = 0; + + /** + * @brief Builds GPU geometry for one contiguous same-type run and appends it to + * this pass's per-frame instance buffer. + * + * @param commands Span over a single SBatchRun's slice of RenderBatch::GetCommands(). + * @return Index of the first instance generated by this call. + * + * @note The number of instances appended is not necessarily commands.size() - e.g. + * TextRenderPass expands each command into one instance per glyph. Read the actual + * count produced via GetInstanceCount() right after this call. + */ + virtual uint32_t AppendRange(std::span commands) = 0; + + /** + * @brief Binds this pass's pipeline and vertex buffer. + * + * The Renderer calls this only when the pass differs from the one used by the + * previous draw item. + * + * @param cmd The command buffer to record the bind commands into. + */ + virtual void Bind(const Ref& cmd) = 0; + + /** + * @brief Issues the draw call for the [firstInstance, firstInstance + instanceCount) + * range produced by an earlier AppendRange this frame. + * + * @param cmd The command buffer to record the draw commands into. + * @param firstInstance The index of the first instance to draw. + * @param instanceCount The number of instances to draw. + */ + virtual void Render( + const Ref& cmd, + uint32_t firstInstance, + uint32_t instanceCount + ) = 0; + virtual bool HasData() const = 0; virtual void Clear() = 0; + + /** + * @brief Total instances accumulated so far this frame. + * + * For passes that don't draw instanced, it handles + * vertices count - see DebugRenderPass. + * + * @return The number of instances accumulated in this pass for this frame. + */ + virtual uint32_t GetInstanceCount() const = 0; + + /** + * The EDrawCommandType this pass consumes. The Renderer uses this at + * registration time to route each SBatchRun to the pass responsible for it. + * @return The type of draw commands this pass is responsible for rendering. + */ + virtual EDrawCommandType GetHandleType() const = 0; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/Renderer.cpp b/Elixir/Source/Engine/GUI/Renderer/Renderer.cpp index 9f867259..f06f0ca3 100644 --- a/Elixir/Source/Engine/GUI/Renderer/Renderer.cpp +++ b/Elixir/Source/Engine/GUI/Renderer/Renderer.cpp @@ -36,10 +36,30 @@ namespace Elixir::GUI m_PerFrameConstantBuffer->UpdateData(&m_PerFrameData, sizeof(SPerFrameData)); } - void Renderer::Rebuild(const RenderBatch& batch) const + void Renderer::Rebuild(const RenderBatch& batch) { for (const auto& pass : m_RenderPasses) - pass->GenerateDrawCommands(batch); + pass->BeginFrame(); + + m_DrawItems.clear(); + + for (const auto& run : batch.GetRuns()) + { + const auto it = m_PassesByType.find(run.Type); + if (it == m_PassesByType.end()) continue; + + const auto pass = it->second; + const std::span range(batch.GetCommands().data() + run.First, run.Count); + + const auto firstInstance = pass->AppendRange(range); + const auto instanceCount = pass->GetInstanceCount() - firstInstance; + + if (instanceCount > 0) + m_DrawItems.push_back({ pass, firstInstance, instanceCount }); + } + + for (const auto& pass : m_RenderPasses) + pass->EndFrame(); } void Renderer::Draw() const @@ -47,9 +67,17 @@ namespace Elixir::GUI const auto cmd = m_GraphicsContext->GetSecondaryCommandBuffer(); BeginRendering(cmd); - for (const auto& pass : m_RenderPasses) - if (pass->HasData()) - pass->Render(cmd); + RenderPass* lastPass = nullptr; + for (const auto& item : m_DrawItems) + { + if (item.Pass != lastPass) + { + item.Pass->Bind(cmd); + lastPass = item.Pass; + } + + item.Pass->Render(cmd, item.FirstInstance, item.InstanceCount); + } EndRendering(cmd); } @@ -57,6 +85,7 @@ namespace Elixir::GUI void Renderer::RegisterRenderPass(const Ref& pass) { m_RenderPasses.push_back(pass); + m_PassesByType[pass->GetHandleType()] = pass.get(); EE_CORE_TRACE("GUI: Registered RenderPass.") } diff --git a/Elixir/Source/Engine/GUI/Renderer/Renderer.h b/Elixir/Source/Engine/GUI/Renderer/Renderer.h index 156bb311..68178eef 100644 --- a/Elixir/Source/Engine/GUI/Renderer/Renderer.h +++ b/Elixir/Source/Engine/GUI/Renderer/Renderer.h @@ -6,6 +6,18 @@ namespace Elixir::GUI { + /** + * @brief One z-ordered draw call: the instance range an earlier + * RenderPass::AppendRange produced for a single SBatchRun, plus the + * pass that owns it. + */ + struct SDrawItem + { + RenderPass* Pass; + uint32_t FirstInstance; + uint32_t InstanceCount; + }; + struct SPerFrameData { glm::mat4 Proj; @@ -23,11 +35,12 @@ namespace Elixir::GUI void Resize(const Extent2D& extent); /** - * Regenerate each pass's GPU geometry from the batch (CPU build + vertex upload). - * Only needs to run when the batch changed; the passes retain their buffers otherwise. + * Regenerate each pass's GPU geometry from the batch (CPU build + vertex upload) + * and rebuild the z-ordered draw item list used by Draw(). Only needs to run when + * the batch changed; the passes retain their buffers otherwise. * @param batch the assembled frame batch. */ - void Rebuild(const RenderBatch& batch) const; + void Rebuild(const RenderBatch& batch); /** * Record and submit the draw calls using each pass's current (cached) geometry. @@ -50,6 +63,9 @@ namespace Elixir::GUI Ref m_PerFrameConstantBuffer; std::vector> m_RenderPasses; + std::unordered_map m_PassesByType; + + std::vector m_DrawItems; float m_DPIScale = 1.0f; Extent2D m_RenderExtent{}; diff --git a/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.cpp b/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.cpp index 2295ec57..dcf1959a 100644 --- a/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.cpp +++ b/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.cpp @@ -19,33 +19,43 @@ namespace Elixir::GUI BindShaderParameters(); } - void TextRenderPass::GenerateDrawCommands(const RenderBatch& batch) + void TextRenderPass::BeginFrame() { m_Quads.clear(); + } - for (const auto& drawCmd : batch.GetCommands()) - { - switch (drawCmd.Type) - { - case SDrawCommand::EType::Text: - BuildTextGeometry(drawCmd); - break; - default: - break; - } - } - + void TextRenderPass::EndFrame() + { if (!m_Quads.empty()) { + EnsureQuadBufferCapacity(m_Quads.size()); m_QuadBuffer->UpdateData(m_Quads.data(), m_Quads.size() * sizeof(SQuad)); } } - void TextRenderPass::Render(const Ref& cmd) + uint32_t TextRenderPass::AppendRange(const std::span commands) + { + const auto firstInstance = (uint32_t)m_Quads.size(); + + for (const auto& drawCmd : commands) + BuildTextGeometry(drawCmd); + + return firstInstance; + } + + void TextRenderPass::Bind(const Ref& cmd) { m_Pipeline->Bind(cmd); m_QuadBuffer->Bind(cmd); - cmd->Draw(6, m_Quads.size()); + } + + void TextRenderPass::Render( + const Ref& cmd, + const uint32_t firstInstance, + const uint32_t instanceCount + ) + { + cmd->Draw(6, instanceCount, 0, firstInstance); } bool TextRenderPass::HasData() const @@ -58,6 +68,16 @@ namespace Elixir::GUI m_Quads.clear(); } + uint32_t TextRenderPass::GetInstanceCount() const + { + return (uint32_t)m_Quads.size(); + } + + EDrawCommandType TextRenderPass::GetHandleType() const + { + return EDrawCommandType::Text; + } + void TextRenderPass::InitRenderPass(const ShaderLoader* shaderLoader) { const BufferLayout bufferLayout({ @@ -91,6 +111,7 @@ namespace Elixir::GUI m_Quads.reserve(MAX_CHARACTERS); m_QuadBuffer = DynamicVertexBuffer::Create(m_GraphicsContext, MAX_CHARACTERS * sizeof(SQuad)); m_QuadBuffer->SetLayout(bufferLayout); + m_QuadCapacity = MAX_CHARACTERS; } void TextRenderPass::BindShaderParameters() const @@ -107,6 +128,20 @@ namespace Elixir::GUI m_Shader->BindSampler("atlasSampler", sampler); } + void TextRenderPass::EnsureQuadBufferCapacity(const size_t requiredCapacity) + { + if (requiredCapacity <= m_QuadCapacity) + return; + + const size_t newCapacity = GrowBufferCapacity(m_QuadCapacity, requiredCapacity); + auto buffer = DynamicVertexBuffer::Create(m_GraphicsContext, newCapacity * sizeof(SQuad)); + buffer->SetLayout(m_QuadBuffer->GetLayout()); + + m_RetiredQuadBuffers.push_back(std::move(m_QuadBuffer)); + m_QuadBuffer = std::move(buffer); + m_QuadCapacity = newCapacity; + } + void TextRenderPass::BuildTextGeometry(const SDrawCommand& cmd) { const auto font = cmd.Font; @@ -116,7 +151,9 @@ namespace Elixir::GUI const float lineHeight = FontManager::GetLineHeight(font, cmd.FontSize); float cursorX = cmd.Geometry.Position.x; - float cursorY = cmd.Geometry.Position.y + (cmd.Geometry.Size.y - lineHeight) * 0.5f; + const size_t lineCount = std::count(cmd.Text.begin(), cmd.Text.end(), '\n') + 1; + const float textHeight = lineHeight * lineCount; + float cursorY = cmd.Geometry.Position.y + (cmd.Geometry.Size.y - textHeight) * 0.5f; int i = 0; while (i < (int)cmd.Text.size()) @@ -127,6 +164,7 @@ namespace Elixir::GUI if (codepoint == '\n') { cursorX = cmd.Geometry.Position.x; cursorY += lineHeight; + i += charLen; continue; } @@ -189,4 +227,4 @@ namespace Elixir::GUI m_Quads.push_back(quad); } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.h b/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.h index 9a84af1a..7c316534 100644 --- a/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.h +++ b/Elixir/Source/Engine/GUI/Renderer/TextRenderPass.h @@ -18,14 +18,29 @@ namespace Elixir::GUI const Ref& perFrameCB ); - void GenerateDrawCommands(const RenderBatch& batch) override; - void Render(const Ref& cmd) override; + void BeginFrame() override; + void EndFrame() override; + + uint32_t AppendRange(std::span commands) override; + + void Bind(const Ref& cmd) override; + void Render( + const Ref& cmd, + uint32_t firstInstance, + uint32_t instanceCount + ) override; + bool HasData() const override; void Clear() override; + uint32_t GetInstanceCount() const override; + + EDrawCommandType GetHandleType() const override; + private: void InitRenderPass(const ShaderLoader* shaderLoader); void BindShaderParameters() const; + void EnsureQuadBufferCapacity(size_t requiredCapacity); void BuildTextGeometry(const SDrawCommand& cmd); void BuildTextureGeometry(const SDrawCommand& cmd); @@ -46,9 +61,11 @@ namespace Elixir::GUI Ref m_Shader; Ref m_Pipeline; Ref m_QuadBuffer; + std::vector> m_RetiredQuadBuffers; float m_DPIScale; Ref m_PerFrameConstantBuffer; const GraphicsContext* m_GraphicsContext = nullptr; + size_t m_QuadCapacity = 0; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/ScrollBox.cpp b/Elixir/Source/Engine/GUI/ScrollBox.cpp new file mode 100644 index 00000000..db05444b --- /dev/null +++ b/Elixir/Source/Engine/GUI/ScrollBox.cpp @@ -0,0 +1,315 @@ +#include "epch.h" +#include "ScrollBox.h" + +#include + +namespace Elixir::GUI +{ + ScrollBox::ScrollBox() + : m_ScrollBarStyle(GetDefaultStyles().GetWidgetStyle()) + { + } + + void ScrollBox::SetStyle(const SScrollBarStyle& style) + { + m_ScrollBarStyle = style; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void ScrollBox::SetSize(const glm::vec2& size) + { + if (m_Size == size) return; + m_Size = size; + MarkLayoutDirty(); + } + + void ScrollBox::SetScrollAxis(EScrollAxis axis) + { + if (m_ScrollAxis == axis) return; + m_ScrollAxis = axis; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void ScrollBox::SetScrollOffset(const glm::vec2& offset) + { + const SScrollbarVisibility visibility = ResolveScrollbarVisibility(m_Geometry.Size); + const glm::vec2 clamped = ClampScrollOffset(offset, m_Geometry.Size, visibility); + if (m_ScrollOffset == clamped) return; + + m_ScrollOffset = clamped; + MarkLayoutDirty(); // reposition content + MarkRenderDirty(); // thumb moved + } + + void ScrollBox::SetShowScrollbar(bool show) + { + if (m_ShowScrollbar == show) return; + m_ShowScrollbar = show; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void ScrollBox::SetScrollbarThickness(const float thickness) + { + if (m_ScrollBarStyle.Thickness == thickness) return; + m_ScrollBarStyle.Thickness = thickness; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void ScrollBox::SetScrollbarColor(const SColor& color) + { + m_ScrollBarStyle.Normal.Thumb.Color = color; + MarkRenderDirty(); + } + + glm::vec2 ScrollBox::ComputeDesiredSize(const glm::vec2& availableSize) + { + glm::vec2 desired = glm::min(m_Size, availableSize); + + // Content can only shrink the reported size toward itself, never grow it past the + // configured viewport size - a ScrollBox clips oversized content, it doesn't expand + // to swallow it. Measure (not ComputeDesiredSize) is the public, cached entry point + // every container is expected to call on a child. + if (HasContent()) + { + const SScrollbarVisibility visibility = ResolveScrollbarVisibility(desired); + const glm::vec2 contentConstraint = ContentMeasureConstraint(desired, visibility); + const glm::vec2 contentSize = m_ContentSlot->GetWidget()->Measure(contentConstraint); + + glm::vec2 contentSizeWithGutters = contentSize; + if (visibility.Vertical) + contentSizeWithGutters.x += m_ScrollBarStyle.Thickness; + if (visibility.Horizontal) + contentSizeWithGutters.y += m_ScrollBarStyle.Thickness; + + // Content is measured against the gutter-reduced cross axis. Include each + // visible gutter in the desired size so a narrow scrolling child is not laid + // out underneath its own scrollbar. + desired = glm::min(desired, contentSizeWithGutters); + } + + return desired; + } + + void ScrollBox::LayoutChildren(const SRect& allocatedSpace) + { + if (!HasContent()) + { + const bool visibilityChanged = + m_ScrollbarVisibility.Vertical || m_ScrollbarVisibility.Horizontal; + m_ContentSize = {}; + m_ScrollbarVisibility = {}; + if (visibilityChanged) MarkRenderDirty(); + return; + } + + const auto& content = m_ContentSlot->GetWidget(); + const SScrollbarVisibility visibility = ResolveScrollbarVisibility(allocatedSpace.Size); + + // The content keeps its DESIRED size along the scrolling axis/axes - that's what + // there is to scroll through - but is capped to the viewport on the other axis, + // same as a non-scrolling child would be. + const glm::vec2 contentConstraint = ContentMeasureConstraint(allocatedSpace.Size, visibility); + const glm::vec2 desired = content->Measure(contentConstraint); + + // Same gutter CrossAxisSpace reserves in ContentMeasureConstraint, applied to the + // space content is actually ARRANGED into - measuring content against a narrower + // width but then stretching it back out to the full viewport here would put it right + // back under the scrollbar it was just measured to avoid. + const glm::vec2 crossAxisSpace = CrossAxisSpace(allocatedSpace.Size, visibility); + + glm::vec2 contentSize = crossAxisSpace; + if (m_ScrollAxis != EScrollAxis::Horizontal) contentSize.y = desired.y; + if (m_ScrollAxis != EScrollAxis::Vertical) contentSize.x = desired.x; + + const glm::vec2 previousContentSize = m_ContentSize; + const glm::vec2 previousScrollOffset = m_ScrollOffset; + const SScrollbarVisibility previousVisibility = m_ScrollbarVisibility; + + m_ContentSize = contentSize; + m_ScrollbarVisibility = visibility; + const glm::vec2 clampedOffset = ClampScrollOffset( + previousScrollOffset, + allocatedSpace.Size, + visibility + ); + m_ScrollOffset = clampedOffset; + + if (m_ContentSize != previousContentSize || m_ScrollOffset != previousScrollOffset || + m_ScrollbarVisibility.Vertical != previousVisibility.Vertical || + m_ScrollbarVisibility.Horizontal != previousVisibility.Horizontal) + MarkRenderDirty(); // scrollbar thumb size and position changed + + const SRect contentRect = { allocatedSpace.Position - m_ScrollOffset, contentSize }; + content->ArrangeChildren(contentRect); + } + + void ScrollBox::BuildDrawCommands(RenderBatch& batch, const int zOrder) + { + if (!m_ShowScrollbar) return; + + if (m_ScrollbarVisibility.Vertical) + AddScrollbar(batch, zOrder, true, m_ScrollbarVisibility); + + if (m_ScrollbarVisibility.Horizontal) + AddScrollbar(batch, zOrder, false, m_ScrollbarVisibility); + } + + SInputReply ScrollBox::HandleMouseScrolled(const MouseScrolledEvent& event) + { + glm::vec2 delta{}; + + if (m_ScrollAxis != EScrollAxis::Horizontal) + delta.y = -event.GetOffsetY() * SCROLL_SPEED; + if (m_ScrollAxis != EScrollAxis::Vertical) + delta.x = -event.GetOffsetX() * SCROLL_SPEED; + + if (delta == glm::vec2(0.0f)) + return SInputReply::Unhandled(); + + const glm::vec2 clamped = ClampScrollOffset( + m_ScrollOffset + delta, + m_Geometry.Size, + m_ScrollbarVisibility + ); + if (clamped == m_ScrollOffset) + return SInputReply::Unhandled(); // at the edge; let an ancestor try. + + m_ScrollOffset = clamped; + MarkLayoutDirty(); // reposition content + MarkRenderDirty(); // thumb moved + + return SInputReply::Handled(); + } + + ScrollBox::SScrollbarVisibility ScrollBox::ResolveScrollbarVisibility( + const glm::vec2& viewportSize + ) const + { + SScrollbarVisibility visibility; + if (!m_ShowScrollbar || !HasContent()) return visibility; + + const auto& content = m_ContentSlot->GetWidget(); + while (true) + { + const glm::vec2 contentConstraint = ContentMeasureConstraint(viewportSize, visibility); + const glm::vec2 contentSize = content->Measure(contentConstraint); + const glm::vec2 contentViewport = CrossAxisSpace(viewportSize, visibility); + + SScrollbarVisibility required = visibility; + if (m_ScrollAxis != EScrollAxis::Horizontal && contentSize.y > contentViewport.y) + required.Vertical = true; + if (m_ScrollAxis != EScrollAxis::Vertical && contentSize.x > contentViewport.x) + required.Horizontal = true; + + if (required.Vertical == visibility.Vertical && + required.Horizontal == visibility.Horizontal) + return visibility; + + visibility = required; + } + } + + glm::vec2 ScrollBox::CrossAxisSpace( + const glm::vec2& viewportSize, + const SScrollbarVisibility& visibility + ) const + { + glm::vec2 space = viewportSize; + + if (visibility.Vertical) + space.x = std::max(0.0f, space.x - m_ScrollBarStyle.Thickness); + if (visibility.Horizontal) + space.y = std::max(0.0f, space.y - m_ScrollBarStyle.Thickness); + + return space; + } + + glm::vec2 ScrollBox::ContentMeasureConstraint( + const glm::vec2& viewportSize, + const SScrollbarVisibility& visibility + ) const + { + glm::vec2 constraint = CrossAxisSpace(viewportSize, visibility); + if (m_ScrollAxis != EScrollAxis::Horizontal) constraint.y = UnconstrainedSize; + if (m_ScrollAxis != EScrollAxis::Vertical) constraint.x = UnconstrainedSize; + return constraint; + } + + glm::vec2 ScrollBox::ClampScrollOffset( + const glm::vec2& offset, + const glm::vec2& viewportSize, + const SScrollbarVisibility& visibility + ) const + { + const glm::vec2 contentViewport = CrossAxisSpace(viewportSize, visibility); + const glm::vec2 maxOffset = glm::max(m_ContentSize - contentViewport, glm::vec2(0.0f)); + return glm::clamp(offset, glm::vec2(0.0f), maxOffset); + } + + void ScrollBox::AddScrollbar( + RenderBatch& batch, + const int zOrder, + const bool vertical, + const SScrollbarVisibility& visibility + ) const + { + const auto& appearance = m_ScrollBarStyle.Resolve(GetInteractionState()); + const float thickness = m_ScrollBarStyle.Thickness; + const glm::vec2 contentViewport = CrossAxisSpace(m_Geometry.Size, visibility); + + if (vertical) + { + const SRect track = { + { m_Geometry.Position.x + contentViewport.x, m_Geometry.Position.y }, + { thickness, contentViewport.y } + }; + + const float maxScroll = m_ContentSize.y - contentViewport.y; + const float thumbHeight = std::min( + track.Size.y, + std::max( + track.Size.y * (contentViewport.y / m_ContentSize.y), + m_ScrollBarStyle.MinimumThumbLength + ) + ); + const float scrollRatio = maxScroll > 0.0f ? m_ScrollOffset.y / maxScroll : 0.0f; + + const SRect thumb = { + { track.Position.x, track.Position.y + scrollRatio * (track.Size.y - thumbHeight) }, + { thickness, thumbHeight } + }; + + batch.AddBrush(appearance.Track, track, zOrder); + batch.AddBrush(appearance.Thumb, thumb, zOrder + 1); + } + else + { + const SRect track = { + { m_Geometry.Position.x, m_Geometry.Position.y + contentViewport.y }, + { contentViewport.x, thickness } + }; + + const float maxScroll = m_ContentSize.x - contentViewport.x; + const float thumbWidth = std::min( + track.Size.x, + std::max( + track.Size.x * (contentViewport.x / m_ContentSize.x), + m_ScrollBarStyle.MinimumThumbLength + ) + ); + const float scrollRatio = maxScroll > 0.0f ? m_ScrollOffset.x / maxScroll : 0.0f; + + const SRect thumb = { + { track.Position.x + scrollRatio * (track.Size.x - thumbWidth), track.Position.y }, + { thumbWidth, thickness } + }; + + batch.AddBrush(appearance.Track, track, zOrder); + batch.AddBrush(appearance.Thumb, thumb, zOrder + 1); + } + } +} diff --git a/Elixir/Source/Engine/GUI/ScrollBox.h b/Elixir/Source/Engine/GUI/ScrollBox.h new file mode 100644 index 00000000..ccda422d --- /dev/null +++ b/Elixir/Source/Engine/GUI/ScrollBox.h @@ -0,0 +1,135 @@ +#pragma once + +#include + +namespace Elixir::GUI +{ + enum class EScrollAxis : uint8_t + { + Vertical, Horizontal, Both + }; + + /** @brief Visual data for a scrollbar in one interaction state. */ + struct SScrollBarAppearance + { + SBrush Track; + SBrush Thumb; + }; + + /** @brief Complete visual and sizing style for a scrollbar. */ + struct SScrollBarStyle final : SStyle, TStateStyles + { + float Thickness = 8.0f; + float MinimumThumbLength = 8.0f; + }; + + class ELIXIR_API ScrollBox : public ContentWidget + { + public: + ScrollBox(); + + /** + * @brief Replace this scroll box's complete scrollbar style. + * @param style Style to copy. + */ + void SetStyle(const SScrollBarStyle& style); + + /** + * @brief Set the viewport size this ScrollBox asks for. + * + * Unlike most containers, a ScrollBox never grows past this to fit its content - + * that would defeat the point of scrolling. Content smaller than this still shrinks + * the reported desired size, same as any other widget (see ComputeDesiredSize). + * Distinct from the base Widget's m_DesiredSize, which the Measure() cache owns and + * overwrites every call - this is the configured input to that computation, not its + * cached output. + * + * @param size The viewport size. + */ + void SetSize(const glm::vec2& size); + + EScrollAxis GetScrollAxis() const { return m_ScrollAxis; } + void SetScrollAxis(EScrollAxis axis); + + glm::vec2 GetScrollOffset() const { return m_ScrollOffset; } + void SetScrollOffset(const glm::vec2& offset); + + bool IsShowingScrollbar() const { return m_ShowScrollbar; } + void SetShowScrollbar(bool show); + + float GetScrollbarThickness() const { return m_ScrollBarStyle.Thickness; } + void SetScrollbarThickness(float thickness); + + SColor GetScrollbarColor() const { return m_ScrollBarStyle.Normal.Thumb.Color; } + void SetScrollbarColor(const SColor& color); + + bool CanHandleMouseInput() const override { return IsEnabled(); } + + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; + + bool ClipsChildren() const override { return true; } + + void LayoutChildren(const SRect& allocatedSpace) override; + void BuildDrawCommands(RenderBatch& batch, int zOrder) override; + + SInputReply HandleMouseScrolled(const MouseScrolledEvent& event) override; + + private: + struct SScrollbarVisibility + { + bool Vertical = false; + bool Horizontal = false; + }; + + SScrollbarVisibility ResolveScrollbarVisibility(const glm::vec2& viewportSize) const; + + // viewportSize with the gutters for the scrollbars that are actually visible + // subtracted. Shared by measurement and arrangement so content never sits under a + // rendered scrollbar, without reserving space for one that is not needed. + glm::vec2 CrossAxisSpace( + const glm::vec2& viewportSize, + const SScrollbarVisibility& visibility + ) const; + + // Constraint handed to the content's Measure() call: UnconstrainedSize on every axis + // this ScrollBox scrolls (so content reports its full natural size to scroll + // through), CrossAxisSpace's result on the axis it doesn't (content is capped to the + // viewport remaining after visible gutters, same as a non-scrolling child would be). + glm::vec2 ContentMeasureConstraint( + const glm::vec2& viewportSize, + const SScrollbarVisibility& visibility + ) const; + + glm::vec2 ClampScrollOffset( + const glm::vec2& offset, + const glm::vec2& viewportSize, + const SScrollbarVisibility& visibility + ) const; + void AddScrollbar( + RenderBatch& batch, + int zOrder, + bool vertical, + const SScrollbarVisibility& visibility + ) const; + + static constexpr float SCROLL_SPEED = 40.0f; + + EScrollAxis m_ScrollAxis = EScrollAxis::Vertical; + glm::vec2 m_ScrollOffset{}; + + // Configured viewport size; ComputeDesiredSize never returns more than this on + // either axis. A reasonable non-zero default. + glm::vec2 m_Size{ 200.0f, 200.0f }; + + // Content's arranged size (desired size along the scrolling axis/axes, capped to + // the viewport on the other axis). Recomputed by LayoutChildren; used to clamp + // m_ScrollOffset and to size/position the scrollbar thumb. + glm::vec2 m_ContentSize{}; + + SScrollbarVisibility m_ScrollbarVisibility; + + bool m_ShowScrollbar = true; + SScrollBarStyle m_ScrollBarStyle; + }; +} diff --git a/Elixir/Source/Engine/GUI/Slot.cpp b/Elixir/Source/Engine/GUI/Slot.cpp index c0b9f30a..9e1b1263 100644 --- a/Elixir/Source/Engine/GUI/Slot.cpp +++ b/Elixir/Source/Engine/GUI/Slot.cpp @@ -80,9 +80,23 @@ namespace Elixir::GUI return *this; } - LayoutSlot& LayoutSlot::SetFillRatio(const float ratio) + LayoutSlot& LayoutSlot::SetAutoSize() { - m_FillRatio = ratio; + m_SizeRule = SSizeParam::Auto(); + InvalidateOwnerLayout(); + return *this; + } + + LayoutSlot& LayoutSlot::SetFillSize(const float ratio) + { + m_SizeRule = SSizeParam::Fill(ratio); + InvalidateOwnerLayout(); + return *this; + } + + LayoutSlot& LayoutSlot::SetFixedSize(const float pixels) + { + m_SizeRule = SSizeParam::Fixed(pixels); InvalidateOwnerLayout(); return *this; } diff --git a/Elixir/Source/Engine/GUI/Slot.h b/Elixir/Source/Engine/GUI/Slot.h index 8f5be7b5..b895ff8b 100644 --- a/Elixir/Source/Engine/GUI/Slot.h +++ b/Elixir/Source/Engine/GUI/Slot.h @@ -63,12 +63,13 @@ namespace Elixir::GUI glm::vec2 GetMinSize() const { return m_MinSize; } LayoutSlot& SetMinSize(const glm::vec2& size); - glm::vec2 GetMaxSize() const { return m_MaxSize; } LayoutSlot& SetMaxSize(const glm::vec2& size); - float GetFillRatio() const { return m_FillRatio; } - LayoutSlot& SetFillRatio(float ratio); + SSizeParam GetSizeRule() const { return m_SizeRule; } + LayoutSlot& SetAutoSize(); + LayoutSlot& SetFillSize(float ratio = 1.0f); + LayoutSlot& SetFixedSize(float pixels); private: EHorizontalAlignment m_HAlignment = EHorizontalAlignment::Center; @@ -79,8 +80,7 @@ namespace Elixir::GUI glm::vec2 m_MinSize{0, 0}; glm::vec2 m_MaxSize{FLT_MAX, FLT_MAX}; - // For proportional layouts (like Flexbox flex property) - // Work only with Stretch alignment - float m_FillRatio = 1.0f; + // Sizing rule along the owner's main axis. + SSizeParam m_SizeRule; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/GUI/Style.cpp b/Elixir/Source/Engine/GUI/Style.cpp new file mode 100644 index 00000000..56fd8bd3 --- /dev/null +++ b/Elixir/Source/Engine/GUI/Style.cpp @@ -0,0 +1,110 @@ +#include "epch.h" +#include "Style.h" + +#include +#include +#include +#include +#include +#include + +namespace Elixir::GUI +{ + namespace + { + SAppearance MakeCheckboxAppearance(const SColor& color, const SOutline& outline = {}) + { + SAppearance appearance; + appearance.Background.Color = color; + appearance.Background.CornerRadius = glm::vec4{ 3.0f }; + appearance.Background.Outline = outline; + return appearance; + } + + StyleSet CreateDefaultStyles() + { + StyleSet styles; + + styles.SetWidgetStyle(SWidgetStyle{}); + + SScrollBarStyle scrollBar; + scrollBar.Normal.Track.Color = { 0.0f, 0.0f, 0.0f, 0.15f }; + scrollBar.Normal.Thumb.Color = { 1.0f, 1.0f, 1.0f, 0.35f }; + scrollBar.Normal.Thumb.CornerRadius = glm::vec4{ 4.0f }; + scrollBar.Hovered = scrollBar.Normal; + scrollBar.Hovered->Thumb.Color.A = 0.55f; + scrollBar.Pressed = scrollBar.Hovered; + scrollBar.Focused = scrollBar.Normal; + scrollBar.Disabled = scrollBar.Normal; + scrollBar.Disabled->Thumb.Color.A = 0.2f; + styles.SetWidgetStyle(std::move(scrollBar)); + + SIconStyle icon; + icon.Normal.Foreground = { 0.875f, 0.882f, 0.898f, 1.0f }; + icon.Hovered = icon.Normal; + icon.Hovered->Foreground = { 1.0f, 1.0f, 1.0f, 1.0f }; + icon.Pressed = icon.Hovered; + icon.Focused = icon.Normal; + icon.Disabled = icon.Normal; + icon.Disabled->Foreground.A = 0.5f; + styles.SetWidgetStyle(std::move(icon)); + + SButtonStyle button; + button.Normal.Background.Color = { 0.0941f, 0.0941f, 0.1059f, 1.0f }; + button.Normal.Background.CornerRadius = glm::vec4{ 4.0f }; + button.Normal.Background.Borders = glm::vec4{ 30.0f }; + button.Normal.Background.Outline = { { 0.1529f, 0.1529f, 0.1647f, 1.0f }, 0.5f }; + button.Normal.Foreground = { 0.8941f, 0.8941f, 0.9059f, 1.0f }; + button.Hovered = button.Normal; + button.Hovered->Background.Color = { 0.1529f, 0.1529f, 0.1647f, 1.0f }; + button.Focused = button.Normal; + button.Focused->Background.Outline = { { 0.6314f, 0.6314f, 0.6667f, 1.0f }, 1.0f }; + button.Pressed = button.Hovered; + button.Pressed->Background.Outline = button.Focused->Background.Outline; + button.Disabled = button.Normal; + button.Disabled->Background.Color.A = 0.5f; + button.Disabled->Foreground.A = 0.5f; + styles.SetWidgetStyle(std::move(button)); + + STextFieldStyle textField; + textField.Normal.Background.Color = { 0.0941f, 0.0941f, 0.1059f, 1.0f }; + textField.Normal.Background.CornerRadius = glm::vec4{ 4.0f }; + textField.Normal.Background.Borders = glm::vec4{ 30.0f }; + textField.Normal.Background.Outline = { { 0.1529f, 0.1529f, 0.1647f, 1.0f }, 0.5f }; + textField.Normal.Foreground = { 0.8941f, 0.8941f, 0.9059f, 1.0f }; + textField.Hovered = textField.Normal; + textField.Focused = textField.Normal; + textField.Focused->Background.Outline = { { 0.6314f, 0.6314f, 0.6667f, 1.0f }, 1.0f }; + textField.Pressed = textField.Focused; + textField.Disabled = textField.Normal; + textField.Disabled->Background.Color.A = 0.5f; + textField.Disabled->Foreground.A = 0.5f; + styles.SetWidgetStyle(std::move(textField)); + + const SOutline uncheckedOutline{ { 0.224f, 0.231f, 0.251f, 1.0f }, 1.0f }; + SCheckboxStyle checkbox; + checkbox.Normal = MakeCheckboxAppearance({ 0.094f, 0.098f, 0.106f, 1.0f }, uncheckedOutline); + checkbox.Hovered = MakeCheckboxAppearance({ 0.145f, 0.149f, 0.161f, 1.0f }, uncheckedOutline); + checkbox.Pressed = checkbox.Hovered; + checkbox.Focused = checkbox.Normal; + checkbox.Disabled = checkbox.Normal; + checkbox.Disabled->Background.Color.A = 0.5f; + checkbox.Checked = MakeCheckboxAppearance({ 0.208f, 0.455f, 0.941f, 1.0f }); + checkbox.CheckedHovered = checkbox.Checked; + checkbox.CheckedHovered->Background.Color = { 0.271f, 0.510f, 0.980f, 1.0f }; + checkbox.CheckedPressed = checkbox.CheckedHovered; + checkbox.CheckedFocused = checkbox.Checked; + checkbox.CheckedDisabled = checkbox.Checked; + checkbox.CheckedDisabled->Background.Color.A = 0.5f; + styles.SetWidgetStyle(std::move(checkbox)); + + return styles; + } + } + + StyleSet& GetDefaultStyles() + { + static StyleSet styles = CreateDefaultStyles(); + return styles; + } +} diff --git a/Elixir/Source/Engine/GUI/Style.h b/Elixir/Source/Engine/GUI/Style.h new file mode 100644 index 00000000..395fb8f1 --- /dev/null +++ b/Elixir/Source/Engine/GUI/Style.h @@ -0,0 +1,225 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace Elixir::GUI +{ + /** + * @brief States supplied by Widget while it handles input. + * + * More than one state can be active at once. A style resolves them in this order: + * Disabled, Pressed, Focused, Hovered, then Normal. + */ + enum class EInteractionState : uint8_t + { + None = 0, + Hovered = 1 << 0, + Pressed = 1 << 1, + Focused = 1 << 2, + Disabled = 1 << 3, + }; + + GENERATE_ENUM_CLASS_OPERATORS(EInteractionState) + + /** + * @brief Legacy names for a single interactive appearance. + * + * Use a component style and SetStyle for new code. This enum remains while existing + * callers move from individual setters to complete, typed styles. + */ + enum class EStyleLayer : uint8_t + { + Normal, + Hovered, + Pressed, + Focused, + Disabled, + Count, + }; + + /** + * @brief Describes how to draw one rectangular surface. + * + * A brush can be a solid surface or a tinted nine-patch texture. Its radius, outline, + * inset shadow and drop shadow apply to solid surfaces. RenderBatch chooses the suitable + * command from Texture. + */ + struct SBrush + { + SColor Color{}; + Ref Texture; + glm::vec4 Borders{}; + glm::vec4 CornerRadius{}; + SOutline Outline{}; + glm::vec4 InsetShadow{}; + glm::vec4 DropShadow{}; + + }; + + /** + * @brief Base visual data shared by component appearances. + * + * A base widget draws only Background. Components extend this type when they draw more + * data, such as Button's foreground color. + */ + struct SAppearance + { + SBrush Background; + + }; + + /** + * @brief Stores the appearances a component uses for interaction states. + * + * The struct stores complete appearances, not field-level patches. Resolve returns one + * appearance and never merges fields from separate states. + * + * @tparam TAppearance Appearance type owned by the component style. + */ + template + struct TStateStyles + { + TAppearance Normal; + std::optional Hovered; + std::optional Pressed; + std::optional Focused; + std::optional Disabled; + + /** + * @brief Select the appearance for active interaction states. + * + * Disabled wins over Pressed, Focused and Hovered. An absent state uses Normal. + * + * @param states Interaction states active on the component. + * @return The selected complete appearance. + */ + const TAppearance& Resolve(const EInteractionState states) const + { + if (states & EInteractionState::Disabled && Disabled) return *Disabled; + if (states & EInteractionState::Pressed && Pressed) return *Pressed; + if (states & EInteractionState::Focused && Focused) return *Focused; + if (states & EInteractionState::Hovered && Hovered) return *Hovered; + return Normal; + } + + /** + * @brief Get one appearance by its legacy layer name. + * @param layer Appearance to access. + * @return The requested complete appearance. + */ + TAppearance& Get(const EStyleLayer layer) + { + switch (layer) + { + case EStyleLayer::Hovered: return GetOrCreate(Hovered); + case EStyleLayer::Pressed: return GetOrCreate(Pressed); + case EStyleLayer::Focused: return GetOrCreate(Focused); + case EStyleLayer::Disabled: return GetOrCreate(Disabled); + default: return Normal; + } + } + + /** + * @brief Get one appearance by its legacy layer name. + * @param layer Appearance to access. + * @return The requested complete appearance. + */ + const TAppearance& Get(const EStyleLayer layer) const + { + switch (layer) + { + case EStyleLayer::Hovered: return Hovered ? *Hovered : Normal; + case EStyleLayer::Pressed: return Pressed ? *Pressed : Normal; + case EStyleLayer::Focused: return Focused ? *Focused : Normal; + case EStyleLayer::Disabled: return Disabled ? *Disabled : Normal; + default: return Normal; + } + } + + private: + TAppearance& GetOrCreate(std::optional& appearance) + { + if (!appearance) + appearance = Normal; + + return *appearance; + } + }; + + /** @brief Base class required for a style stored by StyleSet. */ + struct SStyle + { + virtual ~SStyle() = default; + }; + + /** + * @brief Owns the complete styles used as the application's defaults. + * + * A StyleSet is a typed registry. Widgets copy their registered style when constructed, so + * replacing an entry affects subsequently constructed widgets. SetStyle replaces a widget's + * local copy explicitly. + */ + class ELIXIR_API StyleSet + { + public: + StyleSet() = default; + StyleSet(const StyleSet&) = delete; + StyleSet& operator=(const StyleSet&) = delete; + StyleSet(StyleSet&&) = default; + StyleSet& operator=(StyleSet&&) = default; + + /** + * @brief Store the default style for one component type. + * + * @tparam TStyle Concrete style type, derived from SStyle. + * @param style Complete style to store. + */ + template + void SetWidgetStyle(TStyle style) + { + static_assert(std::is_base_of_v); + m_Styles[std::type_index(typeid(TStyle))] = CreateScope(std::move(style)); + } + + /** + * @brief Get the style registered for one component type. + * @tparam TStyle Concrete style type to retrieve. + * @return The registered complete style, or an empty fallback when it is not registered. + */ + template + const TStyle& GetWidgetStyle() const + { + static_assert(std::is_base_of_v); + const auto it = m_Styles.find(std::type_index(typeid(TStyle))); + if (it == m_Styles.end()) + { + EE_CORE_ERROR("StyleSet has no style for component type {}", typeid(TStyle).name()) + static const TStyle fallback{}; + return fallback; + } + return static_cast(*it->second); + } + + private: + std::unordered_map> m_Styles; + }; + + /** + * @brief Get the application's built-in default styles. + * + * The returned registry supplies styles for subsequently constructed widgets. Applications + * can replace registered styles to update their default look without selecting a named theme. + * Existing widgets keep their copied styles until SetStyle replaces them. + * + * @return The shared default style registry. + */ + ELIXIR_API StyleSet& GetDefaultStyles(); +} diff --git a/Elixir/Source/Engine/GUI/TextBlock.cpp b/Elixir/Source/Engine/GUI/TextBlock.cpp index 953059ad..a35e298e 100644 --- a/Elixir/Source/Engine/GUI/TextBlock.cpp +++ b/Elixir/Source/Engine/GUI/TextBlock.cpp @@ -5,23 +5,26 @@ namespace Elixir::GUI { - TextBlock::TextBlock(const std::string& text) - : m_Text(text) + namespace { - m_Font = FontManager::GetDefaultFont(); - UpdateTextSize(); + std::string GetFirstLine(const std::string& text) + { + return text.substr(0, text.find_first_of("\r\n")); + } } - glm::vec2 TextBlock::ComputeDesiredSize() + TextBlock::TextBlock(const std::string& text) + : m_Text(text), + m_DisplayText(GetFirstLine(text)) { - return m_DesiredSize; + m_Font = FontManager::GetDefaultFont(); } void TextBlock::SetText(const std::string& text) { if (m_Text == text) return; m_Text = text; - UpdateTextSize(); + m_DisplayText = m_Overflow == ETextOverflow::Wrap ? text : GetFirstLine(text); MarkLayoutDirty(); MarkRenderDirty(); // the drawn glyphs change even when geometry does not } @@ -32,7 +35,7 @@ namespace Elixir::GUI if (!font || m_Font == font) return; m_Font = font; - UpdateTextSize(); + m_DisplayText = m_Overflow == ETextOverflow::Wrap ? m_Text : GetFirstLine(m_Text); MarkLayoutDirty(); MarkRenderDirty(); } @@ -47,58 +50,131 @@ namespace Elixir::GUI { if (m_FontSize == size) return; m_FontSize = size; - UpdateTextSize(); + m_DisplayText = m_Overflow == ETextOverflow::Wrap ? m_Text : GetFirstLine(m_Text); MarkLayoutDirty(); MarkRenderDirty(); } - void TextBlock::BuildDrawCommands(RenderBatch& batch, const int zOrder) + void TextBlock::SetOverflow(const ETextOverflow overflow) { - if (!m_Text.empty()) - { - const float availableWidth = m_Geometry.Size.x; - const auto displayText = ProcessText(m_Text, availableWidth); - - batch.AddText( - displayText, - m_Geometry, - m_Font, - m_FontSize, - m_Color, - zOrder - ); - } + if (m_Overflow == overflow) return; + m_Overflow = overflow; + m_DisplayText = overflow == ETextOverflow::Wrap ? m_Text : GetFirstLine(m_Text); + MarkLayoutDirty(); + MarkRenderDirty(); + } + + glm::vec2 TextBlock::ComputeDesiredSize(const glm::vec2& availableSize) + { + if (m_Overflow == ETextOverflow::Wrap) + return UpdateWrappedDisplayText(availableSize.x); + + m_DisplayText = GetFirstLine(m_Text); + + return FontManager::MeasureText(m_DisplayText, m_Font, m_FontSize); } - void TextBlock::UpdateTextSize() + void TextBlock::LayoutChildren(const SRect& allocatedSpace) + { + if (m_Overflow == ETextOverflow::Ellipsis) + m_DisplayText = EllipsizeText(GetFirstLine(m_Text), allocatedSpace.Size.x, false); + else if (m_Overflow == ETextOverflow::Wrap) + UpdateWrappedDisplayText(allocatedSpace.Size.x, allocatedSpace.Size.y); + else + m_DisplayText = ClipText(GetFirstLine(m_Text), allocatedSpace.Size.x); + } + + void TextBlock::BuildDrawCommands(RenderBatch& batch, const int zOrder) { - m_DesiredSize = FontManager::MeasureText(m_Text, m_Font, m_FontSize); + if (m_DisplayText.empty()) return; + batch.AddText( + m_DisplayText, + m_Geometry, + m_Font, + m_FontSize, + m_Color, + zOrder + ); } - std::string TextBlock::ProcessText( + std::string TextBlock::ClipText( const std::string& text, const float availableWidth ) const { - auto size = FontManager::MeasureText(text, m_Font, m_FontSize); - if (size.x <= availableWidth) - return text; + if (availableWidth <= 0.0f) return {}; - std::string ellipsis = "..."; - const float ellipsisWidth = FontManager::MeasureText(ellipsis, m_Font, m_FontSize).x; + std::string clipped = text; + while (!clipped.empty() + && FontManager::MeasureText(clipped, m_Font, m_FontSize).x > availableWidth) + { + UTF8::UTF8RemoveLastChar(clipped); + } + return clipped; + } - if (ellipsisWidth >= availableWidth) - return ellipsis; + std::string TextBlock::EllipsizeText( + const std::string& text, + const float availableWidth, + const bool appendEllipsis + ) const + { + if (!appendEllipsis && FontManager::MeasureText(text, m_Font, m_FontSize).x <= availableWidth) + return text; + + constexpr std::string_view ellipsis = "..."; + const float ellipsisWidth = FontManager::MeasureText(std::string(ellipsis), m_Font, m_FontSize).x; + if (ellipsisWidth > availableWidth) + return {}; std::string truncated = text; while (!truncated.empty()) { UTF8::UTF8RemoveLastChar(truncated); - size = FontManager::MeasureText(truncated, m_Font, m_FontSize); - if (size.x + ellipsisWidth <= availableWidth) - return truncated + ellipsis; + const float truncatedWidth = FontManager::MeasureText(truncated, m_Font, m_FontSize).x; + if (truncatedWidth + ellipsisWidth <= availableWidth) + return truncated + std::string(ellipsis); + } + + return std::string(ellipsis); + } + + glm::vec2 TextBlock::UpdateWrappedDisplayText(const float maxWidth, const float maxHeight) + { + std::vector lines; + FontManager::MeasureWrapped( + m_Text, + m_Font, + m_FontSize, + maxWidth, + &lines + ); + + size_t visibleLineCount = lines.size(); + const float lineHeight = FontManager::GetLineHeight(m_Font, m_FontSize); + if (maxHeight != UnconstrainedSize && lineHeight > 0.0f) + { + visibleLineCount = std::min( + visibleLineCount, + static_cast(std::floor(std::max(0.0f, maxHeight) / lineHeight)) + ); + } + + if (visibleLineCount < lines.size() && visibleLineCount > 0) + lines[visibleLineCount - 1] = EllipsizeText(lines[visibleLineCount - 1], maxWidth, true); + + m_DisplayText.clear(); + float widestLine = 0.0f; + for (size_t i = 0; i < visibleLineCount; ++i) + { + if (i > 0) m_DisplayText += '\n'; + m_DisplayText += lines[i]; + widestLine = std::max( + widestLine, + FontManager::MeasureText(lines[i], m_Font, m_FontSize).x + ); } - return ellipsis; + return { widestLine, lineHeight * visibleLineCount }; } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/TextBlock.h b/Elixir/Source/Engine/GUI/TextBlock.h index 54edaa5a..76e38d86 100644 --- a/Elixir/Source/Engine/GUI/TextBlock.h +++ b/Elixir/Source/Engine/GUI/TextBlock.h @@ -7,13 +7,24 @@ namespace Elixir::GUI { class RenderBatch; + /** + * @brief How TextBlock handles text that does not fit its allocated width. + */ + enum class ETextOverflow + { + /** Truncate the first logical line and append an ellipsis when needed. */ + Ellipsis, + /** Preserve explicit line breaks and wrap additional lines within the allocated rect. */ + Wrap, + /** Truncate the first logical line at the allocated width. */ + Clip, + }; + class ELIXIR_API TextBlock final : public Widget { public: explicit TextBlock(const std::string& text); - glm::vec2 ComputeDesiredSize() override; - const std::string& GetText() const { return m_Text; } void SetText(const std::string& text); @@ -26,17 +37,32 @@ namespace Elixir::GUI float GetFontSize() const { return m_FontSize; } void SetFontSize(float size); + ETextOverflow GetOverflow() const { return m_Overflow; } + void SetOverflow(ETextOverflow overflow); + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; + void LayoutChildren(const SRect& allocatedSpace) override; + void BuildDrawCommands(RenderBatch& batch, int zOrder) override; - void UpdateTextSize(); + std::string ClipText(const std::string& text, float availableWidth) const; + std::string EllipsizeText( + const std::string& text, + float availableWidth, + bool appendEllipsis + ) const; - std::string ProcessText(const std::string& text, float availableWidth) const; + glm::vec2 UpdateWrappedDisplayText(float maxWidth, float maxHeight = UnconstrainedSize); private: std::string m_Text; + std::string m_DisplayText; + SColor m_Color{ 1.0, 1.0, 1.0, 1.0 }; Ref m_Font; float m_FontSize = 16.0f; + ETextOverflow m_Overflow = ETextOverflow::Ellipsis; + }; } diff --git a/Elixir/Source/Engine/GUI/TextField.cpp b/Elixir/Source/Engine/GUI/TextField.cpp index ddf3b138..0df1fae3 100644 --- a/Elixir/Source/Engine/GUI/TextField.cpp +++ b/Elixir/Source/Engine/GUI/TextField.cpp @@ -9,22 +9,35 @@ namespace Elixir::GUI { TextField::TextField(const std::string& text) - : m_Text(text) + : m_Text(text), + m_Style(GetDefaultStyles().GetWidgetStyle()) { m_Font = FontManager::GetDefaultFont(); - m_DesiredSize = { 120.0f, 30.0f }; m_CursorPosition = m_Text.size(); + SetFocusable(true); + + SetCursorColor(SColor{ 0.8941f, 0.8941f, 0.9059f, 1.0f }); + SetPlaceholderColor(SColor{ 0.6314f, 0.6314f, 0.6667f, 1.0f }); + SetSelectionColor(SColor{ 0.6314f, 0.6314f, 0.6667f, 0.35f }); } - void TextField::Update(const Timestep frameTime) + void TextField::SetStyle(const STextFieldStyle& style) { - Widget::Update(frameTime); - UpdateCursorState(frameTime); + m_Style = style; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void TextField::SetTextColor(const EStyleLayer layer, const SColor& color) + { + m_Style.Get(layer).Foreground = color; + MarkRenderDirty(); } - glm::vec2 TextField::ComputeDesiredSize() + void TextField::Update(const Timestep frameTime) { - return m_DesiredSize; + Widget::Update(frameTime); + UpdateCursorState(frameTime); } void TextField::SetFont(const Ref& font) @@ -34,6 +47,7 @@ namespace Elixir::GUI m_Font = font; UpdateScrollOffset(); + MarkLayoutDirty(); MarkRenderDirty(); } @@ -50,12 +64,7 @@ namespace Elixir::GUI m_CursorPosition = m_Text.size(); ClearSelection(); UpdateScrollOffset(); - MarkRenderDirty(); - } - - void TextField::SetTextColor(const SColor& color) - { - m_TextColor = color; + MarkLayoutDirty(); MarkRenderDirty(); } @@ -74,43 +83,35 @@ namespace Elixir::GUI void TextField::SetPadding(const SPadding& padding) { m_Padding = padding; + MarkLayoutDirty(); MarkRenderDirty(); } - void TextField::SetCornerRadius(const glm::vec4& radius) + void TextField::SetCursorColor(const SColor& color) { - m_CornerRadius = radius; + m_CursorColor = color; MarkRenderDirty(); } - void TextField::SetBackgroundColor(const SColor& color) + void TextField::SetSelectionColor(const SColor& color) { - m_BackgroundColor = color; + m_SelectionColor = color; MarkRenderDirty(); } - void TextField::SetBackgroundBorders(const glm::vec4& borders) + glm::vec2 TextField::ComputeDesiredSize(const glm::vec2& availableSize) { - m_BackgroundBorders = borders; - MarkRenderDirty(); - } + glm::vec2 contentSize{ 0.0f, 0.0f }; - void TextField::SetBackground(const Ref& texture) - { - m_Background = texture; - MarkRenderDirty(); - } + if (!m_Text.empty()) + contentSize = MeasureTextSize(m_Text); - void TextField::SetCursorColor(const SColor& color) - { - m_CursorColor = color; - MarkRenderDirty(); - } + const glm::vec2 desiredSize = contentSize + glm::vec2( + m_Padding.GetTotalHorizontal(), + m_Padding.GetTotalVertical() + ); - void TextField::SetSelectionColor(const SColor& color) - { - m_SelectionColor = color; - MarkRenderDirty(); + return glm::max(desiredSize, m_MinDesiredSize); } void TextField::LayoutChildren(const SRect&) @@ -120,29 +121,8 @@ namespace Elixir::GUI void TextField::BuildDrawCommands(RenderBatch& batch, const int zOrder) { - // Background - if (m_Background) - { - batch.AddTexture( - m_Background, - m_Geometry, - m_BackgroundBorders, - m_BackgroundColor, - zOrder - ); - } - else - { - batch.AddRect( - m_Geometry, - m_BackgroundColor, - m_CornerRadius, - m_InsetShadow, - m_DropShadow, - m_Outline, - zOrder - ); - } + const auto& appearance = (const STextFieldAppearance&)GetResolvedAppearance(); + batch.AddBrush(appearance.Background, m_Geometry, zOrder); const auto textSize = MeasureTextSize(m_Text); const auto textPos = CalculateTextPosition(textSize); @@ -177,7 +157,7 @@ namespace Elixir::GUI { textPos, textSize }, m_Font, m_FontSize, - m_TextColor, + appearance.Foreground, zOrder + 2, m_Geometry ); @@ -218,6 +198,16 @@ namespace Elixir::GUI } } + const SAppearance& TextField::GetResolvedAppearance() const + { + return m_Style.Resolve(GetInteractionState()); + } + + SBrush& TextField::GetMutableBackgroundBrush(const EStyleLayer layer) + { + return m_Style.Get(layer).Background; + } + void TextField::HandleMouseEnter() { Widget::HandleMouseEnter(); @@ -230,9 +220,20 @@ namespace Elixir::GUI Platform::Get().SetPreviousCursorShape(); } - void TextField::HandleMouseDown(const MouseButtonPressedEvent& event) + SInputReply TextField::HandleMouseDown(const MouseButtonPressedEvent& event) { - Widget::HandleMouseDown(event); + if (!IsEnabled()) + return SInputReply::Unhandled(); + + // TextField is unconditionally interactive: + // it must not depend on m_On*Callback being set, so it sets the press state itself + // instead of delegating to Widget::HandleMouseDown. Without this, m_Pressed would + // stay false (nothing here ever registers an OnClick/OnMouseDown/OnMouseUp callback + // on the field itself), and the drag-select below - gated on IsPressed() - would + // never engage. + m_Pressed = true; + MarkRenderDirty(); + if (m_OnMouseDownCallback) m_OnMouseDownCallback(); const auto x = event.GetX() - m_Geometry.Position.x - m_Padding.Left + m_ScrollOffset; m_CursorPosition = GetCharIndexAtX(m_Text, x); @@ -241,14 +242,19 @@ namespace Elixir::GUI ResetCursorState(); UpdateScrollOffset(); + + // Capture: keep receiving move events while dragging a selection past the field's + // own bounds, and guarantee a matching HandleMouseUp to clear m_Pressed on release. + return SInputReply::HandledAndCaptured(); } - void TextField::HandleMouseMove(const MouseMovedEvent& event) + SInputReply TextField::HandleMouseMove(const MouseMovedEvent& event) { Widget::HandleMouseMove(event); // Only extend selection if mouse button is held (widget is pressed) - if (!IsPressed()) return; + if (!IsPressed()) + return SInputReply::Unhandled(); const auto x = event.GetX() - m_Geometry.Position.x - m_Padding.Left + m_ScrollOffset; m_CursorPosition = GetCharIndexAtX(m_Text, x); @@ -256,79 +262,80 @@ namespace Elixir::GUI UpdateScrollOffset(); MarkRenderDirty(); + return SInputReply::Handled(); } - void TextField::HandleKeyPressed(const KeyPressedEvent& event) + SInputReply TextField::HandleKeyPressed(const KeyPressedEvent& event) { Widget::HandleKeyPressed(event); - if (!m_Focused) return; + const auto handled = [this] + { + MarkRenderDirty(); + return SInputReply::Handled(); + }; switch (event.GetKeyCode()) { - case EE_KEY_LEFT: - if (event.IsShiftPressed()) - { - if (m_SelectionStart == -1) m_SelectionStart = m_CursorPosition; - MoveCursorLeft(); - SelectText(m_SelectionStart, m_CursorPosition); - } - else - { - ClearSelection(); - MoveCursorLeft(); - } - break; - case EE_KEY_RIGHT: - if (event.IsShiftPressed()) - { - if (m_SelectionStart == -1) m_SelectionStart = m_CursorPosition; - MoveCursorRight(); - SelectText(m_SelectionStart, m_CursorPosition); - } - else - { - ClearSelection(); - MoveCursorRight(); - } - break; - case EE_KEY_HOME: - MoveCursorToStart(); - break; - case EE_KEY_END: - MoveCursorToEnd(); - break; - case EE_KEY_BACKSPACE: - ClearPreviousCharacter(); - break; - case EE_KEY_DELETE: - ClearNextCharacter(); - break; - case EE_KEY_A: - if (event.IsCtrlPressed()) - SelectWholeText(); - break; - case EE_KEY_C: - if (event.IsCtrlPressed()) - CopyToClipboard(m_Text); - break; - case EE_KEY_V: - if (event.IsCtrlPressed()) - InsertText(GetFromClipboard()); - break; - default: - break; + case EE_KEY_LEFT: + if (event.IsShiftPressed()) + { + if (m_SelectionStart == -1) m_SelectionStart = m_CursorPosition; + MoveCursorLeft(); + SelectText(m_SelectionStart, m_CursorPosition); + } + else + { + ClearSelection(); + MoveCursorLeft(); + } + return handled(); + case EE_KEY_RIGHT: + if (event.IsShiftPressed()) + { + if (m_SelectionStart == -1) m_SelectionStart = m_CursorPosition; + MoveCursorRight(); + SelectText(m_SelectionStart, m_CursorPosition); + } + else + { + ClearSelection(); + MoveCursorRight(); + } + return handled(); + case EE_KEY_HOME: + MoveCursorToStart(); + return handled(); + case EE_KEY_END: + MoveCursorToEnd(); + return handled(); + case EE_KEY_BACKSPACE: + ClearPreviousCharacter(); + return handled(); + case EE_KEY_DELETE: + ClearNextCharacter(); + return handled(); + case EE_KEY_A: + if (!event.IsCtrlPressed()) return SInputReply::Unhandled(); + SelectWholeText(); + return handled(); + case EE_KEY_C: + if (!event.IsCtrlPressed()) return SInputReply::Unhandled(); + CopyToClipboard(m_Text); + return handled(); + case EE_KEY_V: + if (!event.IsCtrlPressed()) return SInputReply::Unhandled(); + InsertText(GetFromClipboard()); + return handled(); + default: + return SInputReply::Unhandled(); } - - MarkRenderDirty(); } - void TextField::HandleKeyTyped(const KeyTypedEvent& event) + SInputReply TextField::HandleKeyTyped(const KeyTypedEvent& event) { Widget::HandleKeyTyped(event); - if (!m_Focused) return; - ResetCursorState(); // Insert UTF-8 character at cursor position @@ -336,6 +343,7 @@ namespace Elixir::GUI InsertText(c); MarkRenderDirty(); + return SInputReply::Handled(); } void TextField::HandleFocus() @@ -347,6 +355,7 @@ namespace Elixir::GUI void TextField::HandleLostFocus() { Widget::HandleLostFocus(); + ClearSelection(); m_CursorVisible = false; } @@ -498,6 +507,7 @@ namespace Elixir::GUI m_Text.insert(m_CursorPosition, text); m_CursorPosition += text.size(); UpdateScrollOffset(); + MarkLayoutDirty(); // Fire input changed callback if (m_OnChangeCallback) m_OnChangeCallback(m_Text); @@ -527,6 +537,7 @@ namespace Elixir::GUI } UpdateScrollOffset(); + MarkLayoutDirty(); // Fire input changed callback if (m_OnChangeCallback) m_OnChangeCallback(m_Text); @@ -548,6 +559,7 @@ namespace Elixir::GUI } UpdateScrollOffset(); + MarkLayoutDirty(); // Fire input changed callback if (m_OnChangeCallback) m_OnChangeCallback(m_Text); @@ -570,4 +582,4 @@ namespace Elixir::GUI return text; } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/TextField.h b/Elixir/Source/Engine/GUI/TextField.h index 4aca0f1d..6ef865d5 100644 --- a/Elixir/Source/Engine/GUI/TextField.h +++ b/Elixir/Source/Engine/GUI/TextField.h @@ -5,14 +5,31 @@ namespace Elixir::GUI { + /** @brief Text-field visual data for one interactive state. */ + struct STextFieldAppearance : SAppearance + { + SColor Foreground{}; + }; + + /** @brief Complete visual style for TextField. */ + struct STextFieldStyle final : SStyle, TStateStyles{}; + class ELIXIR_API TextField : public Widget { public: + /** + * @brief Construct a text field with a copy of the current default text-field style. + * @param text Initial text. + */ explicit TextField(const std::string& text = ""); - void Update(Timestep frameTime) override; + /** + * @brief Replace this text field's complete style. + * @param style Style to copy. + */ + void SetStyle(const STextFieldStyle& style); - glm::vec2 ComputeDesiredSize() override; + void Update(Timestep frameTime) override; /* Callbacks */ @@ -30,8 +47,12 @@ namespace Elixir::GUI const std::string& GetText() const { return m_Text; } void SetText(const std::string& text); - SColor GetTextColor() const { return m_TextColor; } - void SetTextColor(const SColor& color); + /** + * @brief Set one legacy layer's text color. + * @param layer Layer to change. + * @param color New text color. + */ + void SetTextColor(EStyleLayer layer, const SColor& color); const std::string& GetPlaceholder() const { return m_Placeholder; } void SetPlaceholder(const std::string& placeholder); @@ -42,55 +63,31 @@ namespace Elixir::GUI SPadding GetPadding() const { return m_Padding; } void SetPadding(const SPadding& padding); - /** - * Get corner radius for each corner individually. - * @return vector (top-left, top-right, bottom-right, bottom-left) - */ - glm::vec4 GetCornerRadius() const { return m_CornerRadius; } - - /** - * Set the same radius for all corners. - * @param radius corner radius in pixels - */ - void SetCornerRadius(const float radius) - { - SetCornerRadius({ radius, radius, radius, radius }); - } - - /** - * Set a radius for each corner individually. - * @param radius vector (top-left, top-right, bottom-right, bottom-left) - */ - void SetCornerRadius(const glm::vec4& radius); - - SColor GetBackgroundColor() const { return m_BackgroundColor; } - void SetBackgroundColor(const SColor& color); - - const glm::vec4& GetBackgroundBorders() const { return m_BackgroundBorders; } - void SetBackgroundBorders(const glm::vec4& borders); - - const Ref& GetBackground() const { return m_Background; } - void SetBackground(const Ref& texture); - SColor GetCursorColor() const { return m_CursorColor; } void SetCursorColor(const SColor& color); SColor GetSelectionColor() const { return m_SelectionColor; } void SetSelectionColor(const SColor& color); - protected: + bool CanHandleMouseInput() const override { return IsEnabled(); } + + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; void LayoutChildren(const SRect& allocatedSpace) override; void BuildDrawCommands(RenderBatch& batch, int zOrder) override; void HandleMouseEnter() override; void HandleMouseLeave() override; - void HandleMouseDown(const MouseButtonPressedEvent& event) override; - void HandleMouseMove(const MouseMovedEvent& event) override; - void HandleKeyPressed(const KeyPressedEvent& event) override; - void HandleKeyTyped(const KeyTypedEvent& event) override; + SInputReply HandleMouseDown(const MouseButtonPressedEvent& event) override; + SInputReply HandleMouseMove(const MouseMovedEvent& event) override; + SInputReply HandleKeyPressed(const KeyPressedEvent& event) override; + SInputReply HandleKeyTyped(const KeyTypedEvent& event) override; void HandleFocus() override; void HandleLostFocus() override; + const SAppearance& GetResolvedAppearance() const override; + SBrush& GetMutableBackgroundBrush(EStyleLayer layer) override; + virtual glm::vec2 MeasureTextSize(const std::string& text); virtual glm::vec2 CalculateTextPosition(glm::vec2 textSize); @@ -124,26 +121,12 @@ namespace Elixir::GUI float m_FontSize = 16.0f; std::string m_Text; - SColor m_TextColor{0.0f, 0.0f, 0.0f, 1.0f}; std::string m_Placeholder; SColor m_PlaceholderColor{0.3f, 0.3f, 0.3f, 1.0f}; SPadding m_Padding = { 5.0f, 5.0f, 5.0f, 5.0f }; - // top-left, top-right, bottom-right, bottom-left - glm::vec4 m_CornerRadius = {0.0f, 0.0f, 0.0f, 0.0f}; - - // Colors for different states - SColor m_BackgroundColor{1.0f, 1.0f, 1.0f, 1.0f}; - - // When texture is used, this represents the borders of 9-patch texture. - // Border mapping = (left, top, right, bottom). - glm::vec4 m_BackgroundBorders = {30.0f, 30.0f, 30.0f, 30.0f}; - - // Textures for different states - Ref m_Background; - // Cursor blinking related stuff float m_BlinkTimer = 0.0f; float m_BlinkInterval = 0.5f; // Cursor blink interval in seconds @@ -159,7 +142,10 @@ namespace Elixir::GUI size_t m_SelectionEnd = -1; SColor m_SelectionColor = { 0.3f, 0.5f, 1.0f, 0.4f }; + glm::vec2 m_MinDesiredSize{ 120.0f, 30.0f }; + STextFieldStyle m_Style; + // Callbacks std::function m_OnChangeCallback; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/Util/Interpolation.h b/Elixir/Source/Engine/GUI/Util/Interpolation.h new file mode 100644 index 00000000..a7c949aa --- /dev/null +++ b/Elixir/Source/Engine/GUI/Util/Interpolation.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +namespace Elixir::GUI::Util +{ + /** @brief Interpolate two scalar style values. */ + inline float Interpolate(float from, float to, float amount) + { + return glm::mix(from, to, amount); + } + + /** @brief Interpolate two two-dimensional style values. */ + inline glm::vec2 Interpolate(const glm::vec2& from, const glm::vec2& to, float amount) + { + return glm::mix(from, to, amount); + } + + /** @brief Interpolate two four-dimensional style values. */ + inline glm::vec4 Interpolate(const glm::vec4& from, const glm::vec4& to, float amount) + { + return glm::mix(from, to, amount); + } + + /** @brief Interpolate two colors. */ + inline SColor Interpolate(const SColor& from, const SColor& to, float amount) + { + return { + Interpolate(from.R, to.R, amount), + Interpolate(from.G, to.G, amount), + Interpolate(from.B, to.B, amount), + Interpolate(from.A, to.A, amount), + }; + } + + /** @brief Interpolate an outline's color and thickness. */ + inline SOutline Interpolate(const SOutline& from, const SOutline& to, float amount) + { + return { Interpolate(from.Color, to.Color, amount), Interpolate(from.Thickness, to.Thickness, amount) }; + } + + /** @brief Interpolate the scalar and color properties of a brush. */ + inline SBrush Interpolate(const SBrush& from, const SBrush& to, float amount) + { + return { + .Color = Interpolate(from.Color, to.Color, amount), + .Texture = amount < 0.5f ? from.Texture : to.Texture, + .Borders = Interpolate(from.Borders, to.Borders, amount), + .CornerRadius = Interpolate(from.CornerRadius, to.CornerRadius, amount), + .Outline = Interpolate(from.Outline, to.Outline, amount), + .InsetShadow = Interpolate(from.InsetShadow, to.InsetShadow, amount), + .DropShadow = Interpolate(from.DropShadow, to.DropShadow, amount), + }; + } +} diff --git a/Elixir/Source/Engine/GUI/VerticalBox.cpp b/Elixir/Source/Engine/GUI/VerticalBox.cpp index eb9fbce7..669113ff 100644 --- a/Elixir/Source/Engine/GUI/VerticalBox.cpp +++ b/Elixir/Source/Engine/GUI/VerticalBox.cpp @@ -3,48 +3,57 @@ namespace Elixir::GUI { - LayoutSlot& VerticalBox::AddChild(const Ref& child) + glm::vec2 VerticalBox::ComputeDesiredSize(const glm::vec2& availableSize) { - const auto slot = CreateRef(child); - m_Slots.push_back(slot); - AttachChild(child); - return *slot; - } + const glm::vec2 innerAvailable = { + availableSize.x - m_Padding.GetTotalHorizontal(), + UnconstrainedSize + }; - void VerticalBox::SetStretching(const bool stretching) - { - if (m_Stretching == stretching) return; - m_Stretching = stretching; - MarkLayoutDirty(); - } - - glm::vec2 VerticalBox::ComputeDesiredSize() - { glm::vec2 totalSize = { 0, 0 }; - for (auto& slot : m_Slots) + for (const auto& slot : m_Slots) { - const auto layoutSlot = std::static_pointer_cast(slot); + if (!slot->GetWidget()->TakesSpace()) continue; - auto childSize = slot->GetWidget()->ComputeDesiredSize(); - const auto margin = layoutSlot->GetMargin(); + const auto margin = slot->GetMargin(); + const auto sizeRule = slot->GetSizeRule(); + const auto minSize = slot->GetMinSize(); + const auto maxSize = slot->GetMaxSize(); - // Add margin + const glm::vec2 childConstraint = { + innerAvailable.x - margin.GetTotalHorizontal(), + innerAvailable.y + }; + + auto childSize = slot->GetWidget()->Measure(childConstraint); childSize.x += margin.GetTotalHorizontal(); - childSize.y += margin.GetTotalVertical(); // Width is the maximum totalSize.x = std::max(totalSize.x, childSize.x); - // Height accumulates - totalSize.y += childSize.y; + // Height accumulates using the same rule and main-axis constraints as layout. + // Fill has no measured intrinsic height, but its minimum and margins still + // reserve space because LayoutChildren enforces that minimum. + switch (sizeRule.Rule) + { + case SSizeParam::ERule::Fill: + totalSize.y += minSize.y + margin.GetTotalVertical(); + break; + case SSizeParam::ERule::Fixed: + totalSize.y += std::max(minSize.y, std::min(maxSize.y, sizeRule.Value)) + margin.GetTotalVertical(); + break; + case SSizeParam::ERule::Auto: + default: + totalSize.y += std::max(minSize.y, std::min(maxSize.y, childSize.y)) + margin.GetTotalVertical(); + break; + } } // Add panel padding totalSize.x += m_Padding.GetTotalHorizontal(); totalSize.y += m_Padding.GetTotalVertical(); - m_DesiredSize = totalSize; return totalSize; } @@ -53,71 +62,121 @@ namespace Elixir::GUI // Calculate available space after padding const SRect innerSpace = ApplyPadding(allocatedSpace, m_Padding); - // First: calculate fixed sizes - float usedSpace = 0.0f; + // Measure every child exactly once, with its real constraint, and reuse the result in + // both loops below. Fill/Fixed children still get measured on the cross axis (width) + // - their main-axis (height) entry is only actually used below for Auto children. + // Keep measurements indexed by slot. Collapsed children are skipped by both later + // passes, but they still need an unused entry so a preceding collapsed slot cannot + // shift the measurement belonging to a visible sibling. + std::vector childSizes(m_Slots.size()); - for (auto& slot : m_Slots) + for (size_t i = 0; i < m_Slots.size(); ++i) { - const auto layoutSlot = std::static_pointer_cast(slot); + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; - const auto margin = layoutSlot->GetMargin(); - const auto vAlignment = layoutSlot->GetVerticalAlignment(); + const auto margin = slot->GetMargin(); - if (m_Stretching) - { - const glm::vec2 childSize = slot->GetWidget()->ComputeDesiredSize(); - usedSpace += childSize.y + margin.GetTotalVertical(); - } - } + const glm::vec2 childConstraint = { + innerSpace.Size.x - margin.GetTotalHorizontal(), + UnconstrainedSize + }; - // Calculate space available for fill slots - const float availableForFill = std::max(0.0f, innerSpace.Size.y - usedSpace); + childSizes[i] = slot->GetWidget()->Measure(childConstraint); + } - // Second: Arrange children - float currentY = innerSpace.Position.y; + // First reserve every constrained size. Fill slots start at their minimum so those + // constraints cannot make the final arrangement overflow after space is divided. + std::vector childHeights(m_Slots.size()); + float occupiedSpace = 0.0f; - for (auto& slot : m_Slots) + for (size_t i = 0; i < m_Slots.size(); ++i) { - const auto layoutSlot = std::static_pointer_cast(slot); + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; - const glm::vec2 childSize = slot->GetWidget()->ComputeDesiredSize(); - const auto margin = layoutSlot->GetMargin(); - const auto hAlignment = layoutSlot->GetHorizontalAlignment(); - const auto vAlignment = layoutSlot->GetVerticalAlignment(); - const auto fillRatio = layoutSlot->GetFillRatio(); - const auto minSize = layoutSlot->GetMinSize(); - const auto maxSize = layoutSlot->GetMaxSize(); + const auto margin = slot->GetMargin(); + const auto sizeRule = slot->GetSizeRule(); + const auto minSize = slot->GetMinSize(); + const auto maxSize = slot->GetMaxSize(); - // Calculate child height - float childHeight; - - if (m_Stretching && fillRatio > 0.0f) - { - // Proportional fill - childHeight = availableForFill * fillRatio - margin.GetTotalVertical(); - } - else + switch (sizeRule.Rule) { - // Use the desired height - childHeight = childSize.y; + case SSizeParam::ERule::Fill: + childHeights[i] = minSize.y; + break; + case SSizeParam::ERule::Fixed: + childHeights[i] = std::max(minSize.y, std::min(maxSize.y, sizeRule.Value)); + break; + case SSizeParam::ERule::Auto: + default: + childHeights[i] = std::max(minSize.y, std::min(maxSize.y, childSizes[i].y)); + break; } - // Clamp to min/max constraints - childHeight = std::max(minSize.y, std::min(maxSize.y, childHeight)); - - // Calculate child width based on alignment - float childWidth; + occupiedSpace += childHeights[i] + margin.GetTotalVertical(); + } - if (m_Stretching) + // Divide the remaining space by Fill ratio. A slot that reaches MaxSize is removed + // from later rounds, so its unused share is redistributed to its siblings. + float remainingSpace = std::max(0.0f, innerSpace.Size.y - occupiedSpace); + while (remainingSpace > 0.0f) + { + float activeFillRatio = 0.0f; + for (size_t i = 0; i < m_Slots.size(); ++i) { - childWidth = innerSpace.Size.x - margin.GetTotalHorizontal(); + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto sizeRule = slot->GetSizeRule(); + if (sizeRule.Rule != SSizeParam::ERule::Fill || sizeRule.Value <= 0.0f) continue; + if (childHeights[i] >= slot->GetMaxSize().y) continue; + + activeFillRatio += sizeRule.Value; } - else + + if (activeFillRatio <= 0.0f) break; + + float distributedSpace = 0.0f; + for (size_t i = 0; i < m_Slots.size(); ++i) { - childWidth = childSize.x; + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto sizeRule = slot->GetSizeRule(); + if (sizeRule.Rule != SSizeParam::ERule::Fill || sizeRule.Value <= 0.0f) continue; + + const float capacity = slot->GetMaxSize().y - childHeights[i]; + if (capacity <= 0.0f) continue; + + const float share = remainingSpace * (sizeRule.Value / activeFillRatio); + const float addedSize = std::min(share, capacity); + childHeights[i] += addedSize; + distributedSpace += addedSize; } - childWidth = std::max(minSize.x, std::min(maxSize.x, childWidth)); + if (distributedSpace <= 0.0f) break; + remainingSpace -= distributedSpace; + } + + // Second: Arrange children + float currentY = innerSpace.Position.y; + + for (size_t i = 0; i < m_Slots.size(); ++i) + { + const auto& slot = m_Slots[i]; + if (!slot->GetWidget()->TakesSpace()) continue; + + const auto margin = slot->GetMargin(); + const auto hAlignment = slot->GetHorizontalAlignment(); + const auto minSize = slot->GetMinSize(); + const auto maxSize = slot->GetMaxSize(); + + const float childHeight = childHeights[i]; + + // Clamp the desired width; EHorizontalAlignment::Fill overrides it below with the + // full available width regardless of this value (see Widget::AlignHorizontally). + const float childWidth = std::max(minSize.x, std::min(maxSize.x, childSizes[i].x)); // Create available space for this child SRect childAvailableSpace; @@ -142,4 +201,4 @@ namespace Elixir::GUI currentY += childHeight + margin.GetTotalVertical(); } } -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/VerticalBox.h b/Elixir/Source/Engine/GUI/VerticalBox.h index 738ed985..afa0bdb6 100644 --- a/Elixir/Source/Engine/GUI/VerticalBox.h +++ b/Elixir/Source/Engine/GUI/VerticalBox.h @@ -4,18 +4,10 @@ namespace Elixir::GUI { - class ELIXIR_API VerticalBox final : public Panel + class ELIXIR_API VerticalBox final : public TPanel { - public: - LayoutSlot& AddChild(const Ref& child); - - bool IsStretching() const { return m_Stretching; } - void SetStretching(bool stretching); - - protected: - glm::vec2 ComputeDesiredSize() override; + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override; void LayoutChildren(const SRect& allocatedSpace) override; - - bool m_Stretching = false; }; } \ No newline at end of file diff --git a/Elixir/Source/Engine/GUI/Widget.cpp b/Elixir/Source/Engine/GUI/Widget.cpp index 3a2d3a3c..858cdd76 100644 --- a/Elixir/Source/Engine/GUI/Widget.cpp +++ b/Elixir/Source/Engine/GUI/Widget.cpp @@ -7,6 +7,36 @@ namespace Elixir::GUI { /* Widget */ + Widget::Widget() + : m_Style(GetDefaultStyles().GetWidgetStyle()) {} + + const glm::vec2& Widget::Measure(const glm::vec2& availableSize) + { + if (!m_MeasureDirty && m_LastMeasureConstraint == availableSize) + return m_DesiredSize; + + // Border-box sizing: the outline is drawn INSET, in the outer band of this widget's + // own geometry (see applyOutline in GUI.ps.hlsl), so - like a CSS border, unlike a + // CSS outline - it has to be budgeted for here rather than left to bleed past + // whatever ComputeDesiredSize reports. A leaf that wants a 10x10 content+padding box + // with a 1px outline must actually occupy 12x12, or the outline eats into its own + // content instead of wrapping around it. Subtracting first gives ComputeDesiredSize + // the real content budget when this widget is itself content-constrained; adding + // back after is a no-op with an UnconstrainedSize (infinity - 2 is still infinity) or + // when there's no outline at all (the common case). + const float outlineSpace = GetResolvedAppearance().Background.Outline.Thickness * 2.0f; + const glm::vec2 innerAvailable = { + std::max(0.0f, availableSize.x - outlineSpace), + std::max(0.0f, availableSize.y - outlineSpace) + }; + + m_DesiredSize = ComputeDesiredSize(innerAvailable) + glm::vec2(outlineSpace, outlineSpace); + m_LastMeasureConstraint = availableSize; + m_MeasureDirty = false; + + return m_DesiredSize; + } + void Widget::ArrangeChildren(const SRect& allocatedSpace) { if (!m_LayoutDirty && m_LastArrangedSpace == allocatedSpace) @@ -22,11 +52,65 @@ namespace Elixir::GUI m_LayoutDirty = false; } + void Widget::HitTest( + const glm::vec2& point, + std::vector>& path, + const SRect& clipRect + ) + { + // HitTestInvisible prunes this whole branch (neither this widget nor its children can + // be hit); Hidden/Collapsed are not rendered/laid out, so neither should be clickable. + if (m_Visibility == EVisibility::HitTestInvisible || + m_Visibility == EVisibility::Hidden || + m_Visibility == EVisibility::Collapsed) + return; + + if (clipRect.IsValid() && !clipRect.Contains(point)) + return; + + const SRect childClipRect = ClipsChildren() + ? (clipRect.IsValid() ? SRect::Intersect(m_Geometry, clipRect) : m_Geometry) + : clipRect; + + // Children sit above their parent z (see CollectDrawCommands' pre-order zCursor): + // test the topmost child first and recurse depth-first, so the first branch that + // reports a hit wins. + for (size_t i = GetChildCount(); i-- > 0;) + { + if (const Ref child = GetChildAt(i)) + { + const size_t sizeBefore = path.size(); + child->HitTest(point, path, childClipRect); + + if (path.size() > sizeBefore) + { + // SelfHitTestInvisible: this widget does not join the path, but the + // matched child (already appended by the recursive call) still does. + if (IsSelfHitTestVisible()) + path.insert(path.begin() + sizeBefore, shared_from_this()); + + return; + } + } + } + + // No child matched; this widget itself is the candidate. + if (IsSelfHitTestVisible() && HitTestSelf(point)) + path.push_back(shared_from_this()); + } + void Widget::SetOpacity(const float opacity) { if (m_Opacity == opacity) return; m_Opacity = opacity; - MarkRenderDirty(); + ++s_DirtyEpoch; + } + + void Widget::SetRenderOffset(const glm::vec2& offset) + { + if (m_RenderOffset == offset) return; + m_RenderOffset = offset; + ++s_DirtyEpoch; } void Widget::SetVisibility(const EVisibility visibility) @@ -41,71 +125,180 @@ namespace Elixir::GUI return m_Visibility == EVisibility::Visible && m_Opacity > 0.0f; } - void Widget::SetInsetShadow(const glm::vec4& shadow) + bool Widget::IsRenderVisible() const + { + return (m_Visibility == EVisibility::Visible || + m_Visibility == EVisibility::HitTestInvisible || + m_Visibility == EVisibility::SelfHitTestInvisible) && + m_Opacity > 0.0f; + } + + bool Widget::TakesSpace() const + { + return m_Visibility != EVisibility::Collapsed; + } + + bool Widget::IsSelfHitTestVisible() const + { + return m_Visibility == EVisibility::Visible; + } + + bool Widget::CanHandleMouseInput() const + { + return m_Enabled && ( + m_OnMouseEnterCallback || + m_OnMouseLeaveCallback || + m_OnMouseDownCallback || + m_OnMouseUpCallback || + m_OnClickCallback + ); + } + + const SWidgetStyle& Widget::GetStyle() const + { + return m_Style; + } + + void Widget::SetStyle(const SWidgetStyle& style) + { + m_Style = style; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + SBrush& Widget::GetMutableBackgroundBrush(const EStyleLayer layer) + { + return m_Style.Get(layer).Background; + } + + void Widget::SetBackgroundColor(const EStyleLayer layer, const SColor& color) + { + GetMutableBackgroundBrush(layer).Color = color; + MarkRenderDirty(); + } + + void Widget::SetBackgroundTexture(const EStyleLayer layer, const Ref& texture) + { + GetMutableBackgroundBrush(layer).Texture = texture; + MarkRenderDirty(); + } + + void Widget::ClearBackgroundTexture(const EStyleLayer layer) + { + GetMutableBackgroundBrush(layer).Texture.reset(); + MarkRenderDirty(); + } + + void Widget::SetBackgroundBorders(const EStyleLayer layer, const glm::vec4& borders) + { + GetMutableBackgroundBrush(layer).Borders = borders; + MarkRenderDirty(); + } + + void Widget::SetCornerRadius(const EStyleLayer layer, const glm::vec4& radius) { - m_InsetShadow = shadow; + GetMutableBackgroundBrush(layer).CornerRadius = radius; MarkRenderDirty(); } - void Widget::SetInsetShadowOffset(const glm::vec2& offset) + void Widget::SetInsetShadow(const EStyleLayer layer, const glm::vec4& shadow) { - m_InsetShadow.x = offset.x; - m_InsetShadow.y = offset.y; + GetMutableBackgroundBrush(layer).InsetShadow = shadow; MarkRenderDirty(); } - void Widget::SetInsetShadowBlur(const float blur) + void Widget::SetInsetShadowOffset(const EStyleLayer layer, const glm::vec2& offset) { - m_InsetShadow.z = blur; + auto& shadow = GetMutableBackgroundBrush(layer).InsetShadow; + shadow = { offset, shadow.z, shadow.w }; MarkRenderDirty(); } - void Widget::SetInsetShadowIntensity(const float intensity) + void Widget::SetInsetShadowBlur(const EStyleLayer layer, const float blur) { - m_InsetShadow.w = intensity; + auto& shadow = GetMutableBackgroundBrush(layer).InsetShadow; + shadow.z = blur; MarkRenderDirty(); } - void Widget::SetDropShadow(const glm::vec4& shadow) + void Widget::SetInsetShadowIntensity(const EStyleLayer layer, const float intensity) { - m_DropShadow = shadow; + auto& shadow = GetMutableBackgroundBrush(layer).InsetShadow; + shadow.w = intensity; MarkRenderDirty(); } - void Widget::SetDropShadowOffset(const glm::vec2& offset) + void Widget::SetDropShadow(const EStyleLayer layer, const glm::vec4& shadow) { - m_DropShadow.x = offset.x; - m_DropShadow.y = offset.y; + GetMutableBackgroundBrush(layer).DropShadow = shadow; MarkRenderDirty(); } - void Widget::SetDropShadowBlur(const float blur) + void Widget::SetDropShadowOffset(const EStyleLayer layer, const glm::vec2& offset) { - m_DropShadow.z = blur; + auto& shadow = GetMutableBackgroundBrush(layer).DropShadow; + shadow = { offset, shadow.z, shadow.w }; MarkRenderDirty(); } - void Widget::SetDropShadowIntensity(const float intensity) + void Widget::SetDropShadowBlur(const EStyleLayer layer, const float blur) { - m_DropShadow.w = intensity; + GetMutableBackgroundBrush(layer).DropShadow.z = blur; MarkRenderDirty(); } - void Widget::SetOutline(const SOutline& outline) + void Widget::SetDropShadowIntensity(const EStyleLayer layer, const float intensity) { - m_Outline = outline; + GetMutableBackgroundBrush(layer).DropShadow.w = intensity; MarkRenderDirty(); } - void Widget::SetOutlineColor(const SColor& color) + void Widget::SetOutline(const EStyleLayer layer, const SOutline& outline) { - m_Outline.Color = color; + GetMutableBackgroundBrush(layer).Outline = outline; + MarkLayoutDirty(); MarkRenderDirty(); } - void Widget::SetOutlineThickness(const float thickness) + void Widget::SetOutlineColor(const EStyleLayer layer, const SColor& color) { - m_Outline.Thickness = thickness; + GetMutableBackgroundBrush(layer).Outline.Color = color; + MarkRenderDirty(); + } + + void Widget::SetOutlineThickness(const EStyleLayer layer, const float thickness) + { + GetMutableBackgroundBrush(layer).Outline.Thickness = thickness; + MarkLayoutDirty(); + MarkRenderDirty(); + } + + void Widget::SetFocusable(const bool focusable) + { + if (m_Focusable == focusable) return; + + // Purely a membership change in Manager::BuildFocusOrder's cached traversal, not a + // layout or visual change - MarkLayoutDirty/MarkRenderDirty would both do more than + // needed (and MarkRenderDirty alone would still be a lie: nothing about this widget's + // own draw commands changed). Bumping s_DirtyEpoch directly is enough to invalidate + // Manager's focus-order cache, which keys off the same epoch as everything else that + // reuses it (see Manager::GetFocusOrder). + m_Focusable = focusable; + ++s_DirtyEpoch; + } + + void Widget::SetEnabled(const bool enabled) + { + if (m_Enabled == enabled) return; + + m_Enabled = enabled; + + // Cancels a press in progress immediately, rather than waiting for the eventual + // mouse-up to see m_Enabled == false: also fixes the pressed-looking visual without + // waiting for that mouse-up. + if (!m_Enabled) + m_Pressed = false; + MarkRenderDirty(); } @@ -132,9 +325,25 @@ namespace Elixir::GUI } } - void Widget::CollectDrawCommands(RenderBatch& batch, int& zCursor, bool& rebuilt) + void Widget::ForEachChild(const std::function&)>& fn) const + { + for (size_t i = 0; i < GetChildCount(); ++i) + { + if (const Ref child = GetChildAt(i)) + fn(child); + } + } + + void Widget::CollectDrawCommands( + RenderBatch& batch, + int& zCursor, + bool& rebuilt, + const SRect& clipRect, + const glm::vec2 inheritedOffset, + const float inheritedOpacity + ) { - if (!IsVisible()) return; + if (!IsRenderVisible()) return; // Regenerate this widget's own commands only when its visuals/geometry changed. if (m_RenderDirty) @@ -145,17 +354,71 @@ namespace Elixir::GUI rebuilt = true; } + const glm::vec2 renderOffset = inheritedOffset + m_RenderOffset; + const float renderOpacity = inheritedOpacity * m_Opacity; + // Own commands occupy [zCursor, zCursor + span); advance so children stack above, - // and the next sibling starts above this whole subtree. - batch.Append(m_CachedCommands, zCursor); + // and the next sibling starts above this whole subtree. The ancestor clip is applied + // here, at Append time, rather than baked into m_CachedCommands: this widget's own + // visual content and the clip it happens to sit under are independent, and + // MarkRenderDirty never propagates to descendants (only MarkLayoutDirty does, and + // only upward) - so nothing would tell an otherwise-unchanged widget "an ancestor's + // clip moved, rebuild yourself". CollectDrawCommands already walks every visible + // widget on every rebuild regardless, so intersecting the clip here costs nothing + // extra; baking it into BuildDrawCommands would require a new downward invalidation + // pass to avoid going stale. + batch.Append(m_CachedCommands, zCursor, clipRect, renderOffset, renderOpacity); zCursor += m_CachedCommands.LayerSpan(); + // A clipping container (e.g. ScrollBox) intersects its own bounds with whatever clip + // it inherited and hands that down; everyone else just forwards the inherited clip + // unchanged. With no inherited clip yet (root, or the first clipping ancestor in the + // chain), the container's own geometry becomes the clip outright. + SRect renderGeometry = m_Geometry; + renderGeometry.Position += renderOffset; + const SRect childClipRect = ClipsChildren() + ? (clipRect.IsValid() + ? SRect::Intersect(renderGeometry, clipRect) + : renderGeometry) + : clipRect; + ForEachChild([&](const Ref& child) { - child->CollectDrawCommands(batch, zCursor, rebuilt); + child->CollectDrawCommands( + batch, + zCursor, + rebuilt, + childClipRect, + renderOffset, + renderOpacity + ); }); } + EInteractionState Widget::GetInteractionState() const + { + auto states = EInteractionState::None; + + if (IsHovered()) + states |= EInteractionState::Hovered; + + if (IsPressed()) + states |= EInteractionState::Pressed; + + if (IsFocused()) + states |= EInteractionState::Focused; + + if (!IsEnabled()) + states |= EInteractionState::Disabled; + + return states; + } + + const SAppearance& Widget::GetResolvedAppearance() const + { + return GetStyle().Resolve(GetInteractionState()); + } + void Widget::MarkLayoutDirty() { // Bump before the short-circuit: a change while already dirty must still be seen by @@ -166,6 +429,7 @@ namespace Elixir::GUI return; m_LayoutDirty = true; + m_MeasureDirty = true; if (const auto parent = m_Parent.lock()) parent->MarkLayoutDirty(); @@ -177,6 +441,11 @@ namespace Elixir::GUI ++s_DirtyEpoch; } + bool Widget::HitTestSelf(const glm::vec2& point) const + { + return m_Geometry.Contains(point); + } + void Widget::HandleMouseEnter() { m_Hovered = true; @@ -191,26 +460,26 @@ namespace Elixir::GUI if (m_OnMouseLeaveCallback) m_OnMouseLeaveCallback(); } - void Widget::HandleMouseDown(const MouseButtonPressedEvent& event) + SInputReply Widget::HandleMouseDown(const MouseButtonPressedEvent& event) { + if (!m_Enabled) return SInputReply::Unhandled(); + if (!m_OnMouseDownCallback && !m_OnClickCallback && !m_OnMouseUpCallback) + return SInputReply::Unhandled(); + m_Pressed = true; MarkRenderDirty(); if (m_OnMouseDownCallback) m_OnMouseDownCallback(); + return SInputReply::HandledAndCaptured(); } - void Widget::HandleMouseUp(const MouseButtonReleasedEvent& event) + SInputReply Widget::HandleMouseUp(const MouseButtonReleasedEvent& event) { - if (m_Pressed) - { - if (m_OnMouseUpCallback) - m_OnMouseUpCallback(); - - if (m_Hovered) - HandleClick(); - } + if (m_Pressed && m_OnMouseUpCallback) + m_OnMouseUpCallback(); m_Pressed = false; MarkRenderDirty(); + return SInputReply::Handled(); } void Widget::HandleFocus() @@ -229,7 +498,7 @@ namespace Elixir::GUI void Widget::HandleClick() { - if (m_OnClickCallback) m_OnClickCallback(); + if (m_Enabled && m_OnClickCallback) m_OnClickCallback(); } SRect Widget::ApplyPadding(const SRect& availableSpace, const SPadding& padding) @@ -291,6 +560,10 @@ namespace Elixir::GUI result.Position.x = availableSpace.Position.x + availableSpace.Size.x - childSize.x; result.Size.x = childSize.x; break; + case EHorizontalAlignment::Fill: + result.Position.x = availableSpace.Position.x; + result.Size.x = availableSpace.Size.x; + break; } return result; @@ -318,6 +591,9 @@ namespace Elixir::GUI result.Position.y = availableSpace.Position.y + availableSpace.Size.y - childSize.y; result.Size.y = childSize.y; break; + case EVerticalAlignment::Fill: + result.Position.y = availableSpace.Position.y; + result.Size.y = availableSpace.Size.y; } return result; @@ -327,7 +603,8 @@ namespace Elixir::GUI void ContentWidget::Update(const Timestep frameTime) { - if (m_ContentSlot && m_ContentSlot->IsVisible()) + Widget::Update(frameTime); + if (m_ContentSlot && m_ContentSlot->GetWidget()->TakesSpace()) { m_ContentSlot->GetWidget()->Update(frameTime); } @@ -371,12 +648,11 @@ namespace Elixir::GUI ClearContent(); } - void ContentWidget::ForEachChild(const std::function&)>& fn) const + Ref ContentWidget::GetChildAt(const size_t index) const { - if (m_ContentSlot) - { - if (const auto& child = m_ContentSlot->GetWidget()) - fn(child); - } + if (m_ContentSlot && index == 0) + return m_ContentSlot->GetWidget(); + + return nullptr; } } diff --git a/Elixir/Source/Engine/GUI/Widget.h b/Elixir/Source/Engine/GUI/Widget.h index e1f48033..992238e6 100644 --- a/Elixir/Source/Engine/GUI/Widget.h +++ b/Elixir/Source/Engine/GUI/Widget.h @@ -6,19 +6,50 @@ #include #include #include +#include namespace Elixir::GUI { class Manager; + /** + * @brief Result of routing an input event to a widget: whether it was consumed (stops + * further bubbling) and, for mouse-down, whether the widget wants to keep receiving mouse + * move/up regardless of hover (see Manager::m_MouseCapture). + */ + struct SInputReply + { + bool EventHandled = false; + bool CaptureMouse = false; + + static SInputReply Unhandled() { return {}; } + static SInputReply Handled() { return { true, false }; } + static SInputReply HandledAndCaptured() { return { true, true }; } + }; + + /** + * @brief Sentinel meaning "no limit" for one axis of a Measure/ComputeDesiredSize + * constraint. + * + * Containers propagate this on the axis they do not constrain (e.g. the main axis of a + * stacking panel). ComputeDesiredSize overrides may receive it as input on either axis, + * but must never return it in the result. + */ + inline constexpr float UnconstrainedSize = std::numeric_limits::infinity(); + + /** @brief Complete visual style for a widget that only draws a background. */ + struct SWidgetStyle final : SStyle, TStateStyles{}; + class ELIXIR_API Widget : public std::enable_shared_from_this { friend class Manager; friend class Slot; - friend class ContentSlot; - friend class LayoutSlot; - friend class CanvasSlot; + public: + /** + * @brief Construct a widget with a copy of the current default widget style. + */ + Widget(); virtual ~Widget() = default; /** @@ -28,10 +59,18 @@ namespace Elixir::GUI virtual void Update(Timestep frameTime) {} /** - * Compute how much space this widget wants. - * @return a 2d vector representing width and height. + * @brief Get how much space this widget wants, given the space available to it. + * + * This is the template method: it is non-virtual, so subclasses override + * ComputeDesiredSize instead. Caches the result keyed by availableSize and by + * m_MeasureDirty: calling this again on a clean widget with the same constraint is + * O(1) and does not touch this widget's subtree. + * + * @param availableSize Space available to this widget on each axis; an axis may be + * UnconstrainedSize when the caller places no limit on it. + * @return This widget's desired size for the given constraint. */ - virtual glm::vec2 ComputeDesiredSize() = 0; + const glm::vec2& Measure(const glm::vec2& availableSize); /** * Arrange this widget in the given space. Short-circuits when the layout is clean and @@ -42,6 +81,26 @@ namespace Elixir::GUI */ void ArrangeChildren(const SRect& allocatedSpace); + /** + * @brief Finds the topmost widget under point and every hit-testable ancestor above it, + * in root -> leaf order. + * + * Descends children back-to-front (last child = highest z, see CollectDrawCommands) + * so the first matching branch, depth-first, wins. Prunes HitTestInvisible/Hidden/Collapsed + * branches entirely; skips (but still descends through) SelfHitTestInvisible widgets. + * A clipping ancestor limits the hit area of every descendant. + * Non-virtual: built on HitTestSelf and the GetChildCount/GetChildAt traversal primitives. + * + * @param point Point to test, in the same space as m_Geometry. + * @param path Appended with the hit path; left untouched if nothing was hit. + * @param clipRect Clip inherited from a clipping ancestor, or an invalid rect when none applies. + */ + void HitTest( + const glm::vec2& point, + std::vector>& path, + const SRect& clipRect = {{ -1, -1 }, { -1, -1 }} + ); + /** * Get this widget's parent, or nullptr if it has none (or the parent was destroyed). * @return a Ref to the parent, kept alive for the duration of the call. @@ -67,53 +126,168 @@ namespace Elixir::GUI */ static uint64_t CurrentDirtyEpoch() { return s_DirtyEpoch; } - /* Callbacks */ - - void OnFocus(const std::function& callback) { m_OnFocusCallback = callback; } - void OnLostFocus(const std::function& callback) { m_OnLostFocusCallback = callback; } - void OnClick(const std::function& callback) { m_OnClickCallback = callback; } - void OnMouseEnter(const std::function& callback) { m_OnMouseEnterCallback = callback; } - void OnMouseLeave(const std::function& callback) { m_OnMouseLeaveCallback = callback; } - void OnMouseDown(const std::function& callback) { m_OnMouseDownCallback = callback; } - void OnMouseUp(const std::function& callback) { m_OnMouseUpCallback = callback; } - float GetOpacity() const { return m_Opacity; } void SetOpacity(float opacity); + /** @brief Get this widget's visual displacement from its layout geometry. */ + glm::vec2 GetRenderOffset() const { return m_RenderOffset; } + + /** + * @brief Set a displacement applied only while rendering this widget and its children. + * @param offset Offset in GUI units. + */ + void SetRenderOffset(const glm::vec2& offset); + EVisibility GetVisibility() const { return m_Visibility; } + + /** + * @brief Set this widget's visibility immediately. + * @param visibility New visibility state. + */ void SetVisibility(EVisibility visibility); + bool IsVisible() const; - glm::vec4 GetInsetShadow() const { return m_InsetShadow; } - glm::vec4 GetDropShadow() const { return m_DropShadow; } + /** + * @brief True for whether this widget should still be drawn, regardless of whether + * it can be clicked. + * @return True for Visible/HitTestInvisible/SelfHitTestInvisible (and Opacity > 0). + */ + bool IsRenderVisible() const; + + /** + * @brief Whether this widget should still occupy a slot in its parent's layout. + * @return True for everything excepts Collapsed. + */ + bool TakesSpace() const; + + /** + * @brief Whether HitTest may consider THIS widget (as opposed + * to its children) a hit target. See the EVisibility semantics table. + * @return True only for Visible. + */ + bool IsSelfHitTestVisible() const; + + /** + * @brief Whether this widget can consume mouse input at its current state. + * + * Manager uses this to distinguish a visual hit-test surface from an interactive + * control when reporting WantsMouse(). Components with built-in mouse behavior + * override it; the base implementation recognizes registered mouse callbacks. + */ + virtual bool CanHandleMouseInput() const; + + /** + * @brief Get this widget's complete style. + * @return Complete style currently used by this widget. + */ + const SWidgetStyle& GetStyle() const; + + /** + * @brief Replace this widget's complete style. + * @param style Complete style to copy into this widget. + */ + void SetStyle(const SWidgetStyle& style); + + /** + * @brief Set one layer's background color. + * @param layer Layer that owns the override. + * @param color Background color for that layer. + */ + void SetBackgroundColor(EStyleLayer layer, const SColor& color); + + /** + * @brief Set one legacy layer's background texture. + * @param layer Layer that owns the override. + * @param texture Texture for that layer. + */ + void SetBackgroundTexture(EStyleLayer layer, const Ref& texture); + + /** + * @brief Clear one legacy layer's background texture. + * @param layer Layer to change. + */ + void ClearBackgroundTexture(EStyleLayer layer); + + /** + * @brief Set the border metric for a 9-patch background texture. + * @param layer Layer that owns the override. + * @param borders Border mapping = (left, top, right, bottom). + */ + void SetBackgroundBorders(EStyleLayer layer, const glm::vec4& borders); + + /** + * Set the same radius for all corners. + * @param layer Layer that owns the override. + * @param radius corner radius in pixels + */ + void SetCornerRadius(const EStyleLayer layer, const float radius) + { + SetCornerRadius(layer, { radius, radius, radius, radius }); + } + + /** + * Set a radius for each corner individually. + * @param layer Layer that owns the override. + * @param radius vector (top-left, top-right, bottom-right, bottom-left) + */ + void SetCornerRadius(EStyleLayer layer, const glm::vec4& radius); /** * Set the inset shadow parameters. + * @param layer Layer that owns the override. * @param shadow Shadow offset (x, y), blur (z) and intensity (w). */ - void SetInsetShadow(const glm::vec4& shadow); - void SetInsetShadowOffset(const glm::vec2& offset); - void SetInsetShadowBlur(float blur); - void SetInsetShadowIntensity(float intensity); + void SetInsetShadow(EStyleLayer layer, const glm::vec4& shadow); + void SetInsetShadowOffset(EStyleLayer layer, const glm::vec2& offset); + void SetInsetShadowBlur(EStyleLayer layer, float blur); + void SetInsetShadowIntensity(EStyleLayer layer, float intensity); /** * Set the drop shadow parameters. + * @param layer Layer that owns the override. * @param shadow Shadow offset (x, y), blur (z) and intensity (w). */ - void SetDropShadow(const glm::vec4& shadow); - void SetDropShadowOffset(const glm::vec2& offset); - void SetDropShadowBlur(float blur); - void SetDropShadowIntensity(float intensity); + void SetDropShadow(EStyleLayer layer, const glm::vec4& shadow); + void SetDropShadowOffset(EStyleLayer layer, const glm::vec2& offset); + void SetDropShadowBlur(EStyleLayer layer, float blur); + void SetDropShadowIntensity(EStyleLayer layer, float intensity); + + void SetOutline(EStyleLayer layer, const SOutline& outline); + void SetOutlineColor(EStyleLayer layer, const SColor& color); + void SetOutlineThickness(EStyleLayer layer, float thickness); - SOutline GetOutline() const { return m_Outline; } - void SetOutline(const SOutline& outline); - void SetOutlineColor(const SColor& color); - void SetOutlineThickness(float thickness); + bool IsFocusable() const { return m_Focusable; } + void SetFocusable(bool focusable); bool IsHovered() const { return m_Hovered; } bool IsPressed() const { return m_Pressed; } bool IsFocused() const { return m_Focused; } + bool IsEnabled() const { return m_Enabled; } + + /** + * @brief Enable or disable this widget's interactivity. + * + * A disabled widget keeps rendering and keeps its layout slot; only interaction + * changes. It stops accepting input events. Visibility and layout are untouched - + * callers that also want the widget hidden or removed from layout still need + * SetVisibility for that. + * + * @param enabled New enabled state. + */ + void SetEnabled(bool enabled); + + /* Callbacks */ + + void OnFocus(const std::function& callback) { m_OnFocusCallback = callback; } + void OnLostFocus(const std::function& callback) { m_OnLostFocusCallback = callback; } + void OnClick(const std::function& callback) { m_OnClickCallback = callback; } + void OnMouseEnter(const std::function& callback) { m_OnMouseEnterCallback = callback; } + void OnMouseLeave(const std::function& callback) { m_OnMouseLeaveCallback = callback; } + void OnMouseDown(const std::function& callback) { m_OnMouseDownCallback = callback; } + void OnMouseUp(const std::function& callback) { m_OnMouseUpCallback = callback; } + protected: /** * Register a widget as a child of this one: sets the child's parent back-pointer @@ -137,7 +311,38 @@ namespace Elixir::GUI void DetachChild(const Ref& child); virtual void RemoveChild(const Ref& child) {} - virtual void ForEachChild(const std::function&)>& fn) const {} + + /** + * @brief Number of direct children this widget exposes to generic tree traversal (render, + * HitTest, ...). + * Leaf widgets keep the default of zero; containers override this alongside GetChildAt. + * @return Number of children. + */ + virtual size_t GetChildCount() const { return 0; } + + /** + * @brief Get the direct child at the given index, in the same order/index space as + * GetChildCount. + * @param index Child index; must be in [0, GetChildCount()). + * @return The child widget, or nullptr if index is out of range. + */ + virtual Ref GetChildAt(size_t index) const { return nullptr; } + + /** + * @brief Invoke fn for each direct child of this widget, in order. + * + * Non-virtual: built on GetChildCount/GetChildAt so every container gets consistent + * iteration for free. + * + * Does not filter by visibility. + * + * @param fn Callback invoked once per child widget. + */ + void ForEachChild(const std::function&)>& fn) const; + + // Compute how much space this widget wants, given the space available + // to it on each axis. + virtual glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) = 0; /** * Position this widget's children within its (already updated) geometry. Container @@ -157,11 +362,25 @@ namespace Elixir::GUI * starts above this widget's whole subtree — so sibling subtrees never overlap * in z. * + * Also threads the ancestor clip rect: applied at Append time (not baked into + * m_CachedCommands by BuildDrawCommands), so changing a ScrollBox's scroll offset - + * or anything else that only moves an ancestor's clip - never has to invalidate a + * descendant's command cache. See ClipsChildren. + * * @param batch destination batch. * @param zCursor running layer index; advanced past everything this subtree. * @param rebuilt set to true if any widget's command cache was regenerated. + * @param clipRect Clip rect inherited from ancestors; the invalid + * {-1, -1}/{-1, -1} sentinel (see SRect::IsValid) means "no clip". */ - void CollectDrawCommands(RenderBatch& batch, int& zCursor, bool& rebuilt); + void CollectDrawCommands( + RenderBatch& batch, + int& zCursor, + bool& rebuilt, + const SRect& clipRect, + glm::vec2 inheritedOffset = {}, + float inheritedOpacity = 1.0f + ); /** * Build the draw commands for THIS widget only (no children). Containers emit their @@ -172,6 +391,42 @@ namespace Elixir::GUI */ virtual void BuildDrawCommands(RenderBatch& batch, int zOrder) {} + /** + * @brief Whether this widget clips its children to its own geometry. + * + * A container that returns true (e.g. ScrollBox) intersects m_Geometry with + * whatever clip it inherited and hands the result down to CollectDrawCommands for + * each child; everyone else (default) just forwards the intersected clip unchanged. + * + * @return True if this widget's own bounds should clip its children. + */ + virtual bool ClipsChildren() const { return false; } + + /** + * Build this frame's interaction state mask from this widget's own + * hover/press/enabled flags. Feeds StyleSet::Resolve only - it does not feed back + * into input routing. + * @return Mask combining Hovered/Pressed/Disabled as currently active. + */ + EInteractionState GetInteractionState() const; + + /** + * Get the complete appearance selected for this widget's current interaction state. + * Component widgets override this to resolve their own typed style. + * @return Current background appearance. + */ + virtual const SAppearance& GetResolvedAppearance() const; + + /** + * @brief Get the brush a legacy setter must change. + * + * Component widgets override this so legacy background setters still create a typed + * component-style override rather than changing a separate base style. + * @param layer Legacy interaction layer to change. + * @return Mutable background brush for that layer. + */ + virtual SBrush& GetMutableBackgroundBrush(EStyleLayer layer); + /** * Mark this widget's layout as dirty and propagate the mark to ancestors. * A dirty widget (and any ancestor whose layout depends on it) is re-arranged @@ -186,13 +441,25 @@ namespace Elixir::GUI */ void MarkRenderDirty(); + /** + * @brief Per-widget hit test, used by HitTest. + * + * Default hits the widget's own geometry; override for non-rectangular or + * custom-shaped hit areas. + * + * @param point Point to test, in the same space as m_Geometry. + * @return True if point is within this widget's hit area. + */ + virtual bool HitTestSelf(const glm::vec2& point) const; + virtual void HandleMouseEnter(); virtual void HandleMouseLeave(); - virtual void HandleMouseDown(const MouseButtonPressedEvent& event); - virtual void HandleMouseUp(const MouseButtonReleasedEvent& event); - virtual void HandleMouseMove(const MouseMovedEvent& event) {} - virtual void HandleKeyPressed(const KeyPressedEvent& event) {} - virtual void HandleKeyTyped(const KeyTypedEvent& event) {} + virtual SInputReply HandleMouseDown(const MouseButtonPressedEvent& event); + virtual SInputReply HandleMouseUp(const MouseButtonReleasedEvent& event); + virtual SInputReply HandleMouseMove(const MouseMovedEvent& event) { return SInputReply::Unhandled(); } + virtual SInputReply HandleMouseScrolled(const MouseScrolledEvent& event) { return SInputReply::Unhandled(); } + virtual SInputReply HandleKeyPressed(const KeyPressedEvent& event) { return SInputReply::Unhandled(); } + virtual SInputReply HandleKeyTyped(const KeyTypedEvent& event) { return SInputReply::Unhandled(); } virtual void HandleFocus(); virtual void HandleLostFocus(); virtual void HandleClick(); @@ -276,21 +543,24 @@ namespace Elixir::GUI // Bumped by MarkLayoutDirty / MarkRenderDirty. inline static uint64_t s_DirtyEpoch = 1; - SRect m_Geometry{}; glm::vec2 m_DesiredSize{}; + glm::vec2 m_LastMeasureConstraint{ -1.0f, -1.0f }; + bool m_MeasureDirty = true; + SRect m_Geometry{}; float m_Opacity = 1.0f; + glm::vec2 m_RenderOffset{}; EVisibility m_Visibility = EVisibility::Visible; - glm::vec4 m_InsetShadow = {}; - glm::vec4 m_DropShadow = {}; + SWidgetStyle m_Style; - SOutline m_Outline = {}; + bool m_Focusable = false; bool m_Hovered = false; bool m_Pressed = false; bool m_Focused = false; + bool m_Enabled = true; std::function m_OnMouseEnterCallback; std::function m_OnMouseLeaveCallback; std::function m_OnMouseDownCallback; @@ -344,13 +614,10 @@ namespace Elixir::GUI */ void RemoveChild(const Ref& child) override; - /** - * Invoke fn with this widget's content, if any. Calls fn at most once, since a - * ContentWidget hosts a single child; no-op when there is no content. - * @param fn callback invoked with the content widget. - */ - void ForEachChild(const std::function&)>& fn) const override; + size_t GetChildCount() const override { return m_ContentSlot ? 1 : 0; } + + Ref GetChildAt(size_t index) const override; Ref m_ContentSlot; }; -} \ No newline at end of file +} diff --git a/Elixir/Source/Engine/GUI/WidgetAnimation.cpp b/Elixir/Source/Engine/GUI/WidgetAnimation.cpp new file mode 100644 index 00000000..cf565335 --- /dev/null +++ b/Elixir/Source/Engine/GUI/WidgetAnimation.cpp @@ -0,0 +1,52 @@ +#include "epch.h" +#include "WidgetAnimation.h" + +namespace Elixir::GUI +{ + WidgetAnimation::WidgetAnimation(Ref target) + : m_Target(std::move(target)) + { + EE_CORE_ASSERT(m_Target, "WidgetAnimation requires a target widget"); + } + + void WidgetAnimation::ClearTracks() + { + Stop(); + m_Tracks.clear(); + } + + void WidgetAnimation::Play() + { + Stop(); + if (!m_Target || m_Tracks.empty()) + { + if (m_OnFinished) m_OnFinished(); + return; + } + + m_IsPlaying = true; + m_PendingTracks = m_Tracks.size(); + for (const TTrack& track : m_Tracks) + { + track(m_Animator, m_Target, [this] + { + if (--m_PendingTracks != 0) return; + + m_IsPlaying = false; + if (m_OnFinished) m_OnFinished(); + }); + } + } + + void WidgetAnimation::Stop() + { + m_Animator.StopAll(); + m_PendingTracks = 0; + m_IsPlaying = false; + } + + void WidgetAnimation::Update(const Timestep frameTime) + { + m_Animator.Update(frameTime); + } +} diff --git a/Elixir/Source/Engine/GUI/WidgetAnimation.h b/Elixir/Source/Engine/GUI/WidgetAnimation.h new file mode 100644 index 00000000..3d846d8c --- /dev/null +++ b/Elixir/Source/Engine/GUI/WidgetAnimation.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include + +namespace Elixir::GUI +{ + /** + * @brief Plays keyframe tracks that update one widget. + * + * The animation belongs to its owner, not to Widget. Each track supplies its own binding, + * so callers can animate any public widget or style property. + */ + class ELIXIR_API WidgetAnimation + { + public: + /** + * @brief Create an animation for one widget. + * @param target Widget updated by each track. + */ + explicit WidgetAnimation(Ref target); + + /** + * @brief Add a keyframe track and its widget binding. + * @tparam TValue Value sampled from the curve. + * @param curve Keyframes to play. + * @param apply Stores each sampled value in the target widget. + */ + template + void AddTrack( + AnimationCurve curve, + TApply apply + ) + { + m_Tracks.emplace_back( + [curve = std::move(curve), apply = std::move(apply)]( + Animator& animator, + const Ref& target, + std::function onComplete + ) + { + animator.Bind( + curve, + [target, apply](const TValue& value) { apply(*target, value); }, + std::move(onComplete) + ); + } + ); + } + + /** @brief Remove every track from this animation. */ + void ClearTracks(); + + /** @brief Start the tracks from their first keyframes. */ + void Play(); + + /** @brief Stop the tracks at their current values. */ + void Stop(); + + /** @brief Advance the active tracks by one frame. */ + void Update(Timestep frameTime); + + /** @brief Return true while this animation has active tracks. */ + bool IsPlaying() const { return m_IsPlaying; } + + /** + * @brief Set the callback that runs after every track finishes. + * @param callback Callback to run once per completed playback. + */ + void OnFinished(std::function callback) + { + m_OnFinished = std::move(callback); + } + + private: + using TTrack = std::function&, std::function)>; + + Ref m_Target; + Animator m_Animator; + std::vector m_Tracks; + size_t m_PendingTracks = 0; + bool m_IsPlaying = false; + std::function m_OnFinished; + }; +} diff --git a/Elixir/Source/Engine/Icon/Icon.cpp b/Elixir/Source/Engine/Icon/Icon.cpp new file mode 100644 index 00000000..bf964b14 --- /dev/null +++ b/Elixir/Source/Engine/Icon/Icon.cpp @@ -0,0 +1,54 @@ +#include "epch.h" +#include "Icon.h" + +namespace Elixir +{ + namespace + { + uint64_t MakeRasterKey(const glm::uvec2 size) + { + return (static_cast(size.x) << 32) | size.y; + } + } + + Icon::Icon( + const GraphicsContext* context, + SIconSource source, + Ref content + ) : m_GraphicsContext(context), + m_Source(std::move(source)), + m_Content(std::move(content)) {} + + Ref Icon::GetTexture(const glm::vec2& logicalSize) const + { + EE_CORE_ASSERT(m_GraphicsContext, "Icon requires an initialized IconManager") + if (!m_GraphicsContext) return nullptr; + + const float dpiScale = m_GraphicsContext->GetDPIScale(); + const glm::uvec2 pixelSize = glm::max( + glm::uvec2(glm::round(glm::max(logicalSize, glm::vec2(1.0f)) * dpiScale)), + glm::uvec2(1) + ); + const uint64_t key = MakeRasterKey(pixelSize); + if (const auto it = m_Textures.find(key); it != m_Textures.end()) + return it->second; + + const SIconBitmap bitmap = m_Content->Rasterize({ .PixelSize = pixelSize }); + if (!bitmap.IsValid()) + { + EE_CORE_ERROR("Cannot rasterize icon! [Path={0}]", m_Source.Path.string()) + return nullptr; + } + + const auto texture = Texture2D::Create( + m_GraphicsContext, + EImageFormat::R8G8B8A8_UNORM, + bitmap.Size.x, + bitmap.Size.y, + bitmap.Pixels.data(), + m_Source.Path.string() + ); + m_Textures.emplace(key, texture); + return texture; + } +} \ No newline at end of file diff --git a/Elixir/Source/Engine/Icon/Icon.h b/Elixir/Source/Engine/Icon/Icon.h new file mode 100644 index 00000000..a4dd867d --- /dev/null +++ b/Elixir/Source/Engine/Icon/Icon.h @@ -0,0 +1,137 @@ +#pragma once + +#include +#include + +namespace Elixir +{ + class GraphicsContext; +} + +namespace Elixir +{ + /** @brief Identifies the source format of an icon asset. */ + enum class EIconFormat : uint8_t + { + SVG, + PNG, + WebP, + }; + + /** @brief Describes an icon file before a format loader imports it. */ + struct SIconSource + { + std::filesystem::path Path; + EIconFormat Format; + }; + + /** @brief Describes the intrinsic size of imported icon content. */ + struct SIconMetrics + { + glm::vec2 Size{ 1.0f, 1.0f }; + }; + + /** @brief Requests one rasterized icon size in physical pixels. */ + struct SIconRasterRequest + { + glm::uvec2 PixelSize{ 1, 1 }; + }; + + /** @brief Stores pixels produced by an icon content implementation. */ + struct SIconBitmap + { + glm::uvec2 Size{}; + std::vector Pixels; + + /** @brief Check whether this bitmap contains one RGBA pixel buffer. */ + bool IsValid() const + { + return Size.x > 0 && Size.y > 0 && + Pixels.size() == size_t(Size.x) * Size.y * 4; + } + }; + + /** + * @brief Represents imported icon data without exposing its source library. + * + * A concrete content type owns the native data returned by its loader. It rasterizes on + * demand, which lets SVG, raster files and future compiled formats share Icon. + */ + class ELIXIR_API IconContent + { + public: + virtual ~IconContent() = default; + + /** @brief Get the icon's intrinsic size. */ + virtual SIconMetrics GetMetrics() const = 0; + + /** + * @brief Rasterize this icon at one physical size. + * @param request Requested output dimensions. + * @return RGBA bitmap, or an invalid bitmap when rasterization fails. + */ + virtual SIconBitmap Rasterize(const SIconRasterRequest& request) const = 0; + }; + + /** + * @brief Imports one icon format into opaque icon content. + * + * Loaders do not leak parser types through engine headers. Register another loader to add a + * format or replace the implementation that imports an existing format. + */ + class ELIXIR_API IconLoader + { + public: + virtual ~IconLoader() = default; + + /** @brief Get the format this loader imports. */ + virtual EIconFormat GetFormat() const = 0; + + /** + * @brief Import one source file. + * @param source File and format to import. + * @return Imported content, or nullptr when the source is invalid. + */ + virtual Ref Load(const SIconSource& source) const = 0; + }; + + /** + * @brief Identifies one imported icon and caches its GPU rasters. + * + * An asset is format-neutral. It delegates rasterization to its opaque IconContent and + * stores one texture for each requested physical size. + */ + class ELIXIR_API Icon final + { + friend class IconManager; + + public: + /** @brief Get the path used to import this icon. */ + const std::filesystem::path& GetPath() const { return m_Source.Path; } + + /** @brief Get this asset's source format. */ + EIconFormat GetFormat() const { return m_Source.Format; } + + /** @brief Get the icon's intrinsic size. */ + SIconMetrics GetMetrics() const { return m_Content->GetMetrics(); } + + /** + * @brief Get a raster texture for one logical render size. + * @param logicalSize Icon dimensions in logical render units. + * @return Cached or newly rasterized texture, or nullptr when rasterization fails. + */ + Ref GetTexture(const glm::vec2& logicalSize) const; + + private: + Icon( + const GraphicsContext* context, + SIconSource source, + Ref content + ); + + const GraphicsContext* m_GraphicsContext = nullptr; + SIconSource m_Source; + Ref m_Content; + mutable std::unordered_map> m_Textures; + }; +} diff --git a/Elixir/Source/Engine/Icon/IconManager.cpp b/Elixir/Source/Engine/Icon/IconManager.cpp new file mode 100644 index 00000000..1820051f --- /dev/null +++ b/Elixir/Source/Engine/Icon/IconManager.cpp @@ -0,0 +1,85 @@ +#include "epch.h" +#include "IconManager.h" + +#include +#include + +namespace Elixir +{ + const GraphicsContext* s_GraphicsContext = nullptr; + std::unordered_map> s_Loaders; + + void IconManager::Initialize(const GraphicsContext* context) + { + EE_CORE_ASSERT(context, "IconManager requires a graphics context") + if (s_GraphicsContext == context) return; + + s_GraphicsContext = context; + s_Loaders.clear(); + RegisterLoader(CreateScope()); + } + + void IconManager::Shutdown() + { + s_Loaders.clear(); + s_GraphicsContext = nullptr; + } + + bool IconManager::RegisterLoader(Scope loader) + { + if (!loader) return false; + + const EIconFormat format = loader->GetFormat(); + if (s_Loaders.contains(format)) return false; + + s_Loaders.emplace(format, std::move(loader)); + return true; + } + + bool IconManager::ReplaceLoader(Scope loader) + { + if (!loader) return false; + + s_Loaders[loader->GetFormat()] = std::move(loader); + return true; + } + + Ref IconManager::Load(const std::filesystem::path& path) + { + EE_CORE_ASSERT(s_GraphicsContext, "IconManager must be initialized before loading icons") + + const auto format = InferFormat(path); + if (!format) + { + EE_CORE_ERROR("Unsupported icon format! [Path={0}]", path.string()) + return nullptr; + } + + const auto it = s_Loaders.find(*format); + if (it == s_Loaders.end()) + { + EE_CORE_ERROR("No loader registered for icon format! [Path={0}]", path.string()) + return nullptr; + } + + SIconSource source{ .Path = path, .Format = *format }; + const auto content = it->second->Load(source); + if (!content) return nullptr; + + return Ref(new Icon(s_GraphicsContext, std::move(source), content)); + } + + std::optional IconManager::InferFormat(const std::filesystem::path& path) + { + std::string extension = path.extension().string(); + std::ranges::transform(extension, extension.begin(), [](const unsigned char character) + { + return static_cast(std::tolower(character)); + }); + + if (extension == ".svg") return EIconFormat::SVG; + if (extension == ".png") return EIconFormat::PNG; + if (extension == ".webp") return EIconFormat::WebP; + return std::nullopt; + } +} diff --git a/Elixir/Source/Engine/Icon/IconManager.h b/Elixir/Source/Engine/Icon/IconManager.h new file mode 100644 index 00000000..60b2e3d2 --- /dev/null +++ b/Elixir/Source/Engine/Icon/IconManager.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +namespace Elixir +{ + class GraphicsContext; +} + +namespace Elixir +{ + /** + * @brief Loads icons through registered format loaders. + * + * Initialize the manager once after the graphics context exists. It registers the SVG + * loader by default; applications can add or explicitly replace other format loaders. + */ + class ELIXIR_API IconManager final + { + public: + IconManager() = delete; + + /** + * @brief Initialize icon loading for one graphics context. + * @param context Graphics context that owns raster textures. + */ + static void Initialize(const GraphicsContext* context); + + /** @brief Release registered loaders and cached manager state. */ + static void Shutdown(); + + /** + * @brief Register a loader for a previously unsupported icon format. + * @param loader Loader to register. + * @return False when that format already has a loader. + */ + static bool RegisterLoader(Scope loader); + + /** + * @brief Replace the loader registered for one icon format. + * @param loader Loader that becomes responsible for its declared format. + * @return False when loader is null. + */ + static bool ReplaceLoader(Scope loader); + + /** + * @brief Import an icon file based on its extension. + * @param path Local icon file. + * @return Icon, or nullptr when no loader accepts the file. + */ + static Ref Load(const std::filesystem::path& path); + + private: + static std::optional InferFormat(const std::filesystem::path& path); + }; +} diff --git a/Elixir/Source/Platform/LunaSVG/LunaSVGIconLoader.cpp b/Elixir/Source/Platform/LunaSVG/LunaSVGIconLoader.cpp new file mode 100644 index 00000000..ff19624e --- /dev/null +++ b/Elixir/Source/Platform/LunaSVG/LunaSVGIconLoader.cpp @@ -0,0 +1,76 @@ +#include "epch.h" +#include "LunaSVGIconLoader.h" + +#include + +namespace Elixir +{ + namespace + { + class LunaSVGIconContent final : public IconContent + { + public: + explicit LunaSVGIconContent(std::unique_ptr document) + : m_Document(std::move(document)) {} + + SIconMetrics GetMetrics() const override + { + return { + .Size = { + std::max(m_Document->width(), 1.0f), + std::max(m_Document->height(), 1.0f), + } + }; + } + + SIconBitmap Rasterize(const SIconRasterRequest& request) const override + { + auto bitmap = m_Document->renderToBitmap( + static_cast(request.PixelSize.x), + static_cast(request.PixelSize.y) + ); + if (bitmap.isNull()) return {}; + + bitmap.convertToRGBA(); + + SIconBitmap result; + result.Size = request.PixelSize; + result.Pixels.resize(static_cast(result.Size.x) * result.Size.y * 4); + + // Keep only coverage; the GUI renderer applies the icon color. + const uint8_t* source = bitmap.data(); + for (uint32_t y = 0; y < result.Size.y; ++y) + { + for (uint32_t x = 0; x < result.Size.x; ++x) + { + const size_t destinationIndex = + (static_cast(y) * result.Size.x + x) * 4; + const size_t sourceIndex = + static_cast(y) * bitmap.stride() + x * 4; + result.Pixels[destinationIndex] = 255; + result.Pixels[destinationIndex + 1] = 255; + result.Pixels[destinationIndex + 2] = 255; + result.Pixels[destinationIndex + 3] = source[sourceIndex + 3]; + } + } + + return result; + } + + private: + std::unique_ptr m_Document; + }; + } + + Ref LunaSVGIconLoader::Load(const SIconSource& source) const + { + auto document = lunasvg::Document::loadFromFile(source.Path.string()); + if (!document) + { + EE_CORE_ERROR("Cannot load SVG icon! [Path={0}]", source.Path.string()) + return nullptr; + } + + return CreateRef(std::move(document)); + } +} diff --git a/Elixir/Source/Platform/LunaSVG/LunaSVGIconLoader.h b/Elixir/Source/Platform/LunaSVG/LunaSVGIconLoader.h new file mode 100644 index 00000000..fb47d21b --- /dev/null +++ b/Elixir/Source/Platform/LunaSVG/LunaSVGIconLoader.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace Elixir +{ + /** @brief Imports and rasterizes SVG icons with LunaSVG. */ + class ELIXIR_API LunaSVGIconLoader final : public IconLoader + { + public: + /** @brief Get the SVG format supported by this loader. */ + EIconFormat GetFormat() const override { return EIconFormat::SVG; } + + /** + * @brief Import one SVG document. + * @param source Source file to import. + * @return Rasterizable icon content, or nullptr when the SVG is invalid. + */ + Ref Load(const SIconSource& source) const override; + }; +} diff --git a/Elixir/Tests/Engine/Font/FontManagerTest.cpp b/Elixir/Tests/Engine/Font/FontManagerTest.cpp new file mode 100644 index 00000000..5c96f241 --- /dev/null +++ b/Elixir/Tests/Engine/Font/FontManagerTest.cpp @@ -0,0 +1,97 @@ +#include + +#include + +using namespace Elixir; + +namespace +{ + Ref CreateTestFont() + { + return CreateRef(SFontCreateInfo{ + .Name = "Test", + .Atlas = { .Info = { .PxRange = 1.0f, .Width = 1, .Height = 1 } }, + .Glyphs = { + { .Unicode = ' ', .Advance = 0.5f }, + { .Unicode = 'a', .Advance = 1.0f }, + { .Unicode = 'b', .Advance = 1.0f }, + { .Unicode = 0x00E9, .Advance = 1.0f }, + }, + .AscenderY = 1.0f, + .DescenderY = 0.0f, + }); + } +} + +TEST(FontManagerTest, MeasureWrappedBreaksAtWhitespace) +{ + const auto font = CreateTestFont(); + std::vector lines; + + const glm::vec2 size = FontManager::MeasureWrapped("aa aa", font, 10.0f, 25.0f, &lines); + + EXPECT_EQ(lines, (std::vector{ "aa", "aa" })); + EXPECT_EQ(size, glm::vec2(20.0f, 20.0f)); +} + +TEST(FontManagerTest, MeasureWrappedBreaksOverlongWords) +{ + const auto font = CreateTestFont(); + std::vector lines; + + const glm::vec2 size = FontManager::MeasureWrapped("aaa", font, 10.0f, 15.0f, &lines); + + EXPECT_EQ(lines, (std::vector{ "a", "a", "a" })); + EXPECT_EQ(size, glm::vec2(10.0f, 30.0f)); +} + +TEST(FontManagerTest, MeasureWrappedBreaksAtZeroWidth) +{ + const auto font = CreateTestFont(); + std::vector lines; + + const glm::vec2 size = FontManager::MeasureWrapped("aaa", font, 10.0f, 0.0f, &lines); + + EXPECT_EQ(lines, (std::vector{ "a", "a", "a" })); + EXPECT_EQ(size, glm::vec2(10.0f, 30.0f)); +} + +TEST(FontManagerTest, MeasureWrappedDoesNotSplitUtf8Characters) +{ + const auto font = CreateTestFont(); + const std::string accented = "\xC3\xA9"; + std::vector lines; + + const glm::vec2 size = FontManager::MeasureWrapped( + accented + accented, + font, + 10.0f, + 15.0f, + &lines + ); + + EXPECT_EQ(lines, (std::vector{ accented, accented })); + EXPECT_EQ(size, glm::vec2(10.0f, 20.0f)); +} + +TEST(FontManagerTest, MeasureWrappedPreservesExplicitAndEmptyLines) +{ + const auto font = CreateTestFont(); + std::vector lines; + + const glm::vec2 size = FontManager::MeasureWrapped("a\n\nb", font, 10.0f, 100.0f, &lines); + + EXPECT_EQ(lines, (std::vector{ "a", "", "b" })); + EXPECT_EQ(size, glm::vec2(10.0f, 30.0f)); +} + +TEST(FontManagerTest, MeasureWrappedPreservesLeadingWhitespace) +{ + const auto font = CreateTestFont(); + std::vector lines; + + const glm::vec2 size = FontManager::MeasureWrapped(" a\n\ta", font, 10.0f, 100.0f, &lines); + + EXPECT_EQ(lines, (std::vector{ " a", "\ta" })); + EXPECT_EQ(size, glm::vec2(15.0f, 20.0f)); +} diff --git a/Elixir/Tests/Engine/GUI/AnimationTest.cpp b/Elixir/Tests/Engine/GUI/AnimationTest.cpp new file mode 100644 index 00000000..f3f71167 --- /dev/null +++ b/Elixir/Tests/Engine/GUI/AnimationTest.cpp @@ -0,0 +1,255 @@ +#include + +#include +#include + +using namespace Elixir; +using namespace Elixir::GUI; +using namespace Elixir::GUI::Util; + +namespace +{ + class AnimatedLeaf final : public Widget + { + public: + using Widget::CollectDrawCommands; + + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 20.0f, 20.0f }; } + + void BuildDrawCommands(RenderBatch& batch, const int zOrder) override + { + batch.AddBrush(GetResolvedAppearance().Background, GetGeometry(), zOrder); + } + }; + + class AnimatedContainer final : public ContentWidget + { + public: + using Widget::CollectDrawCommands; + + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override + { + return HasContent() ? GetContentSlot()->GetWidget()->Measure(availableSize) : glm::vec2{}; + } + + void LayoutChildren(const SRect& allocatedSpace) override + { + if (HasContent()) + GetContentSlot()->GetWidget()->ArrangeChildren(allocatedSpace); + } + }; +} + +TEST(AnimationTest, CurveSamplesLinearKeyframes) +{ + AnimationCurve curve; + curve.AddKey({ .Time = 0.0f, .Value = 0.0f }); + curve.AddKey({ .Time = 0.05f, .Value = 0.0f }); + curve.AddKey({ .Time = 0.15f, .Value = 1.0f }); + + EXPECT_FLOAT_EQ(curve.Sample(0.025f), 0.0f); + EXPECT_FLOAT_EQ(curve.Sample(0.10f), 0.5f); + EXPECT_FLOAT_EQ(curve.Sample(1.0f), 1.0f); +} + +TEST(AnimationTest, AnimatorAppliesCurveValues) +{ + AnimationCurve curve([](const SOutline& from, const SOutline& to, const float amount) + { + return Interpolate(from, to, amount); + }); + curve.AddKey({ .Time = 0.0f, .Value = { { 0.0f, 0.0f, 0.0f, 0.0f }, 0.0f } }); + curve.AddKey({ .Time = 0.1f, .Value = { { 1.0f, 0.5f, 0.25f, 1.0f }, 2.0f } }); + + Animator animator; + SOutline value{}; + animator.Bind(curve, [&value](const SOutline& next) { value = next; }); + animator.Update(Timestep(0.05f)); + + EXPECT_EQ(value.Color, SColor(0.5f, 0.25f, 0.125f, 0.5f)); + EXPECT_FLOAT_EQ(value.Thickness, 1.0f); +} + +TEST(AnimationTest, AnimatorCompletionCanBindAnotherAnimation) +{ + AnimationCurve curve; + curve.AddKey({ .Time = 0.0f, .Value = 0.0f }); + curve.AddKey({ .Time = 0.1f, .Value = 1.0f }); + + Animator animator; + int completed = 0; + animator.Bind(curve, [](const float&) {}, [&] + { + ++completed; + animator.Bind(curve, [](const float&) {}, [&] { ++completed; }); + }); + + animator.Update(Timestep(0.1f)); + + EXPECT_EQ(completed, 1); + EXPECT_TRUE(animator.IsAnimating()); + + animator.Update(Timestep(0.1f)); + + EXPECT_EQ(completed, 2); + EXPECT_FALSE(animator.IsAnimating()); +} + +TEST(AnimationTest, AnimatorCompletionCanStopAnotherAnimation) +{ + AnimationCurve curve; + curve.AddKey({ .Time = 0.0f, .Value = 0.0f }); + curve.AddKey({ .Time = 0.1f, .Value = 1.0f }); + + Animator animator; + int completed = 0; + Animator::AnimationId second = 0; + animator.Bind(curve, [](const float&) {}, [&] + { + ++completed; + animator.Stop(second); + }); + second = animator.Bind(curve, [](const float&) {}, [&] { ++completed; }); + + animator.Update(Timestep(0.1f)); + + EXPECT_EQ(completed, 1); + EXPECT_FALSE(animator.IsAnimating()); +} + +TEST(AnimationTest, AnimatorCompletionCanStopAllAnimations) +{ + AnimationCurve curve; + curve.AddKey({ .Time = 0.0f, .Value = 0.0f }); + curve.AddKey({ .Time = 0.1f, .Value = 1.0f }); + + Animator animator; + int completed = 0; + animator.Bind(curve, [](const float&) {}, [&] + { + ++completed; + animator.StopAll(); + }); + animator.Bind(curve, [](const float&) {}, [&] { ++completed; }); + + animator.Update(Timestep(0.1f)); + + EXPECT_EQ(completed, 1); + EXPECT_FALSE(animator.IsAnimating()); +} + +TEST(AnimationTest, AnimatorIgnoresNestedUpdates) +{ + AnimationCurve curve; + curve.AddKey({ .Time = 0.0f, .Value = 0.0f }); + curve.AddKey({ .Time = 0.1f, .Value = 1.0f }); + + Animator animator; + float outerValue = 0.0f; + float nestedValue = 0.0f; + bool requestedNestedUpdate = false; + animator.Bind(curve, [&](const float value) + { + outerValue = value; + if (requestedNestedUpdate || value == 0.0f) return; + + requestedNestedUpdate = true; + animator.Bind(curve, [&](const float nested) { nestedValue = nested; }); + animator.Update(Timestep(0.1f)); + }); + + animator.Update(Timestep(0.05f)); + + EXPECT_FLOAT_EQ(outerValue, 0.5f); + EXPECT_FLOAT_EQ(nestedValue, 0.0f); + + animator.Update(Timestep(0.05f)); + + EXPECT_FLOAT_EQ(nestedValue, 0.5f); +} + +TEST(AnimationTest, WidgetAnimationUpdatesWidgetPropertiesOutsideWidget) +{ + const auto widget = CreateRef(); + WidgetAnimation animation(widget); + + AnimationCurve opacity; + opacity.AddKey({ .Time = 0.0f, .Value = 1.0f }); + opacity.AddKey({ .Time = 0.1f, .Value = 0.0f }); + animation.AddTrack(opacity, [](Widget& target, const float value) { target.SetOpacity(value); }); + + AnimationCurve offset; + offset.AddKey({ .Time = 0.0f, .Value = {} }); + offset.AddKey({ .Time = 0.1f, .Value = { 12.0f, 0.0f } }); + animation.AddTrack(offset, [](Widget& target, const glm::vec2& value) { target.SetRenderOffset(value); }); + + animation.Play(); + widget->Update(Timestep(0.05f)); + EXPECT_FLOAT_EQ(widget->GetOpacity(), 1.0f); + + animation.Update(Timestep(0.05f)); + EXPECT_FLOAT_EQ(widget->GetOpacity(), 0.5f); + EXPECT_EQ(widget->GetRenderOffset(), glm::vec2(6.0f, 0.0f)); +} + +TEST(AnimationTest, WidgetAnimationCanAnimateStyleProperties) +{ + const auto widget = CreateRef(); + WidgetAnimation animation(widget); + + AnimationCurve outline([](const SOutline& from, const SOutline& to, const float amount) + { + return Interpolate(from, to, amount); + }); + outline.AddKey({ .Time = 0.0f, .Value = { { 0.0f, 0.0f, 0.0f, 1.0f }, 0.0f } }); + outline.AddKey({ .Time = 0.1f, .Value = { { 1.0f, 1.0f, 1.0f, 1.0f }, 2.0f } }); + animation.AddTrack(outline, [](Widget& target, const SOutline& value) + { + target.SetOutline(EStyleLayer::Normal, value); + }); + + animation.Play(); + animation.Update(Timestep(0.05f)); + + EXPECT_FLOAT_EQ(widget->GetStyle().Normal.Background.Outline.Thickness, 1.0f); + EXPECT_EQ(widget->GetStyle().Normal.Background.Outline.Color, SColor(0.5f, 0.5f, 0.5f, 1.0f)); +} + +TEST(AnimationTest, FinishedAnimationCanApplyTheFinalVisibility) +{ + const auto widget = CreateRef(); + WidgetAnimation animation(widget); + AnimationCurve opacity; + opacity.AddKey({ .Time = 0.0f, .Value = 1.0f }); + opacity.AddKey({ .Time = 0.1f, .Value = 0.0f }); + animation.AddTrack(opacity, [](Widget& target, const float value) { target.SetOpacity(value); }); + animation.OnFinished([widget] { widget->SetVisibility(EVisibility::Collapsed); }); + + widget->SetVisibility(EVisibility::HitTestInvisible); + animation.Play(); + animation.Update(Timestep(0.1f)); + + EXPECT_EQ(widget->GetVisibility(), EVisibility::Collapsed); +} + +TEST(AnimationTest, ParentOpacityAppliesToChildCommands) +{ + const auto root = CreateRef(); + const auto child = CreateRef(); + SWidgetStyle style; + style.Normal.Background.Color = { 1.0f, 1.0f, 1.0f, 1.0f }; + child->SetStyle(style); + root->SetContent(child); + root->ArrangeChildren({ {}, { 100.0f, 100.0f } }); + root->SetOpacity(0.5f); + + RenderBatch batch; + int zOrder = 0; + bool rebuilt = false; + root->CollectDrawCommands(batch, zOrder, rebuilt, {{ -1, -1 }, { -1, -1 }}); + + ASSERT_EQ(batch.GetCommands().size(), 1u); + EXPECT_FLOAT_EQ(batch.GetCommands().front().Color.A, 0.5f); +} diff --git a/Elixir/Tests/Engine/GUI/CanvasTest.cpp b/Elixir/Tests/Engine/GUI/CanvasTest.cpp new file mode 100644 index 00000000..d10823ec --- /dev/null +++ b/Elixir/Tests/Engine/GUI/CanvasTest.cpp @@ -0,0 +1,70 @@ +#include +using namespace testing; + +#include +using namespace Elixir; +using namespace Elixir::GUI; + +TEST(CanvasTest, DefaultDesiredSizeIs100x100) +{ + const auto canvas = CreateRef(); + const glm::vec2 desired = canvas->Measure({ UnconstrainedSize, UnconstrainedSize }); + + EXPECT_FLOAT_EQ(desired.x, 100.0f); + EXPECT_FLOAT_EQ(desired.y, 100.0f); +} + +TEST(CanvasTest, SetSizeOverridesTheDefault) +{ + const auto canvas = CreateRef(); + canvas->SetSize({ 320.0f, 240.0f }); + + const glm::vec2 desired = canvas->Measure({ UnconstrainedSize, UnconstrainedSize }); + EXPECT_FLOAT_EQ(desired.x, 320.0f); + EXPECT_FLOAT_EQ(desired.y, 240.0f); +} + +TEST(CanvasTest, SetSizeMarksLayoutDirty) +{ + const auto canvas = CreateRef(); + canvas->ArrangeChildren({ { 0, 0 }, { 100, 100 } }); + ASSERT_FALSE(canvas->IsLayoutDirty()); + + canvas->SetSize({ 320.0f, 240.0f }); + EXPECT_TRUE(canvas->IsLayoutDirty()); +} + +TEST(CanvasTest, SettingSameSizeDoesNotInvalidate) +{ + const auto canvas = CreateRef(); + canvas->SetSize({ 320.0f, 240.0f }); + canvas->ArrangeChildren({ { 0, 0 }, { 100, 100 } }); + ASSERT_FALSE(canvas->IsLayoutDirty()); + + canvas->SetSize({ 320.0f, 240.0f }); + EXPECT_FALSE(canvas->IsLayoutDirty()); +} + +TEST(CanvasTest, DesiredSizeIsClampedToAvailableSpace) +{ + const auto canvas = CreateRef(); + canvas->SetSize({ 320.0f, 240.0f }); + + // The parent only has 100x80 to offer - Canvas must not ask for more than that, same + // rule ScrollBox follows, even though its own configured size is bigger. + const glm::vec2 desired = canvas->Measure({ 100.0f, 80.0f }); + EXPECT_FLOAT_EQ(desired.x, 100.0f); + EXPECT_FLOAT_EQ(desired.y, 80.0f); +} + +TEST(CanvasTest, DesiredSizeIsUnaffectedByUnconstrainedAvailableSpace) +{ + const auto canvas = CreateRef(); + canvas->SetSize({ 320.0f, 240.0f }); + + // UnconstrainedSize on an axis means "no limit from the parent" - min() against it must + // be a no-op, so the configured size still comes through unclamped. + const glm::vec2 desired = canvas->Measure({ UnconstrainedSize, UnconstrainedSize }); + EXPECT_FLOAT_EQ(desired.x, 320.0f); + EXPECT_FLOAT_EQ(desired.y, 240.0f); +} diff --git a/Elixir/Tests/Engine/GUI/CheckboxTest.cpp b/Elixir/Tests/Engine/GUI/CheckboxTest.cpp new file mode 100644 index 00000000..07a8d267 --- /dev/null +++ b/Elixir/Tests/Engine/GUI/CheckboxTest.cpp @@ -0,0 +1,126 @@ +#include +using namespace testing; + +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +namespace +{ + // Checkbox's own promoted surface: HandleMouseDown and HandleClick are protected + // overrides with no public equivalent, so this test double promotes them the same way + // ScrollBoxTest.cpp/ForEachChildTest.cpp promote other protected members. + class TestCheckbox final : public Checkbox + { + public: + using Checkbox::HandleMouseDown; + using Checkbox::HandleClick; + }; +} + +TEST(CheckboxTest, DefaultsToUnchecked) +{ + const auto checkbox = CreateRef(); + EXPECT_FALSE(checkbox->GetChecked()); +} + +TEST(CheckboxTest, ClickTogglesAndFiresCallbackExactlyOnce) +{ + const auto checkbox = CreateRef(); + + int callCount = 0; + bool lastValue = false; + checkbox->OnCheckedChanged([&](const bool checked) + { + ++callCount; + lastValue = checked; + }); + + // Manager only calls HandleClick() after a HandleMouseDown it accepted is followed by a + // matching HandleMouseUp on the same widget (see Manager::ProcessMouseRelease) - calling + // it directly here exercises exactly that contract without needing a full Manager/event + // round trip. + checkbox->HandleClick(); + + EXPECT_TRUE(checkbox->GetChecked()); + EXPECT_EQ(callCount, 1); + EXPECT_TRUE(lastValue); +} + +TEST(CheckboxTest, SecondClickTogglesBackAndFiresAgain) +{ + const auto checkbox = CreateRef(); + + int callCount = 0; + checkbox->OnCheckedChanged([&](bool) { ++callCount; }); + + checkbox->HandleClick(); + checkbox->HandleClick(); + + EXPECT_FALSE(checkbox->GetChecked()); + EXPECT_EQ(callCount, 2); +} + +TEST(CheckboxTest, SetCheckedProgrammaticallyDoesNotFireCallback) +{ + const auto checkbox = CreateRef(); + + int callCount = 0; + checkbox->OnCheckedChanged([&](bool) { ++callCount; }); + + checkbox->SetChecked(true); + + EXPECT_TRUE(checkbox->GetChecked()); + EXPECT_EQ(callCount, 0) + << "SetChecked is the programmatic sync path - firing the callback here would let " + "external state that syncs INTO this checkbox echo straight back out again"; +} + +TEST(CheckboxTest, SetCheckedToSameValueIsANoOp) +{ + const auto checkbox = CreateRef(); + + const uint64_t before = Widget::CurrentDirtyEpoch(); + checkbox->SetChecked(false); // already false + EXPECT_EQ(Widget::CurrentDirtyEpoch(), before); +} + +TEST(CheckboxTest, ToggleAdvancesDirtyEpoch) +{ + const auto checkbox = CreateRef(); + + const uint64_t before = Widget::CurrentDirtyEpoch(); + checkbox->HandleClick(); // toggles false -> true, must MarkRenderDirty() + EXPECT_GT(Widget::CurrentDirtyEpoch(), before); +} + +TEST(CheckboxTest, DisabledCheckboxIgnoresMouseDown) +{ + const auto checkbox = CreateRef(); + checkbox->SetEnabled(false); + + const MouseButtonPressedEvent event(0, glm::vec2{ 0.0f, 0.0f }); + const SInputReply reply = checkbox->HandleMouseDown(event); + + EXPECT_FALSE(reply.EventHandled) + << "a disabled checkbox must never become Manager::m_PressedWidget, or HandleClick " + "would still run for it on the matching mouse-up"; +} + +TEST(CheckboxTest, DisabledCheckboxClickDoesNotToggleOrFireCallback) +{ + const auto checkbox = CreateRef(); + checkbox->SetEnabled(false); + + int callCount = 0; + checkbox->OnCheckedChanged([&](bool) { ++callCount; }); + + // Exercises HandleClick()'s own guard directly (see Checkbox.cpp) - covers the case where + // SetEnabled(false) runs after Manager already latched this widget as m_PressedWidget from + // an earlier mouse-down, so HandleMouseDown's own gate above never gets a say. + checkbox->HandleClick(); + + EXPECT_FALSE(checkbox->GetChecked()); + EXPECT_EQ(callCount, 0); +} diff --git a/Elixir/Tests/Engine/GUI/ClipStackTest.cpp b/Elixir/Tests/Engine/GUI/ClipStackTest.cpp new file mode 100644 index 00000000..73201c77 --- /dev/null +++ b/Elixir/Tests/Engine/GUI/ClipStackTest.cpp @@ -0,0 +1,230 @@ +#include +using namespace testing; + +#include "ManagerTestUtils.h" + +#include +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +namespace +{ + // Leaf that emits a single rect tagged with `color` at its own geometry, with no ad-hoc + // ScissorRect of its own - the default AddRect scissor sentinel {-1,-1}/{-1,-1} (see + // RenderBatch::AddRect) - so a test can tell purely-inherited clip from a self-clip. + class ProbeWidget final : public Widget + { + public: + explicit ProbeWidget(const SColor& color, const glm::vec2& desired = { 20.0f, 20.0f }) + : m_Color(color), m_Desired(desired) {} + + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return m_Desired; } + + protected: + void BuildDrawCommands(RenderBatch& batch, const int zOrder) override + { + batch.AddRect(m_Geometry, m_Color, glm::vec4(0.0f), glm::vec4(0.0f), glm::vec4(0.0f), SOutline{}, zOrder); + } + + private: + SColor m_Color; + glm::vec2 m_Desired; + }; + + // Same as ProbeWidget, but emits its own ad-hoc self-clip scissor - the Button/TextField + // pattern of clipping their own label to their own bounds - so a test can check that an + // inherited clip narrows it further instead of replacing it. + class SelfClippingProbeWidget final : public Widget + { + public: + explicit SelfClippingProbeWidget(const SColor& color) : m_Color(color) {} + + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 20.0f, 20.0f }; } + + protected: + void BuildDrawCommands(RenderBatch& batch, const int zOrder) override + { + batch.AddRect(m_Geometry, m_Color, glm::vec4(0.0f), glm::vec4(0.0f), glm::vec4(0.0f), SOutline{}, zOrder, m_Geometry); + } + + private: + SColor m_Color; + }; + + // Minimal container that clips its children to its own geometry, without any of + // ScrollBox's scrolling/scrollbar machinery - isolates clip propagation itself. Deliberately + // does not lay out its children (Panel/TPanel provide no default layout of their own - + // VerticalBox is the one that positions slots): tests that care about a child's own + // geometry arrange it directly, so a child's geometry here is always independent of the + // box's, which is exactly what the self-clip-vs-inherited-clip test needs. + class ClippingBox final : public TPanel + { + public: + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } + + protected: + bool ClipsChildren() const override { return true; } + }; + + void Arrange(const Ref& widget, const SRect& space) + { + widget->ArrangeChildren(space); + } + + // Finds the command tagged with `color`; nullptr if none matched. Callers ASSERT_NE the + // result before dereferencing, so a missing probe fails with a clear gtest message instead + // of a null-deref. + const SDrawCommand* FindByColor(const std::vector& commands, const SColor& color) + { + for (const auto& cmd : commands) + { + if (cmd.Color == color) + return &cmd; + } + return nullptr; + } +} + +TEST(ClipStackTest, NonClippingContainerLeavesChildScissorInvalid) +{ + const auto box = CreateRef(); + const SColor probeColor(1.0f, 0.0f, 0.0f, 1.0f); + const auto probe = CreateRef(probeColor); + box->AddChild(probe); + + Arrange(box, { { 0, 0 }, { 100, 100 } }); + + TestGUIManager manager; + manager.SetRoot(box); + manager.AssembleFrame(); + + const auto* cmd = FindByColor(manager.GetRenderBatch().GetCommands(), probeColor); + ASSERT_NE(cmd, nullptr); + EXPECT_FALSE(cmd->ScissorRect.IsValid()); +} + +TEST(ClipStackTest, ClippingContainerGivesChildScissorEqualToItsGeometry) +{ + const auto box = CreateRef(); + const SColor probeColor(0.0f, 1.0f, 0.0f, 1.0f); + const auto probe = CreateRef(probeColor); + box->AddChild(probe); + + Arrange(box, { { 5, 5 }, { 50, 50 } }); + + TestGUIManager manager; + manager.SetRoot(box); + manager.AssembleFrame(); + + const auto* cmd = FindByColor(manager.GetRenderBatch().GetCommands(), probeColor); + ASSERT_NE(cmd, nullptr); + ASSERT_TRUE(cmd->ScissorRect.IsValid()); + EXPECT_EQ(cmd->ScissorRect.Position, box->GetGeometry().Position); + EXPECT_EQ(cmd->ScissorRect.Size, box->GetGeometry().Size); +} + +TEST(ClipStackTest, NestedClipsIntersectRatherThanReplace) +{ + // Outer clips to a big rect, inner (offset, smaller) clips further -> the child's final + // scissor must be the intersection, strictly smaller than the outer clip alone. + const auto outer = CreateRef(); + const auto inner = CreateRef(); + const SColor probeColor(0.0f, 0.0f, 1.0f, 1.0f); + const auto probe = CreateRef(probeColor); + + outer->AddChild(inner); + inner->AddChild(probe); + + Arrange(outer, { { 0, 0 }, { 100, 100 } }); + inner->ArrangeChildren({ { 20, 20 }, { 30, 30 } }); + + TestGUIManager manager; + manager.SetRoot(outer); + manager.AssembleFrame(); + + const auto* cmd = FindByColor(manager.GetRenderBatch().GetCommands(), probeColor); + ASSERT_NE(cmd, nullptr); + ASSERT_TRUE(cmd->ScissorRect.IsValid()); + + const SRect expected = SRect::Intersect(outer->GetGeometry(), inner->GetGeometry()); + EXPECT_EQ(cmd->ScissorRect.Position, expected.Position); + EXPECT_EQ(cmd->ScissorRect.Size, expected.Size); + + // Sanity: the intersection is genuinely smaller than the outer clip alone, otherwise this + // test would pass even if nesting silently replaced instead of intersecting. + EXPECT_LT(cmd->ScissorRect.Size.x, outer->GetGeometry().Size.x); + EXPECT_LT(cmd->ScissorRect.Size.y, outer->GetGeometry().Size.y); +} + +TEST(ClipStackTest, InheritedClipNarrowsExistingSelfClipInsteadOfReplacingIt) +{ + const auto box = CreateRef(); + const SColor probeColor(1.0f, 1.0f, 0.0f, 1.0f); + const auto probe = CreateRef(probeColor); + box->AddChild(probe); + + // ClippingBox never re-arranges its children (see its comment above), so the probe's own + // geometry - and therefore its self-clip - is set here directly and stays fixed regardless + // of what happens to the box afterwards; only the box's own geometry (the inherited clip) + // changes below. + Arrange(box, { { 0, 0 }, { 100, 100 } }); + probe->ArrangeChildren({ { 0, 0 }, { 40, 40 } }); + + TestGUIManager manager; + manager.SetRoot(box); + manager.AssembleFrame(); + + // Box is bigger than the probe -> self-clip == probe geometry; intersecting with the + // inherited (larger) box clip should leave it exactly at the probe's own geometry. + const auto* cmd = FindByColor(manager.GetRenderBatch().GetCommands(), probeColor); + ASSERT_NE(cmd, nullptr); + ASSERT_TRUE(cmd->ScissorRect.IsValid()); + EXPECT_EQ(cmd->ScissorRect.Position, probe->GetGeometry().Position); + EXPECT_EQ(cmd->ScissorRect.Size, probe->GetGeometry().Size); + + // Now narrow the box below the probe's own geometry: the inherited clip must actually + // shrink the self-clip, not be discarded in favor of it. + box->ArrangeChildren({ { 0, 0 }, { 5, 5 } }); + manager.AssembleFrame(); + + const auto* cmd2 = FindByColor(manager.GetRenderBatch().GetCommands(), probeColor); + ASSERT_NE(cmd2, nullptr); + ASSERT_TRUE(cmd2->ScissorRect.IsValid()); + EXPECT_EQ(cmd2->ScissorRect.Size.x, 5.0f); + EXPECT_EQ(cmd2->ScissorRect.Size.y, 5.0f); +} + +// Direct regression test for a real bug: ScrollBox::ClipsChildren() once returned false by +// mistake, and nothing caught it. Pin both halves of the contract: the flag itself, and the +// end-to-end effect (oversized content actually gets a non-empty ScissorRect after a frame). +TEST(ClipStackTest, ScrollBoxClipsChildrenIsTrueAndOversizedContentGetsScissored) +{ + // Manager::SetRoot takes a Panel; ScrollBox is a ContentWidget, not a Panel, so it needs + // a trivial Panel host - a VerticalBox filling the same rect - the same way any real UI + // would embed a ScrollBox inside a layout container. + const auto root = CreateRef(); + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + root->AddChild(scrollBox).SetHorizontalAlignment(EHorizontalAlignment::Fill) + .SetVerticalAlignment(EVerticalAlignment::Fill); + + const SColor probeColor(1.0f, 0.0f, 1.0f, 1.0f); + const auto content = CreateRef(probeColor, glm::vec2{ 200.0f, 200.0f }); // larger than the viewport + scrollBox->SetContent(content); + + Arrange(root, { { 0, 0 }, { 50, 50 } }); + + TestGUIManager manager; + manager.SetRoot(root); + manager.AssembleFrame(); + + const auto* cmd = FindByColor(manager.GetRenderBatch().GetCommands(), probeColor); + ASSERT_NE(cmd, nullptr); + ASSERT_TRUE(cmd->ScissorRect.IsValid()) << "ScrollBox::ClipsChildren() must clip its content"; + EXPECT_GT(cmd->ScissorRect.Size.x, 0.0f); + EXPECT_GT(cmd->ScissorRect.Size.y, 0.0f); + EXPECT_EQ(cmd->ScissorRect.Size.x, scrollBox->GetGeometry().Size.x); + EXPECT_EQ(cmd->ScissorRect.Size.y, scrollBox->GetGeometry().Size.y); +} diff --git a/Elixir/Tests/Engine/GUI/DirtyTrackingTest.cpp b/Elixir/Tests/Engine/GUI/DirtyTrackingTest.cpp index 4e0ec924..0d55976d 100644 --- a/Elixir/Tests/Engine/GUI/DirtyTrackingTest.cpp +++ b/Elixir/Tests/Engine/GUI/DirtyTrackingTest.cpp @@ -72,7 +72,7 @@ TEST(DirtyTrackingTest, LayoutSetterInvalidatesButVisualSetterDoesNot) ASSERT_FALSE(child->IsLayoutDirty()); // Background color is purely visual (redrawn every frame) -> no relayout. - child->SetBackground(SColor(1.0f, 0.0f, 0.0f, 1.0f)); + child->SetBackgroundColor(EStyleLayer::Normal, SColor(1.0f, 0.0f, 0.0f, 1.0f)); EXPECT_FALSE(child->IsLayoutDirty()); EXPECT_FALSE(root->IsLayoutDirty()); } @@ -147,33 +147,24 @@ TEST(DirtyTrackingTest, SettingSameVisibilityDoesNotInvalidate) EXPECT_TRUE(root->IsLayoutDirty()); } -TEST(DirtyTrackingTest, StretchToggleInvalidatesLayout) +TEST(DirtyTrackingTest, SizeRuleChangeInvalidatesLayout) { const auto root = CreateRef(); const auto child = CreateRef(); - root->AddChild(child); + LayoutSlot& slot = root->AddChild(child); Arrange(root, { { 0, 0 }, { 100, 100 } }); ASSERT_FALSE(root->IsLayoutDirty()); - // Toggling stretch changes how children are sized -> must invalidate layout. - root->SetStretching(!root->IsStretching()); + // Changing how a slot is sized changes the owner's layout -> must invalidate. + // NOTE: unlike the old panel-level m_Stretching (removed by this point), LayoutSlot's + // setters have no "same value" guard, so there is no per-slot equivalent of the old + // SettingSameStretchDoesNotInvalidate test to keep. Judgment call, flagged for review + // in the refactor plan (Docs/GUI-Refactor/04-slot-sizing.html, section 6). + slot.SetFillSize(); EXPECT_TRUE(root->IsLayoutDirty()); } -TEST(DirtyTrackingTest, SettingSameStretchDoesNotInvalidate) -{ - const auto root = CreateRef(); - root->AddChild(CreateRef()); - - Arrange(root, { { 0, 0 }, { 100, 100 } }); - ASSERT_FALSE(root->IsLayoutDirty()); - - // Same value -> guard prevents needless invalidation. - root->SetStretching(root->IsStretching()); - EXPECT_FALSE(root->IsLayoutDirty()); -} - TEST(DirtyTrackingTest, SlotMetadataSetterInvalidatesOwnerNotChild) { const auto root = CreateRef(); diff --git a/Elixir/Tests/Engine/GUI/DrawCacheTest.cpp b/Elixir/Tests/Engine/GUI/DrawCacheTest.cpp index da4e0571..4eacadc9 100644 --- a/Elixir/Tests/Engine/GUI/DrawCacheTest.cpp +++ b/Elixir/Tests/Engine/GUI/DrawCacheTest.cpp @@ -19,7 +19,7 @@ namespace public: int BuildCount = 0; - glm::vec2 ComputeDesiredSize() override { return {}; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } using Widget::MarkRenderDirty; @@ -34,7 +34,7 @@ namespace public: LayeredWidget(const SColor& color, const int layers) : m_Color(color), m_Layers(layers) {} - glm::vec2 ComputeDesiredSize() override { return {}; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } protected: void BuildDrawCommands(RenderBatch& batch, const int zOrder) override @@ -102,8 +102,7 @@ TEST(DrawCacheTest, GeometryChangeRebuildsCache) { const auto box = CreateRef(); const auto child = CreateRef(); - box->SetStretching(true); // the child width tracks the box width - box->AddChild(child); + box->AddChild(child).SetHorizontalAlignment(EHorizontalAlignment::Fill); // the child width tracks the box width Arrange(box, { { 0, 0 }, { 100, 100 } }); AssembleFrame(box); diff --git a/Elixir/Tests/Engine/GUI/FocusTest.cpp b/Elixir/Tests/Engine/GUI/FocusTest.cpp new file mode 100644 index 00000000..a944f98e --- /dev/null +++ b/Elixir/Tests/Engine/GUI/FocusTest.cpp @@ -0,0 +1,356 @@ +#include +using namespace testing; + +#include "ManagerTestUtils.h" + +#include +#include +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +namespace +{ + // Minimal leaf used to populate a focus order - SetFocusable is public on Widget, so + // no subclassing is needed just to opt a widget into Tab navigation (unlike + // ScrollBoxTest.cpp's TestScrollBox, which promotes protected overrides). + class FocusLeaf final : public Widget + { + public: + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 10.0f, 10.0f }; } + }; + + // Records whether it ever received a key event, and always reports it as handled - the + // base Widget's default HandleKeyPressed is unconditionally Unhandled, so a plain + // FocusLeaf can't tell "the event never reached me" apart from "it reached me and I + // chose not to handle it". + class KeyRecordingLeaf final : public Widget + { + public: + bool ReceivedKeyPressed = false; + + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 10.0f, 10.0f }; } + + protected: + SInputReply HandleKeyPressed(const KeyPressedEvent&) override + { + ReceivedKeyPressed = true; + return SInputReply::Handled(); + } + }; + + KeyPressedEvent TabEvent(const bool shift = false) + { + return KeyPressedEvent(EE_KEY_TAB, 0, false, false, shift); + } + + KeyPressedEvent EscapeEvent() + { + return KeyPressedEvent(EE_KEY_ESCAPE, 0, false, false, false); + } +} + +TEST(FocusTest, TabVisitsOnlyFocusableVisibleWidgetsInChildOrder) +{ + const auto root = CreateRef(); + + const auto a = CreateRef(); + a->SetFocusable(true); + + const auto notFocusable = CreateRef(); + // notFocusable never calls SetFocusable - default is false (Widget.h). + + const auto hidden = CreateRef(); + hidden->SetFocusable(true); + hidden->SetVisibility(EVisibility::Hidden); + + const auto collapsed = CreateRef(); + collapsed->SetFocusable(true); + collapsed->SetVisibility(EVisibility::Collapsed); + + const auto b = CreateRef(); + b->SetFocusable(true); + + root->AddChild(a); + root->AddChild(notFocusable); + root->AddChild(hidden); + root->AddChild(collapsed); + root->AddChild(b); + + TestGUIManager manager; + manager.SetRoot(root); + + // Nothing focused yet: Tab must land on the first focusable widget in child order (a), + // not b or either of the skipped ones. + manager.HandleKeyPressed(TabEvent()); + EXPECT_TRUE(a->IsFocused()); + EXPECT_FALSE(b->IsFocused()); + + // From a, the next reachable widget is b - notFocusable/hidden/collapsed are all skipped. + manager.HandleKeyPressed(TabEvent()); + EXPECT_FALSE(a->IsFocused()); + EXPECT_TRUE(b->IsFocused()); + EXPECT_FALSE(notFocusable->IsFocused()); + EXPECT_FALSE(hidden->IsFocused()); + EXPECT_FALSE(collapsed->IsFocused()); +} + +TEST(FocusTest, TabWrapsAroundFromLastToFirst) +{ + const auto root = CreateRef(); + + const auto a = CreateRef(); + a->SetFocusable(true); + const auto b = CreateRef(); + b->SetFocusable(true); + + root->AddChild(a); + root->AddChild(b); + + TestGUIManager manager; + manager.SetRoot(root); + + manager.SetFocusedWidget(b); // start at the last focusable widget + + manager.HandleKeyPressed(TabEvent()); + EXPECT_TRUE(a->IsFocused()) << "Tab from the last focusable widget must wrap to the first"; + EXPECT_FALSE(b->IsFocused()); +} + +TEST(FocusTest, ShiftTabWrapsAroundFromFirstToLast) +{ + const auto root = CreateRef(); + + const auto a = CreateRef(); + a->SetFocusable(true); + const auto b = CreateRef(); + b->SetFocusable(true); + + root->AddChild(a); + root->AddChild(b); + + TestGUIManager manager; + manager.SetRoot(root); + + manager.SetFocusedWidget(a); // start at the first focusable widget + + manager.HandleKeyPressed(TabEvent(/*shift=*/true)); + EXPECT_TRUE(b->IsFocused()) << "Shift+Tab from the first focusable widget must wrap to the last"; + EXPECT_FALSE(a->IsFocused()); +} + +TEST(FocusTest, EscapeClearsFocus) +{ + const auto root = CreateRef(); + const auto a = CreateRef(); + a->SetFocusable(true); + root->AddChild(a); + + TestGUIManager manager; + manager.SetRoot(root); + manager.SetFocusedWidget(a); + ASSERT_TRUE(a->IsFocused()); + + manager.HandleKeyPressed(EscapeEvent()); + EXPECT_FALSE(a->IsFocused()); +} + +TEST(FocusTest, RemovingAPopupClearsItsFocusedWidget) +{ + const auto root = CreateRef(); + const auto popup = CreateRef(); + const auto focused = CreateRef(); + popup->AddChild(focused); + + TestGUIManager manager; + manager.SetRoot(root); + manager.PushPopup(popup, { { 0.0f, 0.0f }, { 10.0f, 10.0f } }); + manager.SetFocusedWidget(focused); + ASSERT_TRUE(focused->IsFocused()); + + manager.PopPopup(); + EXPECT_FALSE(focused->IsFocused()); + + manager.HandleKeyPressed(KeyPressedEvent(EE_KEY_A, 0, false, false, false)); + EXPECT_FALSE(focused->ReceivedKeyPressed); +} + +TEST(FocusTest, ReplacingTheRootClearsThePreviousFocus) +{ + const auto firstRoot = CreateRef(); + const auto focused = CreateRef(); + focused->SetFocusable(true); + firstRoot->AddChild(focused); + + TestGUIManager manager; + manager.SetRoot(firstRoot); + manager.SetFocusedWidget(focused); + ASSERT_TRUE(focused->IsFocused()); + + manager.SetRoot(CreateRef()); + EXPECT_FALSE(focused->IsFocused()); +} + +TEST(FocusTest, NonInteractiveRootDoesNotClaimTheMouse) +{ + const auto root = CreateRef(); + root->ArrangeChildren({ { 0.0f, 0.0f }, { 100.0f, 100.0f } }); + + TestGUIManager manager; + manager.SetRoot(root); + + MouseMovedEvent moved(50.0f, 50.0f); + InputManager::OnEvent(moved); + manager.Update(Timestep(0.0f)); + + EXPECT_FALSE(manager.WantsMouse()); +} + +// The central guarantee behind scoping BuildFocusOrder to the topmost layer: once a popup is +// open, Tab must never reach a widget that sits underneath it, even though that widget is +// still focusable and still in the tree - only PopPopup (or ClearPopups) can bring it back +// into reach. Same idiom PopupLayerTest.cpp uses to prove popups draw above the root: build +// content in two layers and check that behavior stays confined to the active one. +TEST(FocusTest, TabStaysScopedInsideOpenPopup) +{ + const auto root = CreateRef(); + const auto rootWidget = CreateRef(); + rootWidget->SetFocusable(true); + root->AddChild(rootWidget); + + const auto popupRoot = CreateRef(); + const auto popupWidget = CreateRef(); + popupWidget->SetFocusable(true); + popupRoot->AddChild(popupWidget); + + TestGUIManager manager; + manager.SetRoot(root); + manager.PushPopup(popupRoot, { { 0, 0 }, { 10, 10 } }); + + // Nothing focused yet, topmost layer is the popup: Tab must land inside it, never on the + // root layer's widget underneath. + manager.HandleKeyPressed(TabEvent()); + EXPECT_TRUE(popupWidget->IsFocused()); + EXPECT_FALSE(rootWidget->IsFocused()); + + // With only one focusable widget in the popup, Tab keeps cycling back to it - it must + // never "spill over" into the root layer's widget. + manager.HandleKeyPressed(TabEvent()); + EXPECT_TRUE(popupWidget->IsFocused()); + EXPECT_FALSE(rootWidget->IsFocused()); +} + +// Regression guard: SetFocusedWidget(nullptr) on a hit path with nothing in it - the +// existing "clicked outside" behavior ProcessMousePress already had before this point +// (Manager.cpp) - must keep working now that focus is also driven from HandleKeyPressed. +TEST(FocusTest, ClickOutsideStillClearsFocus) +{ + const auto root = CreateRef(); + const auto a = CreateRef(); + a->SetFocusable(true); + root->AddChild(a); + + TestGUIManager manager; + manager.SetRoot(root); + manager.SetFocusedWidget(a); + ASSERT_TRUE(a->IsFocused()); + + manager.ProcessMousePress({}); // empty hit path: nobody under the cursor + EXPECT_FALSE(a->IsFocused()); +} + +TEST(FocusTest, TabWithNoFocusableWidgetsIsANoOp) +{ + const auto root = CreateRef(); + root->AddChild(CreateRef()); // never made focusable + + TestGUIManager manager; + manager.SetRoot(root); + + EXPECT_NO_FATAL_FAILURE(manager.HandleKeyPressed(TabEvent())); +} + +// FocusNext/FocusPrevious deliberately don't fall back to SetFocusedWidget(nullptr) when the +// scoped order is empty (see the comment on Manager::FocusNext) - otherwise opening a popup +// with no focusable content of its own, then pressing Tab, would silently steal focus away +// from whatever was focused in the layer underneath, for no reason the user asked for. +TEST(FocusTest, TabInPopupWithNoFocusableContentLeavesOuterFocusUntouched) +{ + const auto root = CreateRef(); + const auto rootWidget = CreateRef(); + rootWidget->SetFocusable(true); + root->AddChild(rootWidget); + + // A popup whose only content is not focusable - the interesting case is not "no popup", + // it's "popup exists and is the topmost layer, but contributes nothing to the order". + const auto popupRoot = CreateRef(); + popupRoot->AddChild(CreateRef()); // never made focusable + + TestGUIManager manager; + manager.SetRoot(root); + manager.SetFocusedWidget(rootWidget); + manager.PushPopup(popupRoot, { { 0, 0 }, { 10, 10 } }); + ASSERT_TRUE(rootWidget->IsFocused()); + + manager.HandleKeyPressed(TabEvent()); + EXPECT_TRUE(rootWidget->IsFocused()) + << "Tab in a popup with nothing focusable must not clear focus in the layer below it"; +} + +// SetEnabled(false) has no way to clear m_FocusedWidget - Widget has no back-reference to +// the Manager - so a widget disabled while focused stays focused, just Disabled-styled (see +// StyleSet's precedence). Its "stops accepting input events" contract still has to hold for +// the keyboard, or a disabled TextField that was focused before being disabled would keep +// taking keystrokes. +TEST(FocusTest, DisablingTheFocusedWidgetStopsItFromReceivingKeyEvents) +{ + const auto root = CreateRef(); + const auto a = CreateRef(); + a->SetFocusable(true); + root->AddChild(a); + + TestGUIManager manager; + manager.SetRoot(root); + manager.SetFocusedWidget(a); + + manager.HandleKeyPressed(KeyPressedEvent(EE_KEY_A, 0, false, false, false)); + ASSERT_TRUE(a->ReceivedKeyPressed) << "sanity check: an enabled focused widget must receive key events"; + a->ReceivedKeyPressed = false; + + a->SetEnabled(false); + ASSERT_TRUE(a->IsFocused()) << "sanity check: disabling does not itself clear focus"; + + manager.HandleKeyPressed(KeyPressedEvent(EE_KEY_A, 0, false, false, false)); + EXPECT_FALSE(a->ReceivedKeyPressed) + << "a disabled widget must not receive key events even while it is still m_FocusedWidget"; +} + +// Mirrors the mouse-click case (Widget::HandleMouseDown already refuses a disabled widget): +// Tab must not be able to reach somewhere a click already can't. +TEST(FocusTest, TabSkipsADisabledFocusableWidget) +{ + const auto root = CreateRef(); + + const auto a = CreateRef(); + a->SetFocusable(true); + + const auto disabled = CreateRef(); + disabled->SetFocusable(true); + disabled->SetEnabled(false); + + const auto b = CreateRef(); + b->SetFocusable(true); + + root->AddChild(a); + root->AddChild(disabled); + root->AddChild(b); + + TestGUIManager manager; + manager.SetRoot(root); + manager.SetFocusedWidget(a); + + manager.HandleKeyPressed(TabEvent()); + EXPECT_TRUE(b->IsFocused()) << "Tab from a must skip the disabled widget and land on b"; + EXPECT_FALSE(disabled->IsFocused()); +} diff --git a/Elixir/Tests/Engine/GUI/ForEachChildTest.cpp b/Elixir/Tests/Engine/GUI/ForEachChildTest.cpp index c7857a37..982c3149 100644 --- a/Elixir/Tests/Engine/GUI/ForEachChildTest.cpp +++ b/Elixir/Tests/Engine/GUI/ForEachChildTest.cpp @@ -12,7 +12,7 @@ namespace class LeafWidget final : public Widget { public: - glm::vec2 ComputeDesiredSize() override { return {}; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } using Widget::ForEachChild; }; @@ -20,23 +20,18 @@ namespace class ContentTestWidget final : public ContentWidget { public: - glm::vec2 ComputeDesiredSize() override { return {}; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } using ContentWidget::ForEachChild; }; // Minimal multi-child container exercising Panel::ForEachChild. - // VerticalBox is final, so we drive the Panel-level override directly, mirroring - // VerticalBox::AddChild (push a LayoutSlot + AttachChild). - class PanelTestWidget final : public Panel + // VerticalBox is final, so we drive TPanel directly instead - the same base + // VerticalBox itself now uses. It already provides AddChild (see Panel.h), so this no + // longer needs to hand-roll the push_back/AttachChild pair Panel::m_Slots used to allow. + class PanelTestWidget final : public TPanel { public: - glm::vec2 ComputeDesiredSize() override { return {}; } - - void AddChild(const Ref& child) - { - m_Slots.push_back(CreateRef(child)); - AttachChild(child); - } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } using Panel::ForEachChild; }; diff --git a/Elixir/Tests/Engine/GUI/IconTest.cpp b/Elixir/Tests/Engine/GUI/IconTest.cpp new file mode 100644 index 00000000..ae166ffc --- /dev/null +++ b/Elixir/Tests/Engine/GUI/IconTest.cpp @@ -0,0 +1,43 @@ +#include +using namespace testing; + +#include "ManagerTestUtils.h" + +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +TEST(IconTest, IconStartsAsASelfHitTestInvisibleVisual) +{ + const GUI::Icon icon; + + EXPECT_EQ(icon.GetVisibility(), EVisibility::SelfHitTestInvisible); +} + +TEST(IconStyleTest, RequestedStateMaterializesFromNormal) +{ + SIconStyle style; + style.Get(EStyleLayer::Normal).Foreground = { 1.0f, 0.0f, 0.0f, 1.0f }; + style.Get(EStyleLayer::Hovered).Foreground = { 0.0f, 1.0f, 0.0f, 1.0f }; + + ASSERT_TRUE(style.Hovered); + EXPECT_EQ(style.Normal.Foreground, SColor(1.0f, 0.0f, 0.0f, 1.0f)); + EXPECT_EQ(style.Hovered->Foreground, SColor(0.0f, 1.0f, 0.0f, 1.0f)); +} + +TEST(IconTest, ColorSetterMarksTheIconForRerender) +{ + const auto icon = CreateRef(); + const auto root = CreateRef(); + root->AddChild(icon); + + TestGUIManager manager; + manager.SetRoot(root); + manager.AssembleFrame(); + ASSERT_FALSE(icon->IsRenderDirty()); + + icon->SetColor(EStyleLayer::Hovered, { 0.0f, 1.0f, 0.0f, 1.0f }); + + EXPECT_TRUE(icon->IsRenderDirty()); +} diff --git a/Elixir/Tests/Engine/GUI/InvalidationTest.cpp b/Elixir/Tests/Engine/GUI/InvalidationTest.cpp index 5d8b7ec1..87408211 100644 --- a/Elixir/Tests/Engine/GUI/InvalidationTest.cpp +++ b/Elixir/Tests/Engine/GUI/InvalidationTest.cpp @@ -13,15 +13,21 @@ namespace class SizedLeaf final : public Widget { public: - glm::vec2 ComputeDesiredSize() override { return m_DesiredSize; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return m_FakeSize; } void SetDesiredSize(const glm::vec2& size) { - m_DesiredSize = size; + m_FakeSize = size; MarkLayoutDirty(); } using Widget::MarkRenderDirty; + + private: + // m_DesiredSize no longer exists on Widget — Measure() now owns the desired-size + // cache (m_CachedDesiredSize) exclusively, so SetDesiredSize drives this + // widget-local copy instead of writing the cache directly. + glm::vec2 m_FakeSize{}; }; // Minimal single-child widget to exercise ContentWidget lifecycle without Button's @@ -29,7 +35,7 @@ namespace class TestContent final : public ContentWidget { public: - glm::vec2 ComputeDesiredSize() override { return { 10.0f, 10.0f }; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 10.0f, 10.0f }; } }; void Arrange(const Ref& widget, const SRect& space) @@ -53,7 +59,7 @@ TEST(InvalidationTest, VisualSetterMarksRenderDirtyNotLayout) ASSERT_FALSE(box->IsLayoutDirty()); ASSERT_FALSE(box->IsRenderDirty()); - box->SetBackground(SColor(1.0f, 0.0f, 0.0f, 1.0f)); + box->SetBackgroundColor(EStyleLayer::Normal, SColor(1.0f, 0.0f, 0.0f, 1.0f)); EXPECT_TRUE(box->IsRenderDirty()); EXPECT_FALSE(box->IsLayoutDirty()); } diff --git a/Elixir/Tests/Engine/GUI/ManagerTestUtils.h b/Elixir/Tests/Engine/GUI/ManagerTestUtils.h index d487a101..d23e583b 100644 --- a/Elixir/Tests/Engine/GUI/ManagerTestUtils.h +++ b/Elixir/Tests/Engine/GUI/ManagerTestUtils.h @@ -12,5 +12,14 @@ namespace using Manager::AssembleFrame; using Manager::NeedsRebuild; using Manager::MarkRebuilt; + + // Focus surface: SetFocusedWidget/ProcessMousePress/HandleKeyPressed are private + // (Tab/Shift+Tab/Escape are only reachable through HandleKeyPressed; a real mouse + // press would need InputManager's static polling state, which ProcessMousePress lets + // a test skip by taking the hit path directly). Promoted the same way + // AssembleFrame/NeedsRebuild/MarkRebuilt already are above. + using Manager::SetFocusedWidget; + using Manager::ProcessMousePress; + using Manager::HandleKeyPressed; }; -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/GUI/PopupLayerTest.cpp b/Elixir/Tests/Engine/GUI/PopupLayerTest.cpp new file mode 100644 index 00000000..20dcda9b --- /dev/null +++ b/Elixir/Tests/Engine/GUI/PopupLayerTest.cpp @@ -0,0 +1,180 @@ +#include +using namespace testing; + +#include "ManagerTestUtils.h" + +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +namespace +{ + // Leaf that emits a single rect tagged with `color` at its own geometry - lets a test + // locate which layer's command a given draw came from. Same idiom as + // DrawCacheTest.cpp's LayeredWidget, kept local since PopupLayerTest only needs one rect + // per widget (no z-band spanning). + class ProbeWidget final : public Widget + { + public: + explicit ProbeWidget(const SColor& color) : m_Color(color) {} + + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 20.0f, 20.0f }; } + + protected: + void BuildDrawCommands(RenderBatch& batch, const int zOrder) override + { + batch.AddRect(m_Geometry, m_Color, glm::vec4(0.0f), glm::vec4(0.0f), glm::vec4(0.0f), SOutline{}, zOrder); + } + + private: + SColor m_Color; + }; + + // Minimal container that clips its children to its own geometry - used to check that a + // clip established on one layer does not leak into another (each layer starts + // CollectDrawCommands with the invalid sentinel clip; see Manager::AssembleFrame). + class ClippingBox final : public TPanel + { + public: + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } + + protected: + bool ClipsChildren() const override { return true; } + }; + + const SDrawCommand* FindByColor(const std::vector& commands, const SColor& color) + { + for (const auto& cmd : commands) + { + if (cmd.Color == color) + return &cmd; + } + return nullptr; + } +} + +TEST(PopupLayerTest, PopupCountStartsAtZeroAfterSetRoot) +{ + const auto root = CreateRef(); + + Manager manager; // no promotion needed: SetRoot/GetPopupCount/PushPopup/PopPopup/ClearPopups are public + manager.SetRoot(root); + + EXPECT_EQ(manager.GetPopupCount(), 0u); +} + +TEST(PopupLayerTest, PushAndPopPopupTracksCount) +{ + const auto root = CreateRef(); + const auto popup = CreateRef(); + + Manager manager; + manager.SetRoot(root); + + manager.PushPopup(popup, { { 0, 0 }, { 10, 10 } }); + EXPECT_EQ(manager.GetPopupCount(), 1u); + + manager.PopPopup(); + EXPECT_EQ(manager.GetPopupCount(), 0u); +} + +TEST(PopupLayerTest, PopPopupOnEmptyStackIsNoOp) +{ + const auto root = CreateRef(); + + Manager manager; + manager.SetRoot(root); + ASSERT_EQ(manager.GetPopupCount(), 0u); + + // Only the root layer exists; PopPopup must never touch it (see SLayer's doc comment). + manager.PopPopup(); + EXPECT_EQ(manager.GetPopupCount(), 0u); +} + +TEST(PopupLayerTest, ClearPopupsZeroesCountWithMultiplePopupsStacked) +{ + const auto root = CreateRef(); + + Manager manager; + manager.SetRoot(root); + + manager.PushPopup(CreateRef(), { { 0, 0 }, { 10, 10 } }); + manager.PushPopup(CreateRef(), { { 0, 0 }, { 10, 10 } }); + manager.PushPopup(CreateRef(), { { 0, 0 }, { 10, 10 } }); + ASSERT_EQ(manager.GetPopupCount(), 3u); + + manager.ClearPopups(); + EXPECT_EQ(manager.GetPopupCount(), 0u); +} + +TEST(PopupLayerTest, PushPopupArrangesImmediatelyAgainstLastExtent) +{ + const auto root = CreateRef(); + const auto popup = CreateRef(SColor(1.0f, 0.0f, 0.0f, 1.0f)); + + Manager manager; + manager.SetRoot(root); + manager.ArrangeLayout({ 800, 600 }); // establishes the extent PushPopup arranges against + + manager.PushPopup(popup, { { 100, 100 }, { 0, 0 } }); + + // Documented behavior: "Arranged immediately against the last extent ArrangeLayout ran + // with", so the popup already has real geometry before any further ArrangeLayout call. + EXPECT_NE(popup->GetGeometry().Size, glm::vec2(0.0f, 0.0f)); +} + +// The central guarantee behind the whole popup-layer feature: a popup must always draw above +// the root, regardless of what either one contains. Same technique as DrawCacheTest.cpp's +// SiblingSubtreesGetDisjointOrderedZBands - tag each layer's content with a distinct color and +// compare the ZOrder the assembled batch actually gave it. +TEST(PopupLayerTest, PopupCommandsHaveStrictlyHigherZOrderThanRootCommands) +{ + const auto root = CreateRef(); + const SColor rootColor(1.0f, 0.0f, 0.0f, 1.0f); + root->AddChild(CreateRef(rootColor)); + + const auto popup = CreateRef(SColor(0.0f, 1.0f, 0.0f, 1.0f)); + + TestGUIManager manager; + manager.SetRoot(root); + manager.ArrangeLayout({ 800, 600 }); + manager.PushPopup(popup, { { 0, 0 }, { 0, 0 } }); + + manager.AssembleFrame(); + + const auto& commands = manager.GetRenderBatch().GetCommands(); + const auto* rootCmd = FindByColor(commands, rootColor); + const auto* popupCmd = FindByColor(commands, SColor(0.0f, 1.0f, 0.0f, 1.0f)); + + ASSERT_NE(rootCmd, nullptr); + ASSERT_NE(popupCmd, nullptr); + EXPECT_GT(popupCmd->ZOrder, rootCmd->ZOrder); +} + +// Each layer's CollectDrawCommands walk starts from the invalid sentinel clip (see +// Manager::AssembleFrame), so a clipping container in one layer must never narrow another +// layer's commands. +TEST(PopupLayerTest, ClipOnOneLayerDoesNotLeakIntoAnother) +{ + const auto root = CreateRef(); + const SColor rootColor(1.0f, 0.0f, 0.0f, 1.0f); + root->AddChild(CreateRef(rootColor)); + + const SColor popupColor(0.0f, 1.0f, 0.0f, 1.0f); + const auto popup = CreateRef(popupColor); + + TestGUIManager manager; + manager.SetRoot(root); + manager.ArrangeLayout({ 20, 20 }); // small root -> a leaked clip would be obviously tiny + manager.PushPopup(popup, { { 0, 0 }, { 0, 0 } }); + + manager.AssembleFrame(); + + const auto& commands = manager.GetRenderBatch().GetCommands(); + const auto* popupCmd = FindByColor(commands, popupColor); + + ASSERT_NE(popupCmd, nullptr); + EXPECT_FALSE(popupCmd->ScissorRect.IsValid()) + << "the root layer's clip must not apply to the popup layer"; +} diff --git a/Elixir/Tests/Engine/GUI/RenderBatchTest.cpp b/Elixir/Tests/Engine/GUI/RenderBatchTest.cpp index 2d58cda0..6a7bb41c 100644 --- a/Elixir/Tests/Engine/GUI/RenderBatchTest.cpp +++ b/Elixir/Tests/Engine/GUI/RenderBatchTest.cpp @@ -2,6 +2,7 @@ using namespace testing; #include +#include using namespace Elixir; using namespace Elixir::GUI; @@ -29,3 +30,51 @@ TEST(RenderBatchTest, LayerSpanIsHighestZOrderPlusOne) EXPECT_EQ(batch.LayerSpan(), 3); } + +TEST(RenderBatchTest, DebugCommandsDoNotAdvanceTheLayerSpan) +{ + RenderBatch batch; + batch.AddDebugRect(SRect{}); + + EXPECT_EQ(batch.LayerSpan(), 0); + + AddRectAt(batch, 2); + EXPECT_EQ(batch.LayerSpan(), 3); +} + +TEST(RenderBatchTest, DebugCommandsSortAboveEveryNormalCommand) +{ + RenderBatch batch; + batch.AddDebugRect(SRect{}); + AddRectAt(batch, 1'000); + batch.Sort(); + + ASSERT_EQ(batch.GetCommands().size(), 2u); + EXPECT_EQ(batch.GetCommands().back().Type, EDrawCommandType::DebugRect); +} + +TEST(RenderBatchTest, AppendingDebugCommandsDoesNotOverflowTheirZOrder) +{ + RenderBatch source; + source.AddDebugRect(SRect{}); + + RenderBatch destination; + destination.Append(source, 10'000, { { -1.0f, -1.0f }, { -1.0f, -1.0f } }); + + ASSERT_EQ(destination.GetCommands().size(), 1u); + EXPECT_EQ(destination.GetCommands().front().ZOrder, 10'000); + EXPECT_FALSE(destination.GetCommands().front().ScissorRect.IsValid()); + EXPECT_EQ(destination.LayerSpan(), 0); +} + +TEST(RenderPassTest, GrowsBufferCapacityGeometrically) +{ + EXPECT_EQ(GrowBufferCapacity(10'000, 10'001), 20'000u); + EXPECT_EQ(GrowBufferCapacity(10'000, 25'000), 25'000u); +} + +TEST(RenderPassTest, KeepsSufficientBufferCapacity) +{ + EXPECT_EQ(GrowBufferCapacity(10'000, 8'000), 10'000u); + EXPECT_EQ(GrowBufferCapacity(0, 1), 1u); +} diff --git a/Elixir/Tests/Engine/GUI/RenderGateTest.cpp b/Elixir/Tests/Engine/GUI/RenderGateTest.cpp index bfde3614..c3af7f72 100644 --- a/Elixir/Tests/Engine/GUI/RenderGateTest.cpp +++ b/Elixir/Tests/Engine/GUI/RenderGateTest.cpp @@ -80,7 +80,7 @@ TEST(RenderGateTest, InvalidationAfterRebuildNeedsRebuild) manager.MarkRebuilt(); ASSERT_FALSE(manager.NeedsRebuild()); - box->SetBackground(SColor(1.0f, 0.0f, 0.0f, 1.0f)); // bumps the dirty epoch + box->SetBackgroundColor(EStyleLayer::Normal, SColor(1.0f, 0.0f, 0.0f, 1.0f)); // bumps the dirty epoch EXPECT_TRUE(manager.NeedsRebuild()); } diff --git a/Elixir/Tests/Engine/GUI/ScrollBoxTest.cpp b/Elixir/Tests/Engine/GUI/ScrollBoxTest.cpp new file mode 100644 index 00000000..635d23b9 --- /dev/null +++ b/Elixir/Tests/Engine/GUI/ScrollBoxTest.cpp @@ -0,0 +1,423 @@ +#include +using namespace testing; + +#include +#include +#include +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +namespace +{ + // Leaf widget whose desired size can be driven from the test - same pattern as + // WidgetTestUtils.h's CountingWidget, without pulling in its ArrangeCount bookkeeping. + class SizedLeaf final : public Widget + { + public: + explicit SizedLeaf(const glm::vec2& desired) : m_Desired(desired) {} + + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return m_Desired; } + + void SetDesiredSize(const glm::vec2& desired) + { + if (m_Desired == desired) return; + m_Desired = desired; + MarkLayoutDirty(); + } + + private: + glm::vec2 m_Desired; + }; + + class WidthDependentLeaf final : public Widget + { + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override + { + return { availableSize.x, availableSize.x > 200.0f ? 20.0f : 100.0f }; + } + }; + + class GutterSensitiveLeaf final : public Widget + { + protected: + glm::vec2 ComputeDesiredSize(const glm::vec2& availableSize) override + { + return availableSize.x < 100.0f + ? glm::vec2{ availableSize.x, 200.0f } + : glm::vec2{ availableSize.x, 20.0f }; + } + }; + + // ScrollBox's own promoted surface: HandleMouseScrolled and ClipsChildren are protected + // overrides with no public equivalent, so this test double promotes them the same way + // ForEachChildTest.cpp/WidgetLifetimeTest.cpp promote other protected members. + class TestScrollBox final : public ScrollBox + { + public: + using ScrollBox::ClipsChildren; + using ScrollBox::BuildDrawCommands; + using ScrollBox::CollectDrawCommands; + using ScrollBox::HandleMouseScrolled; + }; + + void Arrange(const Ref& widget, const SRect& space) + { + widget->ArrangeChildren(space); + } + + void BuildDrawCache(const Ref& widget) + { + RenderBatch batch; + int zOrder = 0; + bool rebuilt = false; + widget->CollectDrawCommands(batch, zOrder, rebuilt, {{ -1, -1 }, { -1, -1 }}); + } +} + +TEST(ScrollBoxTest, DesiredSizeNeverExceedsViewportEvenWithLargerContent) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + scrollBox->SetContent(CreateRef(glm::vec2{ 500.0f, 500.0f })); + + // GetDesiredSize() reads Measure()'s cache; ArrangeChildren alone never populates it + // (that's the parent's job when it measures this widget before arranging it), so the + // test has to call Measure directly, same as any real container would. + const glm::vec2 desired = scrollBox->Measure({ 1000.0f, 1000.0f }); + EXPECT_LE(desired.x, 50.0f); + EXPECT_LE(desired.y, 50.0f); +} + +TEST(ScrollBoxTest, DesiredSizeShrinksToContentWhenContentIsSmallerThanViewport) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 100.0f, 100.0f }); + scrollBox->SetContent(CreateRef(glm::vec2{ 30.0f, 20.0f })); + + const glm::vec2 desired = scrollBox->Measure({ 1000.0f, 1000.0f }); + EXPECT_EQ(desired.x, 30.0f); + EXPECT_EQ(desired.y, 20.0f); +} + +TEST(ScrollBoxTest, ContentThatFitsDoesNotReserveAScrollbarGutter) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 100.0f, 100.0f }); + const auto content = CreateRef(); + scrollBox->SetContent(content); + + const glm::vec2 desired = scrollBox->Measure({ 1000.0f, 1000.0f }); + EXPECT_EQ(desired, glm::vec2(100.0f, 20.0f)); + + Arrange(scrollBox, { { 0.0f, 0.0f }, desired }); + EXPECT_EQ(content->GetGeometry().Size.x, 100.0f); + + RenderBatch batch; + scrollBox->BuildDrawCommands(batch, 0); + EXPECT_TRUE(batch.GetCommands().empty()); +} + +TEST(ScrollBoxTest, DesiredSizeMeasuresContentAgainstTheConfiguredViewport) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 200.0f, 200.0f }); + scrollBox->SetContent(CreateRef()); + + const glm::vec2 desired = scrollBox->Measure({ 1000.0f, 1000.0f }); + + EXPECT_EQ(desired.y, 100.0f); +} + +TEST(ScrollBoxTest, DesiredSizePreservesTheActiveScrollbarGutter) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 100.0f, 100.0f }); + const auto content = CreateRef(glm::vec2{ 30.0f, 200.0f }); + scrollBox->SetContent(content); + + // The vertical scrollbar needs 8 pixels, so a 30-pixel-wide child requires a + // 38-pixel-wide ScrollBox. The child then receives its full 30-pixel width. + const glm::vec2 desired = scrollBox->Measure({ 1000.0f, 1000.0f }); + EXPECT_EQ(desired.x, 38.0f); + EXPECT_EQ(desired.y, 100.0f); + + Arrange(scrollBox, { { 0.0f, 0.0f }, desired }); + EXPECT_EQ(content->GetGeometry().Size.x, 30.0f); +} + +TEST(ScrollBoxTest, LayoutChildrenOffsetsContentByCurrentScrollOffset) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + scrollBox->SetScrollAxis(EScrollAxis::Both); // both axes must scroll for this to move X too + const auto content = CreateRef(glm::vec2{ 200.0f, 200.0f }); + scrollBox->SetContent(content); + + Arrange(scrollBox, { { 0, 0 }, { 50, 50 } }); + const glm::vec2 unscrolledPos = content->GetGeometry().Position; + + scrollBox->SetScrollOffset({ 10.0f, 15.0f }); + Arrange(scrollBox, { { 0, 0 }, { 50, 50 } }); // same rect, but offset changed -> not skipped + + const glm::vec2 scrolledPos = content->GetGeometry().Position; + EXPECT_EQ(scrolledPos.x, unscrolledPos.x - 10.0f); + EXPECT_EQ(scrolledPos.y, unscrolledPos.y - 15.0f); +} + +TEST(ScrollBoxTest, SetScrollOffsetClampsAboveMaxAndBelowZero) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + scrollBox->SetScrollAxis(EScrollAxis::Both); // both axes must scroll to clamp X too + scrollBox->SetContent(CreateRef(glm::vec2{ 200.0f, 150.0f })); + + Arrange(scrollBox, { { 0, 0 }, { 50, 50 } }); // establishes m_ContentSize used to clamp + + // Both scrollbars reserve 8 pixels, leaving a (42,42) content viewport. The final + // (8-pixel) gutter remains reachable rather than being clipped at (150,100). + scrollBox->SetScrollOffset({ 9999.0f, 9999.0f }); + EXPECT_EQ(scrollBox->GetScrollOffset().x, 158.0f); + EXPECT_EQ(scrollBox->GetScrollOffset().y, 108.0f); + + // Below zero clamps to zero. + scrollBox->SetScrollOffset({ -50.0f, -50.0f }); + EXPECT_EQ(scrollBox->GetScrollOffset().x, 0.0f); + EXPECT_EQ(scrollBox->GetScrollOffset().y, 0.0f); +} + +TEST(ScrollBoxTest, BothAxisScrollbarsUseTheGutterReducedViewport) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + scrollBox->SetScrollAxis(EScrollAxis::Both); + scrollBox->SetContent(CreateRef(glm::vec2{ 200.0f, 150.0f })); + Arrange(scrollBox, { { 0.0f, 0.0f }, { 50.0f, 50.0f } }); + scrollBox->SetScrollOffset({ 9999.0f, 9999.0f }); + + RenderBatch batch; + scrollBox->BuildDrawCommands(batch, 0); + + const auto& commands = batch.GetCommands(); + ASSERT_EQ(commands.size(), 4u); + + const SRect& verticalTrack = commands[0].Geometry; + const SRect& verticalThumb = commands[1].Geometry; + const SRect& horizontalTrack = commands[2].Geometry; + const SRect& horizontalThumb = commands[3].Geometry; + + EXPECT_EQ(verticalTrack.Position.x, 42.0f); + EXPECT_EQ(verticalTrack.Size.y, 42.0f); + EXPECT_EQ(horizontalTrack.Position.y, 42.0f); + EXPECT_EQ(horizontalTrack.Size.x, 42.0f); + EXPECT_FLOAT_EQ(verticalThumb.Position.y + verticalThumb.Size.y, 42.0f); + EXPECT_FLOAT_EQ(horizontalThumb.Position.x + horizontalThumb.Size.x, 42.0f); +} + +TEST(ScrollBoxTest, BothAxisScrollbarsAccountForGuttersIntroducedByEachOther) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 100.0f, 100.0f }); + scrollBox->SetScrollAxis(EScrollAxis::Both); + scrollBox->SetContent(CreateRef(glm::vec2{ 95.0f, 200.0f })); + Arrange(scrollBox, { { 0.0f, 0.0f }, { 100.0f, 100.0f } }); + + RenderBatch batch; + scrollBox->BuildDrawCommands(batch, 0); + + const auto& commands = batch.GetCommands(); + ASSERT_EQ(commands.size(), 4u); + EXPECT_EQ(commands[0].Geometry.Size.y, 92.0f); + EXPECT_EQ(commands[2].Geometry.Size.x, 92.0f); +} + +TEST(ScrollBoxTest, LayoutInvalidatesRenderingWhenContentSizeChanges) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + const auto content = CreateRef(glm::vec2{ 50.0f, 200.0f }); + scrollBox->SetContent(content); + Arrange(scrollBox, { { 0.0f, 0.0f }, { 50.0f, 50.0f } }); + + BuildDrawCache(scrollBox); + ASSERT_FALSE(scrollBox->IsRenderDirty()); + + content->SetDesiredSize({ 50.0f, 100.0f }); + Arrange(scrollBox, { { 0.0f, 0.0f }, { 50.0f, 50.0f } }); + + EXPECT_TRUE(scrollBox->IsRenderDirty()); +} + +TEST(ScrollBoxTest, ChangingScrollAxisInvalidatesRendering) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + scrollBox->SetContent(CreateRef(glm::vec2{ 200.0f, 200.0f })); + Arrange(scrollBox, { { 0.0f, 0.0f }, { 50.0f, 50.0f } }); + + BuildDrawCache(scrollBox); + ASSERT_FALSE(scrollBox->IsRenderDirty()); + + scrollBox->SetScrollAxis(EScrollAxis::Horizontal); + + EXPECT_TRUE(scrollBox->IsRenderDirty()); +} + +TEST(ScrollBoxTest, ChangingScrollbarVisibilityInvalidatesLayout) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + scrollBox->SetContent(CreateRef(glm::vec2{ 200.0f, 200.0f })); + Arrange(scrollBox, { { 0.0f, 0.0f }, { 50.0f, 50.0f } }); + + ASSERT_FALSE(scrollBox->IsLayoutDirty()); + scrollBox->SetShowScrollbar(false); + + EXPECT_TRUE(scrollBox->IsLayoutDirty()); + EXPECT_TRUE(scrollBox->IsRenderDirty()); +} + +TEST(ScrollBoxTest, HandleMouseScrolledMovesOffsetWithinBoundsAndReportsHandled) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + scrollBox->SetContent(CreateRef(glm::vec2{ 200.0f, 200.0f })); + Arrange(scrollBox, { { 0, 0 }, { 50, 50 } }); + + ASSERT_EQ(scrollBox->GetScrollOffset().y, 0.0f); + + // Positive wheel offset scrolls content up (offset increases), per ScrollBox's + // delta.y = -event.GetOffsetY() * SCROLL_SPEED convention. + const MouseScrolledEvent scrollDown(0.0f, -1.0f); + const SInputReply reply = scrollBox->HandleMouseScrolled(scrollDown); + + EXPECT_TRUE(reply.EventHandled); + EXPECT_GT(scrollBox->GetScrollOffset().y, 0.0f); +} + +TEST(ScrollBoxTest, HandleMouseScrolledAtEdgeIsUnhandledSoAnAncestorCanTry) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + // Content fits entirely within the viewport -> already at both scroll edges (offset 0, + // max offset 0), so any wheel delta must be rejected. + scrollBox->SetContent(CreateRef(glm::vec2{ 20.0f, 20.0f })); + Arrange(scrollBox, { { 0, 0 }, { 50, 50 } }); + + const MouseScrolledEvent scrollDown(0.0f, -1.0f); + const SInputReply reply = scrollBox->HandleMouseScrolled(scrollDown); + + EXPECT_FALSE(reply.EventHandled); + EXPECT_EQ(scrollBox->GetScrollOffset().y, 0.0f); +} + +TEST(ScrollBoxTest, WheelEventUsesTheCurrentPointerHitPath) +{ + const auto root = CreateRef(); + root->SetSize({ 200.0f, 100.0f }); + + const auto left = CreateRef(); + left->SetSize({ 100.0f, 100.0f }); + left->SetContent(CreateRef(glm::vec2{ 100.0f, 200.0f })); + root->AddChild(left).SetSize({ 100.0f, 100.0f }); + + const auto right = CreateRef(); + right->SetSize({ 100.0f, 100.0f }); + right->SetContent(CreateRef(glm::vec2{ 100.0f, 200.0f })); + root->AddChild(right).SetPosition({ 100.0f, 0.0f }).SetSize({ 100.0f, 100.0f }); + + root->ArrangeChildren({ { 0.0f, 0.0f }, { 200.0f, 100.0f } }); + + Manager manager; + manager.SetRoot(root); + + MouseMovedEvent moved(150.0f, 50.0f); + InputManager::OnEvent(moved); + + MouseScrolledEvent scrollDown(0.0f, -1.0f); + manager.ProcessEvent(scrollDown); + + EXPECT_EQ(left->GetScrollOffset().y, 0.0f); + EXPECT_GT(right->GetScrollOffset().y, 0.0f); +} + +TEST(ScrollBoxTest, ClipsChildrenIsTrue) +{ + const auto scrollBox = CreateRef(); + EXPECT_TRUE(scrollBox->ClipsChildren()); +} + +TEST(ScrollBarStyleTest, FallsBackToNormalAndKeepsSizingMetrics) +{ + SScrollBarStyle style; + style.Thickness = 12.0f; + style.MinimumThumbLength = 20.0f; + style.Normal.Thumb.Color = { 0.3f, 0.4f, 0.5f, 1.0f }; + + EXPECT_EQ(style.Thickness, 12.0f); + EXPECT_EQ(style.MinimumThumbLength, 20.0f); + EXPECT_EQ(style.Get(EStyleLayer::Hovered).Thumb.Color, SColor(0.3f, 0.4f, 0.5f, 1.0f)); +} + +TEST(ScrollBoxTest, SetStyleUpdatesExposedScrollbarProperties) +{ + const auto scrollBox = CreateRef(); + + SScrollBarStyle style; + style.Thickness = 12.0f; + style.MinimumThumbLength = 20.0f; + style.Normal.Thumb.Color = { 0.3f, 0.4f, 0.5f, 1.0f }; + scrollBox->SetStyle(style); + + EXPECT_EQ(scrollBox->GetScrollbarThickness(), 12.0f); + EXPECT_EQ(scrollBox->GetScrollbarColor(), SColor(0.3f, 0.4f, 0.5f, 1.0f)); +} + +TEST(ScrollBoxTest, MinimumThumbLengthNeverExceedsTheTrack) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 20.0f, 20.0f }); + scrollBox->SetScrollAxis(EScrollAxis::Both); + scrollBox->SetContent(CreateRef(glm::vec2{ 200.0f, 200.0f })); + + SScrollBarStyle style; + style.MinimumThumbLength = 100.0f; + scrollBox->SetStyle(style); + + Arrange(scrollBox, { { 0.0f, 0.0f }, { 20.0f, 20.0f } }); + scrollBox->SetScrollOffset({ 9999.0f, 9999.0f }); + + RenderBatch batch; + scrollBox->BuildDrawCommands(batch, 0); + const auto& commands = batch.GetCommands(); + ASSERT_EQ(commands.size(), 4u); + + const SRect& verticalTrack = commands[0].Geometry; + const SRect& verticalThumb = commands[1].Geometry; + const SRect& horizontalTrack = commands[2].Geometry; + const SRect& horizontalThumb = commands[3].Geometry; + + EXPECT_LE(verticalThumb.Size.y, verticalTrack.Size.y); + EXPECT_LE(verticalThumb.Position.y + verticalThumb.Size.y, verticalTrack.Position.y + verticalTrack.Size.y); + EXPECT_LE(horizontalThumb.Size.x, horizontalTrack.Size.x); + EXPECT_LE(horizontalThumb.Position.x + horizontalThumb.Size.x, horizontalTrack.Position.x + horizontalTrack.Size.x); +} + +TEST(ScrollBoxTest, HitTestExcludesScrolledContentOutsideTheViewport) +{ + const auto scrollBox = CreateRef(); + scrollBox->SetSize({ 50.0f, 50.0f }); + const auto content = CreateRef(glm::vec2{ 50.0f, 200.0f }); + scrollBox->SetContent(content); + + Arrange(scrollBox, { { 0.0f, 28.0f }, { 50.0f, 50.0f } }); + scrollBox->SetScrollOffset({ 0.0f, 20.0f }); + Arrange(scrollBox, { { 0.0f, 28.0f }, { 50.0f, 50.0f } }); + + std::vector> path; + scrollBox->HitTest({ 10.0f, 10.0f }, path); + + EXPECT_TRUE(path.empty()); +} diff --git a/Elixir/Tests/Engine/GUI/SlotSizingTest.cpp b/Elixir/Tests/Engine/GUI/SlotSizingTest.cpp new file mode 100644 index 00000000..f3ea2213 --- /dev/null +++ b/Elixir/Tests/Engine/GUI/SlotSizingTest.cpp @@ -0,0 +1,193 @@ +#include +using namespace testing; + +#include "WidgetTestUtils.h" + +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +// Regression coverage for a real bug: VerticalBox/HorizontalBox::ComputeDesiredSize summed +// each child's raw Measure() result regardless of its slot's SSizeParam rule, while +// LayoutChildren correctly used the slot's configured pixel size for Fixed children. A Fixed +// child whose content measures smaller than its configured size made the container (and +// anything reading its desired size, e.g. a ScrollBox wrapping it) under-report how much +// space it actually occupies - which is exactly how a real ScrollBox ended up thinking its +// content fit the viewport when it didn't, and silently refused to scroll. + +TEST(SlotSizingTest, VerticalBoxDesiredSizeUsesFixedSlotSizeNotMeasuredSize) +{ + const auto box = CreateRef(); + + // CountingWidget measures to 10x10 - far smaller than the 26px the slot is fixed to. + const auto a = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto b = CreateRef(glm::vec2{ 10.0f, 10.0f }); + box->AddChild(a).SetFixedSize(26.0f); + box->AddChild(b).SetFixedSize(26.0f); + + const glm::vec2 desired = box->Measure({ 100.0f, UnconstrainedSize }); + + // Bug: this used to be 20 (10 + 10, the measured heights) instead of 52 (26 + 26, the + // configured Fixed sizes) - the same undercount that made a real ScrollBox never scroll. + EXPECT_FLOAT_EQ(desired.y, 52.0f); +} + +TEST(SlotSizingTest, HorizontalBoxDesiredSizeUsesFixedSlotSizeNotMeasuredSize) +{ + const auto box = CreateRef(); + + const auto a = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto b = CreateRef(glm::vec2{ 10.0f, 10.0f }); + box->AddChild(a).SetFixedSize(26.0f); + box->AddChild(b).SetFixedSize(26.0f); + + const glm::vec2 desired = box->Measure({ UnconstrainedSize, 100.0f }); + + EXPECT_FLOAT_EQ(desired.x, 52.0f); +} + +TEST(SlotSizingTest, VerticalBoxDesiredSizeStillUsesMeasuredSizeForAutoSlots) +{ + const auto box = CreateRef(); + + // Auto is the default slot rule - no explicit SetAutoSize() call needed. + const auto a = CreateRef(glm::vec2{ 15.0f, 15.0f }); + const auto b = CreateRef(glm::vec2{ 15.0f, 15.0f }); + box->AddChild(a); + box->AddChild(b); + + const glm::vec2 desired = box->Measure({ 100.0f, UnconstrainedSize }); + + EXPECT_FLOAT_EQ(desired.y, 30.0f); +} + +TEST(SlotSizingTest, VerticalBoxDesiredSizeIgnoresFillSlotMeasuredSize) +{ + const auto box = CreateRef(); + + // A Fill slot has no intrinsic size - it stretches into whatever LayoutChildren hands + // it - so its measured size must not contribute to the container's own desired size. + const auto a = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto b = CreateRef(glm::vec2{ 500.0f, 500.0f }); + box->AddChild(a).SetAutoSize(); + box->AddChild(b).SetFillSize(); + + const glm::vec2 desired = box->Measure({ 100.0f, UnconstrainedSize }); + + EXPECT_FLOAT_EQ(desired.y, 10.0f); +} + +TEST(SlotSizingTest, VerticalBoxDesiredSizeRespectsSlotConstraints) +{ + const auto box = CreateRef(); + const auto fixed = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto automatic = CreateRef(glm::vec2{ 10.0f, 100.0f }); + const auto fill = CreateRef(glm::vec2{ 10.0f, 500.0f }); + + box->AddChild(fixed).SetFixedSize(26.0f).SetMinSize({ 0.0f, 50.0f }); + box->AddChild(automatic).SetMaxSize({ FLT_MAX, 40.0f }); + box->AddChild(fill) + .SetFillSize() + .SetMinSize({ 0.0f, 20.0f }) + .SetMargin(SMargin(0.0f, 5.0f)); + + const glm::vec2 desired = box->Measure({ 100.0f, UnconstrainedSize }); + + EXPECT_FLOAT_EQ(desired.y, 120.0f); +} + +TEST(SlotSizingTest, HorizontalBoxDesiredSizeRespectsSlotConstraints) +{ + const auto box = CreateRef(); + const auto fixed = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto automatic = CreateRef(glm::vec2{ 100.0f, 10.0f }); + const auto fill = CreateRef(glm::vec2{ 500.0f, 10.0f }); + + box->AddChild(fixed).SetFixedSize(26.0f).SetMinSize({ 50.0f, 0.0f }); + box->AddChild(automatic).SetMaxSize({ 40.0f, FLT_MAX }); + box->AddChild(fill) + .SetFillSize() + .SetMinSize({ 20.0f, 0.0f }) + .SetMargin(SMargin(5.0f, 0.0f)); + + const glm::vec2 desired = box->Measure({ UnconstrainedSize, 100.0f }); + + EXPECT_FLOAT_EQ(desired.x, 120.0f); +} + +TEST(SlotSizingTest, HorizontalBoxDistributesSpaceAfterConstrainedSlots) +{ + const auto box = CreateRef(); + const auto fixed = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto cappedFill = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto flexibleFill = CreateRef(glm::vec2{ 10.0f, 10.0f }); + + box->AddChild(fixed).SetFixedSize(10.0f).SetMinSize({ 50.0f, 0.0f }); + box->AddChild(cappedFill) + .SetFillSize() + .SetMinSize({ 20.0f, 0.0f }) + .SetMaxSize({ 20.0f, FLT_MAX }); + box->AddChild(flexibleFill).SetFillSize(); + + Arrange(box, { { 0.0f, 0.0f }, { 100.0f, 20.0f } }); + + EXPECT_FLOAT_EQ(fixed->GetGeometry().Size.x, 50.0f); + EXPECT_FLOAT_EQ(cappedFill->GetGeometry().Size.x, 20.0f); + EXPECT_FLOAT_EQ(flexibleFill->GetGeometry().Size.x, 30.0f); + EXPECT_FLOAT_EQ(flexibleFill->GetGeometry().Position.x + flexibleFill->GetGeometry().Size.x, 100.0f); +} + +TEST(SlotSizingTest, VerticalBoxDistributesSpaceAfterConstrainedSlots) +{ + const auto box = CreateRef(); + const auto fixed = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto cappedFill = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto flexibleFill = CreateRef(glm::vec2{ 10.0f, 10.0f }); + + box->AddChild(fixed).SetFixedSize(10.0f).SetMinSize({ 0.0f, 50.0f }); + box->AddChild(cappedFill) + .SetFillSize() + .SetMinSize({ 0.0f, 20.0f }) + .SetMaxSize({ FLT_MAX, 20.0f }); + box->AddChild(flexibleFill).SetFillSize(); + + Arrange(box, { { 0.0f, 0.0f }, { 20.0f, 100.0f } }); + + EXPECT_FLOAT_EQ(fixed->GetGeometry().Size.y, 50.0f); + EXPECT_FLOAT_EQ(cappedFill->GetGeometry().Size.y, 20.0f); + EXPECT_FLOAT_EQ(flexibleFill->GetGeometry().Size.y, 30.0f); + EXPECT_FLOAT_EQ(flexibleFill->GetGeometry().Position.y + flexibleFill->GetGeometry().Size.y, 100.0f); +} + +TEST(SlotSizingTest, HorizontalBoxKeepsMeasurementsAlignedAfterCollapsedSlot) +{ + const auto box = CreateRef(); + const auto collapsed = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto visible = CreateRef(glm::vec2{ 25.0f, 15.0f }); + collapsed->SetVisibility(EVisibility::Collapsed); + box->AddChild(collapsed); + box->AddChild(visible); + + Arrange(box, { { 0.0f, 0.0f }, { 100.0f, 40.0f } }); + + EXPECT_EQ(collapsed->ArrangeCount, 0); + EXPECT_EQ(visible->GetGeometry().Position, glm::vec2(0.0f, 12.5f)); + EXPECT_EQ(visible->GetGeometry().Size, glm::vec2(25.0f, 15.0f)); +} + +TEST(SlotSizingTest, VerticalBoxKeepsMeasurementsAlignedAfterCollapsedSlot) +{ + const auto box = CreateRef(); + const auto collapsed = CreateRef(glm::vec2{ 10.0f, 10.0f }); + const auto visible = CreateRef(glm::vec2{ 15.0f, 25.0f }); + collapsed->SetVisibility(EVisibility::Collapsed); + box->AddChild(collapsed); + box->AddChild(visible); + + Arrange(box, { { 0.0f, 0.0f }, { 40.0f, 100.0f } }); + + EXPECT_EQ(collapsed->ArrangeCount, 0); + EXPECT_EQ(visible->GetGeometry().Position, glm::vec2(12.5f, 0.0f)); + EXPECT_EQ(visible->GetGeometry().Size, glm::vec2(15.0f, 25.0f)); +} diff --git a/Elixir/Tests/Engine/GUI/StyleTest.cpp b/Elixir/Tests/Engine/GUI/StyleTest.cpp new file mode 100644 index 00000000..5834e34d --- /dev/null +++ b/Elixir/Tests/Engine/GUI/StyleTest.cpp @@ -0,0 +1,224 @@ +#include +using namespace testing; + +#include "ManagerTestUtils.h" + +#include +#include +#include +#include +#include +using namespace Elixir; +using namespace Elixir::GUI; + +namespace +{ + class StyleLeaf final : public Widget + { + public: + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 10.0f, 10.0f }; } + + using Widget::HandleMouseDown; + using Widget::HandleMouseEnter; + using Widget::HandleMouseLeave; + }; +} + +TEST(StyleTest, StateStylesResolveByInteractionPriority) +{ + TStateStyles styles; + styles.Normal.Foreground = { 1.0f, 0.0f, 0.0f, 1.0f }; + styles.Hovered.emplace().Foreground = { 0.0f, 1.0f, 0.0f, 1.0f }; + styles.Pressed.emplace().Foreground = { 0.0f, 0.0f, 1.0f, 1.0f }; + styles.Focused.emplace().Foreground = { 1.0f, 1.0f, 0.0f, 1.0f }; + styles.Disabled.emplace().Foreground = { 0.5f, 0.5f, 0.5f, 1.0f }; + + EXPECT_EQ(styles.Resolve(EInteractionState::None).Foreground, styles.Normal.Foreground); + EXPECT_EQ(styles.Resolve(EInteractionState::Hovered).Foreground, styles.Hovered->Foreground); + EXPECT_EQ( + styles.Resolve(EInteractionState::Hovered | EInteractionState::Pressed).Foreground, + styles.Pressed->Foreground + ); + EXPECT_EQ( + styles.Resolve(EInteractionState::Hovered | EInteractionState::Focused).Foreground, + styles.Focused->Foreground + ); + EXPECT_EQ( + styles.Resolve(EInteractionState::Hovered | EInteractionState::Pressed | EInteractionState::Disabled).Foreground, + styles.Disabled->Foreground + ); +} + +TEST(StyleTest, MissingInteractionAppearanceFallsBackToNormal) +{ + TStateStyles styles; + styles.Normal.Background.Color = { 1.0f, 0.0f, 0.0f, 1.0f }; + styles.Normal.Foreground = { 1.0f, 1.0f, 1.0f, 1.0f }; + + const SButtonAppearance& hovered = styles.Resolve(EInteractionState::Hovered); + + EXPECT_EQ(hovered.Background.Color, styles.Normal.Background.Color); + EXPECT_EQ(hovered.Foreground, styles.Normal.Foreground); +} + +TEST(StyleTest, CheckboxOwnsCheckedStateResolution) +{ + SCheckboxStyle styles; + styles.Normal.Background.Color = { 1.0f, 0.0f, 0.0f, 1.0f }; + styles.Checked.Background.Color = { 0.0f, 1.0f, 0.0f, 1.0f }; + styles.CheckedHovered.emplace().Background.Color = { 0.0f, 0.0f, 1.0f, 1.0f }; + styles.CheckedDisabled.emplace().Background.Color = { 0.5f, 0.5f, 0.5f, 1.0f }; + + EXPECT_EQ(styles.Resolve(false, EInteractionState::None).Background.Color, styles.Normal.Background.Color); + EXPECT_EQ(styles.Resolve(true, EInteractionState::None).Background.Color, styles.Checked.Background.Color); + EXPECT_EQ(styles.Resolve(true, EInteractionState::Hovered).Background.Color, styles.CheckedHovered->Background.Color); + EXPECT_EQ( + styles.Resolve(true, EInteractionState::Hovered | EInteractionState::Disabled).Background.Color, + styles.CheckedDisabled->Background.Color + ); +} + +TEST(StyleTest, MissingCheckedInteractionAppearanceFallsBackToChecked) +{ + SCheckboxStyle styles; + styles.Checked.Background.Color = { 0.0f, 1.0f, 0.0f, 1.0f }; + + const SAppearance& checkedHovered = styles.Resolve(true, EInteractionState::Hovered); + + EXPECT_EQ(checkedHovered.Background.Color, styles.Checked.Background.Color); +} + +TEST(StyleTest, ExplicitTransparentInteractionAppearanceDoesNotFallBack) +{ + TStateStyles styles; + styles.Normal.Background.Color = { 1.0f, 0.0f, 0.0f, 1.0f }; + styles.Hovered.emplace().Background.Color = { 0.0f, 0.0f, 0.0f, 0.0f }; + + const SAppearance& hovered = styles.Resolve(EInteractionState::Hovered); + + EXPECT_EQ(hovered.Background.Color, SColor(0.0f, 0.0f, 0.0f, 0.0f)); +} + +TEST(StyleTest, StyleSetStoresStylesByConcreteType) +{ + StyleSet styles; + SButtonStyle button; + button.Normal.Foreground = { 0.1f, 0.2f, 0.3f, 1.0f }; + + styles.SetWidgetStyle(button); + + const SButtonStyle& registered = styles.GetWidgetStyle(); + EXPECT_EQ(registered.Normal.Foreground, button.Normal.Foreground); +} + +TEST(StyleTest, MissingWidgetStyleReturnsAnEmptyFallback) +{ + const StyleSet styles; + + const SButtonStyle& fallback = styles.GetWidgetStyle(); + EXPECT_EQ(fallback.Normal.Foreground, SColor{}); +} + +TEST(StyleTest, WidgetOwnsTheStyleItReceives) +{ + StyleLeaf leaf; + SWidgetStyle style; + style.Normal.Background.Color = { 1.0f, 0.0f, 0.0f, 1.0f }; + + leaf.SetStyle(style); + leaf.SetBackgroundColor(EStyleLayer::Hovered, { 0.0f, 1.0f, 0.0f, 1.0f }); + + EXPECT_EQ(leaf.GetStyle().Normal.Background.Color, style.Normal.Background.Color); + ASSERT_TRUE(leaf.GetStyle().Hovered); + EXPECT_EQ(leaf.GetStyle().Hovered->Background.Color, SColor(0.0f, 1.0f, 0.0f, 1.0f)); +} + +TEST(StyleTest, PanelEmitsABrushForOutlineAndShadowWithoutFill) +{ + const auto panel = CreateRef(); + SWidgetStyle style; + style.Normal.Background.Outline = { { 1.0f, 0.0f, 0.0f, 1.0f }, 1.0f }; + style.Normal.Background.DropShadow = { 2.0f, 2.0f, 4.0f, 0.5f }; + panel->SetStyle(style); + panel->ArrangeChildren({ {}, { 100.0f, 100.0f } }); + + TestGUIManager manager; + manager.SetRoot(panel); + manager.AssembleFrame(); + + const auto& commands = manager.GetRenderBatch().GetCommands(); + ASSERT_EQ(commands.size(), 1u); + EXPECT_EQ(commands.front().Color.A, 0.0f); + EXPECT_EQ(commands.front().Outline.Color, style.Normal.Background.Outline.Color); + EXPECT_EQ(commands.front().Outline.Thickness, style.Normal.Background.Outline.Thickness); + EXPECT_EQ(commands.front().DropShadow, style.Normal.Background.DropShadow); +} + +TEST(StyleTest, LegacyBackgroundSetterMarksTheWidgetForRerender) +{ + const auto root = CreateRef(); + const auto leaf = CreateRef(); + root->AddChild(leaf); + + TestGUIManager manager; + manager.SetRoot(root); + manager.AssembleFrame(); + ASSERT_FALSE(leaf->IsRenderDirty()); + + leaf->SetBackgroundColor(EStyleLayer::Normal, { 1.0f, 0.0f, 0.0f, 1.0f }); + + EXPECT_TRUE(leaf->IsRenderDirty()); +} + +TEST(StyleTest, ComponentStyleReplacementMarksTheWidgetForRerender) +{ + const auto root = CreateRef(); + const auto checkbox = CreateRef(); + root->AddChild(checkbox); + + TestGUIManager manager; + manager.SetRoot(root); + manager.AssembleFrame(); + ASSERT_FALSE(checkbox->IsRenderDirty()); + + SCheckboxStyle style; + style.Normal.Background.Color = { 1.0f, 0.0f, 0.0f, 1.0f }; + checkbox->SetStyle(style); + + EXPECT_TRUE(checkbox->IsRenderDirty()); +} + +TEST(StyleTest, HoverPressAndEnabledChangesMarkTheWidgetForRerender) +{ + const auto root = CreateRef(); + const auto leaf = CreateRef(); + root->AddChild(leaf); + + TestGUIManager manager; + manager.SetRoot(root); + + manager.AssembleFrame(); + leaf->HandleMouseEnter(); + EXPECT_TRUE(leaf->IsRenderDirty()); + + manager.AssembleFrame(); + leaf->HandleMouseLeave(); + EXPECT_TRUE(leaf->IsRenderDirty()); + + manager.AssembleFrame(); + leaf->SetEnabled(false); + EXPECT_TRUE(leaf->IsRenderDirty()); +} + +TEST(StyleTest, DisablingBlocksInteractionThatWouldOtherwiseBeHandled) +{ + const auto leaf = CreateRef(); + leaf->OnClick([] {}); + + const MouseButtonPressedEvent event(0); + ASSERT_TRUE(leaf->HandleMouseDown(event).EventHandled); + + leaf->SetEnabled(false); + + EXPECT_FALSE(leaf->HandleMouseDown(event).EventHandled); +} diff --git a/Elixir/Tests/Engine/GUI/WidgetLifetimeTest.cpp b/Elixir/Tests/Engine/GUI/WidgetLifetimeTest.cpp index 043fac03..f25b1bc1 100644 --- a/Elixir/Tests/Engine/GUI/WidgetLifetimeTest.cpp +++ b/Elixir/Tests/Engine/GUI/WidgetLifetimeTest.cpp @@ -7,6 +7,20 @@ using namespace testing; using namespace Elixir; using namespace Elixir::GUI; +namespace +{ + // RemoveChild is protected on Widget/Panel (no production code calls it from outside a + // container's own AddChild/SetContent-style methods); promote it here so this file's + // detach test can drive it directly, same idiom as PanelTestWidget in ForEachChildTest.cpp. + class PanelTestWidget final : public TPanel + { + public: + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return {}; } + + using Panel::RemoveChild; + }; +} + TEST(WidgetLifetimeTest, MutatingChildAfterParentDestroyedIsSafe) { const auto child = CreateRef(); @@ -38,7 +52,7 @@ TEST(WidgetLifetimeTest, ReplacedContentNoLongerBubblesDirty) TEST(WidgetLifetimeTest, RemovedChildNoLongerDirtiesContainer) { - const auto box = CreateRef(); + const auto box = CreateRef(); const auto a = CreateRef(); box->AddChild(a); @@ -52,6 +66,17 @@ TEST(WidgetLifetimeTest, RemovedChildNoLongerDirtiesContainer) EXPECT_FALSE(box->IsLayoutDirty()); } +TEST(WidgetLifetimeTest, RemovingTheSlotOwnedReferenceKeepsTheChildAliveUntilDetached) +{ + const auto box = CreateRef(); + box->AddChild(CreateRef()); + + const Ref& child = box->GetSlotAt(0)->GetWidget(); + box->RemoveChild(child); + + EXPECT_EQ(box->GetSlotCount(), 0u); +} + TEST(WidgetLifetimeTest, ReparentingDetachesFromPreviousContainer) { const auto boxA = CreateRef(); @@ -61,12 +86,12 @@ TEST(WidgetLifetimeTest, ReparentingDetachesFromPreviousContainer) boxA->AddChild(child); boxB->AddChild(child); - EXPECT_TRUE(boxA->GetSlots().empty()); - EXPECT_EQ(boxB->GetSlots().size(), 1u); + EXPECT_EQ(boxA->GetSlotCount(), 0u); + EXPECT_EQ(boxB->GetSlotCount(), 1u); Arrange(boxA, { { 0, 0 }, { 100, 100 } }); Arrange(boxB, { { 0, 0 }, { 100, 100 } }); child->MarkLayoutDirty(); EXPECT_FALSE(boxA->IsLayoutDirty()); EXPECT_TRUE(boxB->IsLayoutDirty()); -} \ No newline at end of file +} diff --git a/Elixir/Tests/Engine/GUI/WidgetTestUtils.h b/Elixir/Tests/Engine/GUI/WidgetTestUtils.h index 8ad8cb9f..64f23144 100644 --- a/Elixir/Tests/Engine/GUI/WidgetTestUtils.h +++ b/Elixir/Tests/Engine/GUI/WidgetTestUtils.h @@ -15,10 +15,10 @@ namespace explicit CountingWidget(const glm::vec2& desired = { 10.0f, 10.0f }) { - m_DesiredSize = desired; + m_FakeSize = desired; } - glm::vec2 ComputeDesiredSize() override { return m_DesiredSize; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return m_FakeSize; } // MarkLayoutDirty is protected on Widget; promote it so tests can simulate a // widget dirtying itself without weakening the production API. @@ -32,6 +32,12 @@ namespace { ++ArrangeCount; } + + private: + // m_DesiredSize no longer exists on Widget — Measure() now owns the desired-size + // cache (m_CachedDesiredSize) exclusively, so a test double that wants a fixed fake + // size keeps its own copy instead. + glm::vec2 m_FakeSize{}; }; // Minimal single-child container to exercise ContentWidget lifecycle @@ -39,7 +45,7 @@ namespace class TestContentWidget final : public ContentWidget { public: - glm::vec2 ComputeDesiredSize() override { return { 10.0f, 10.0f }; } + glm::vec2 ComputeDesiredSize(const glm::vec2&) override { return { 10.0f, 10.0f }; } }; // ArrangeChildren is the non-virtual template method on Widget; call it directly. diff --git a/Elixir/Tests/Engine/Icon/IconTest.cpp b/Elixir/Tests/Engine/Icon/IconTest.cpp new file mode 100644 index 00000000..ed1ee234 --- /dev/null +++ b/Elixir/Tests/Engine/Icon/IconTest.cpp @@ -0,0 +1,38 @@ +#include +using namespace testing; + +#include +using namespace Elixir; + +namespace +{ + class TestIconLoader final : public IconLoader + { + public: + EIconFormat GetFormat() const override { return EIconFormat::PNG; } + + Ref Load(const SIconSource&) const override { return nullptr; } + }; +} + +TEST(IconTest, BitmapValidityRequiresExactlyOneRgbaBuffer) +{ + SIconBitmap bitmap; + bitmap.Size = { 2, 3 }; + bitmap.Pixels.resize(23); + EXPECT_FALSE(bitmap.IsValid()); + + bitmap.Pixels.resize(24); + EXPECT_TRUE(bitmap.IsValid()); +} + +TEST(IconTest, RegisterLoaderRejectsDuplicateFormatAndReplacementIsExplicit) +{ + IconManager::Shutdown(); + + EXPECT_TRUE(IconManager::RegisterLoader(CreateScope())); + EXPECT_FALSE(IconManager::RegisterLoader(CreateScope())); + EXPECT_TRUE(IconManager::ReplaceLoader(CreateScope())); + + IconManager::Shutdown(); +} diff --git a/Elixir/Vendor/lunasvg b/Elixir/Vendor/lunasvg new file mode 160000 index 00000000..83c58df8 --- /dev/null +++ b/Elixir/Vendor/lunasvg @@ -0,0 +1 @@ +Subproject commit 83c58df8103dc7dca423dfd824992af94d49bed6 diff --git a/Shaders/GUI.ps.hlsl b/Shaders/GUI.ps.hlsl index b97dafbc..29234729 100644 --- a/Shaders/GUI.ps.hlsl +++ b/Shaders/GUI.ps.hlsl @@ -32,6 +32,7 @@ struct PS_INPUT float4 OutlineColor : OUTLINE0; // Outline color float OutlineThickness : OUTLINE1; // Outline thickness uint TextureIndex : TEXTURE; // Texture index + uint TextureMapping : TEXTURE_MAPPING; // 0 = stretch, 1 = nine-slice float4 ScissorRect : SCISSOR; // Scissor rect (x, y, width, height) }; @@ -47,7 +48,7 @@ struct PS_INPUT * size: quad size in screen-space * border: left, top, right, bottom */ -float2 calculateTexCoords(uint texIndex, float2 texCoords, float2 size, float4 border) +float2 CalculateTexCoords(uint texIndex, float2 texCoords, float2 size, float4 border) { // Get texture dimensions float2 texSize; @@ -133,7 +134,7 @@ float2 calculateTexCoords(uint texIndex, float2 texCoords, float2 size, float4 b * halfSize: half of the quad size for distance calculation * cornerRadii: corner radii for distance calculation */ -float4 applyShadow(float4 color, float4 shadow, float2 localPos, float2 halfSize, float4 cornerRadii) +float4 ApplyShadow(float4 color, float4 shadow, float2 localPos, float2 halfSize, float4 cornerRadii) { float4 finalColor = color; @@ -146,7 +147,7 @@ float4 applyShadow(float4 color, float4 shadow, float2 localPos, float2 halfSize { // Calculate shadow distance (offset SDF) float2 shadowPos = localPos - offset; - float dist = sdfRect(shadowPos, halfSize, cornerRadii); + float dist = SDFRect(shadowPos, halfSize, cornerRadii); // Soft shadow with gaussian-like falloff float alpha = 1.0f - smoothstep(-blur, blur, dist); @@ -166,7 +167,7 @@ float4 applyShadow(float4 color, float4 shadow, float2 localPos, float2 halfSize * sd: signed distance * px: pixel width (calculated as fwidth(sd)) */ -float4 applyOutline(float4 color, float thickness, float4 outlineColor, float sd, float px) +float4 ApplyOutline(float4 color, float thickness, float4 outlineColor, float sd, float px) { float4 finalColor = color; @@ -192,7 +193,7 @@ float4 applyOutline(float4 color, float thickness, float4 outlineColor, float sd * halfSize: half of the quad size for distance calculation * cornerRadii: corner radii for distance calculation */ -float4 applyInsetShadow(float4 color, float4 shadow, float2 localPos, float2 halfSize, float4 cornerRadii) +float4 ApplyInsetShadow(float4 color, float4 shadow, float2 localPos, float2 halfSize, float4 cornerRadii) { float4 finalColor = color; @@ -205,7 +206,7 @@ float4 applyInsetShadow(float4 color, float4 shadow, float2 localPos, float2 hal { // Calculate shadow distance (offset SDF) float2 pos = localPos + (offset * -1.0f); - float dist = sdfRect(pos, halfSize, cornerRadii); + float dist = SDFRect(pos, halfSize, cornerRadii); // Inner shadow only renders INSIDE the shape (dist < 0) // Fade from edge inward @@ -222,21 +223,19 @@ float4 applyInsetShadow(float4 color, float4 shadow, float2 localPos, float2 hal float4 main(PS_INPUT input) : SV_TARGET { // Discard pixels outside the scissor rect - if (isScissorRectValid(input.ScissorRect) && + if (IsScissorRectValid(input.ScissorRect) && (input.ClipPos.x < input.ScissorRect.x || input.ClipPos.y < input.ScissorRect.y || input.ClipPos.x > input.ScissorRect.x + input.ScissorRect.z || input.ClipPos.y > input.ScissorRect.y + input.ScissorRect.w)) - { discard; - } float4 color = input.Color; float2 texCoords = input.TexCoord; - if (input.TextureIndex > pcWhiteTexture.WhiteTextureIndex) + if (input.TextureIndex > pcWhiteTexture.WhiteTextureIndex && input.TextureMapping == 1) { - texCoords = calculateTexCoords( + texCoords = CalculateTexCoords( input.TextureIndex, input.TexCoord, input.ContentSize, @@ -253,15 +252,14 @@ float4 main(PS_INPUT input) : SV_TARGET float2 localPos = input.LocalPos - contentCenter; // Calculate the signed distance - float dist = sdfRect(localPos, halfSize, input.Border); + float dist = SDFRect(localPos, halfSize, input.Border); float px = fwidth(dist); // Start with transparent float4 finalColor = float4(0, 0, 0, 0); // Run these effects BEFORE shape rendering - finalColor = applyShadow(finalColor, input.DropShadow, localPos, halfSize, input.Border); - finalColor = applyOutline(finalColor, input.OutlineThickness, input.OutlineColor, dist, px); + finalColor = ApplyShadow(finalColor, input.DropShadow, localPos, halfSize, input.Border); // Lerp final color with shape color based on actual quad shape float shapeMask = 1.0f - smoothstep(-px, px, dist); @@ -270,9 +268,15 @@ float4 main(PS_INPUT input) : SV_TARGET // Run these effects AFTER shape rendering if (dist < (-px + 1.0f)) { - finalColor = applyInsetShadow(finalColor, input.InsetShadow, localPos, halfSize, input.Border); + finalColor = ApplyInsetShadow(finalColor, input.InsetShadow, localPos, halfSize, input.Border); } + // Outline is a border, not a CSS-style outline: it's drawn INSET, in the [-thickness, 0] + // band just inside the shape boundary, on top of everything above - never past dist=0, + // so it always stays inside input.ContentSize (see the box-sizing note on Widget::Measure + // for how desired size grows to make room for it instead). + finalColor = ApplyOutline(finalColor, input.OutlineThickness, input.OutlineColor, -dist, px); + if (finalColor.a < 0.001) discard; return finalColor; -} \ No newline at end of file +} diff --git a/Shaders/GUI.vs.hlsl b/Shaders/GUI.vs.hlsl index 6f600065..720daba6 100644 --- a/Shaders/GUI.vs.hlsl +++ b/Shaders/GUI.vs.hlsl @@ -22,6 +22,7 @@ struct VS_INPUT float4 OutlineColor : OUTLINE0; // Outline color float OutlineThickness : OUTLINE1; // Outline thickness uint TextureIndex : TEXTURE; // Texture index + uint TextureMapping : TEXTURE_MAPPING; // 0 = stretch, 1 = nine-slice float4 ScissorRect : SCISSOR; // Scissor rect (x, y, width, height) uint VertexId : SV_VertexID; @@ -42,6 +43,7 @@ struct VS_OUTPUT float4 OutlineColor : OUTLINE0; // Outline color float OutlineThickness : OUTLINE1; // Outline thickness uint TextureIndex : TEXTURE; // Texture index + uint TextureMapping : TEXTURE_MAPPING; // Texture mapping mode float4 ScissorRect : SCISSOR; // Scissor rect (x, y, width, height) }; @@ -66,8 +68,9 @@ VS_OUTPUT main(VS_INPUT input) shadowExpansion = length(shadowOffset) + shadowBlur * 3.0; } - // Total expansion is the maximum of all effects - float expansion = max(input.OutlineThickness, shadowExpansion); + // Only drop shadow needs extra room outside the content bounds - the outline is drawn + // INSET (see applyOutline in GUI.ps.hlsl), so it never needs to expand past input.Size. + float expansion = shadowExpansion; // Expand the quad geometry float2 expandedSize = input.Size + float2(expansion * 2.0, expansion * 2.0); @@ -98,7 +101,8 @@ VS_OUTPUT main(VS_INPUT input) output.OutlineColor = input.OutlineColor; output.OutlineThickness = input.OutlineThickness; output.TextureIndex = input.TextureIndex; + output.TextureMapping = input.TextureMapping; output.ScissorRect = input.ScissorRect; return output; -} \ No newline at end of file +} diff --git a/Shaders/SDF.hlsl b/Shaders/SDF.hlsl index 3a500a77..264267cf 100644 --- a/Shaders/SDF.hlsl +++ b/Shaders/SDF.hlsl @@ -6,7 +6,7 @@ * @param r The corner radii for each corner (top-left, top-right, bottom-right, bottom-left). * @return The signed distance from the point to the rectangle's edge. Negative inside, positive outside. */ -float sdfRect(float2 p, float2 b, float4 r) +float SDFRect(float2 p, float2 b, float4 r) { // Default: top-left float radius = r.x; diff --git a/Shaders/Scissor.hlsl b/Shaders/Scissor.hlsl index bd3c3a20..31ac7b9d 100644 --- a/Shaders/Scissor.hlsl +++ b/Shaders/Scissor.hlsl @@ -1,6 +1,6 @@ static const float4 INVALID_RECT = float4(-1, -1, -1, -1); -bool isScissorRectValid(float4 scissorRect) +bool IsScissorRectValid(float4 scissorRect) { return any(scissorRect != INVALID_RECT); } \ No newline at end of file diff --git a/Shaders/Text.ps.hlsl b/Shaders/Text.ps.hlsl index 9853e25d..68cfbde7 100644 --- a/Shaders/Text.ps.hlsl +++ b/Shaders/Text.ps.hlsl @@ -23,7 +23,7 @@ struct PS_INPUT float4 ScissorRect : SCISSOR; // Scissor rect (x, y, width, height) }; -float median(float r, float g, float b) +float Median(float r, float g, float b) { return max(min(r, g), min(max(r, g), b)); } @@ -31,20 +31,18 @@ float median(float r, float g, float b) float4 main(PS_INPUT input) : SV_TARGET { // Discard pixels outside the scissor rect - if (isScissorRectValid(input.ScissorRect) && + if (IsScissorRectValid(input.ScissorRect) && (input.ClipPos.x < input.ScissorRect.x || input.ClipPos.y < input.ScissorRect.y || input.ClipPos.x > input.ScissorRect.x + input.ScissorRect.z || input.ClipPos.y > input.ScissorRect.y + input.ScissorRect.w)) - { discard; - } // Sample MTSDF atlas: RGB = multi-channel SDF, A = true SDF float4 atlas = atlases[input.AtlasIndex].Sample(atlasSampler, input.TexCoords); // Median-of-three reconstructs sharp corners from the multi-channel encoding - float sd = median(atlas.r, atlas.g, atlas.b); + float sd = Median(atlas.r, atlas.g, atlas.b); // Screen-space derivative of distance for resolution-independent anti-aliasing float screenPxDistance = fwidth(sd);