diff --git a/Source/Game-Lib/Game-Lib/Application/Application.cpp b/Source/Game-Lib/Game-Lib/Application/Application.cpp index 06dab48b..690eb36c 100644 --- a/Source/Game-Lib/Game-Lib/Application/Application.cpp +++ b/Source/Game-Lib/Game-Lib/Application/Application.cpp @@ -67,6 +67,7 @@ AutoCVar_Int CVAR_ApplicationNumThreads(CVarCategory::Client, "numThreads", "num AutoCVar_Int CVAR_ClientDBSaveMethod(CVarCategory::Client, "clientDBSaveMethod", "specifies when clientDBs are saved. (0 = Immediately, 1 = Every x Seconds, 2 = On Shutdown, 3+ = Disabled, default is 1)", 1); AutoCVar_Float CVAR_ClientDBSaveTimer(CVarCategory::Client, "clientDBSaveTimer", "specifies how often clientDBs are saved when using save method 1 (Specified in seconds, default is 5 seconds)", 5.0f); AutoCVar_String CVAR_ImguiTheme(CVarCategory::Client, "imguiTheme", "specifies the current imgui theme", "Blue Teal", CVarFlags::Hidden); +AutoCVar_Int CVAR_DeveloperMode(CVarCategory::Client, "developerMode", "enables developer-only Luau APIs (Editor, Time) and dev-tool scripts under Resources/Scripts/Editor", 1, CVarFlags::EditCheckbox); Application::Application() : _messagesInbound(256), _messagesOutbound(256) { } Application::~Application() @@ -361,6 +362,14 @@ bool Application::Init() auto globalKey = Scripting::ZenithInfoKey::MakeGlobal(0, 0); _luaManager->GetZenithStateManager().Add(globalKey); + _luaManager->SetDeveloperMode(CVAR_DeveloperMode.Get() != 0); + + CVarSystem::Get()->AddOnIntValueChanged(CVarCategory::Client, "developerMode"_h, + [this](const i32& value) + { + _luaManager->SetDeveloperMode(value != 0); + }); + _luaManager->Init(); } diff --git a/Source/Game-Lib/Game-Lib/ECS/Components/UI/LayoutEventInfo.h b/Source/Game-Lib/Game-Lib/ECS/Components/UI/LayoutEventInfo.h new file mode 100644 index 00000000..7d1142c2 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/ECS/Components/UI/LayoutEventInfo.h @@ -0,0 +1,16 @@ +#pragma once +#include + +namespace ECS::Components::UI +{ + struct LayoutEventInfo + { + public: + // Lua function ref (LUA_REGISTRYINDEX) of the layout's refresh callback, + // installed by Lua-side LinearLayout/GridLayout via Widget:RegisterLayoutRefresh. + // Cleared on entity destroy by ReleaseLayoutEventInfoRefs (UIRefCleanup). + i32 onLayoutRefresh = -1; + }; + + struct DirtyLayoutTag {}; +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h b/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h index 5c005683..2410bf1f 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h +++ b/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h @@ -23,6 +23,7 @@ namespace ECS::Components::UI u8 cornerRadius : 1 = 0; u8 texCoords : 1 = 0; u8 nineSliceCoords : 1 = 0; + u8 border : 1 = 0; }; SetFlags setFlags; @@ -32,6 +33,8 @@ namespace ECS::Components::UI std::string foreground; Color color = Color::White; f32 cornerRadius = 0.0f; + Color borderColor = Color(0.0f, 0.0f, 0.0f, 1.0f); + f32 borderSize = 0.0f; ::UI::Box texCoords; ::UI::Box nineSliceCoords; diff --git a/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp b/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp index 40b3881a..7fdf0745 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp @@ -87,7 +87,11 @@ namespace ECS joltState.updateTimer += glm::clamp(clampedDeltaTime, 0.0f, Singletons::JoltState::FixedDeltaTime); - Systems::UpdateDayNightCycle::Update(gameRegistry, clampedDeltaTime); + // Day/night cycle is a wall-clock-style timer: pass the unclamped deltaTime so + // the in-game time tracks real seconds 1:1. Clamping (as the rest of this + // function uses for physics/animation stability) makes the cycle drift behind + // wall-clock whenever the framerate dips below 60 FPS. + Systems::UpdateDayNightCycle::Update(gameRegistry, deltaTime); Systems::NetworkConnection::Update(gameRegistry, clampedDeltaTime); Systems::DrawDebugMesh::Update(gameRegistry, clampedDeltaTime); Systems::Animation::Update(gameRegistry, clampedDeltaTime); diff --git a/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h b/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h index 8ce5a01d..ecb796e5 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h +++ b/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h @@ -11,7 +11,14 @@ namespace ECS::Singletons static constexpr f32 SecondsPerMinute = 60.0f; static constexpr f32 SecondsPerDay = SecondsPerMinute * MinutesPerHour * HoursPerDay; - f32 timeInSeconds = 0.0f; + // f64: f32 ULP at 86400 is ~4-8 ms, comparable to deltaTime, so an f32 + // accumulator drifts by seconds per minute. + f64 timeInSeconds = 0.0; f32 speedModifier = 1.0f; + + // -1 so the first Update fires the second-changed callback. + i32 lastIntegerSecond = -1; + + f32 GetTimeInSecondsF32() const { return static_cast(timeInSeconds); } }; -} \ No newline at end of file +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp index 72e447c7..e368a428 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp @@ -75,7 +75,7 @@ namespace ECS::Systems auto& dayNightCycle = ctx.get(); // Get light settings - vec3 lightDirection = UpdateAreaLights::GetLightDirection(dayNightCycle.timeInSeconds); + vec3 lightDirection = UpdateAreaLights::GetLightDirection(dayNightCycle.GetTimeInSecondsF32()); // Get active render camera auto& activeCamera = ctx.get(); diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp index 36ebff62..67a1dc7b 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp @@ -439,15 +439,12 @@ namespace ECS::Systems::UI if (isWithin) { - Components::Transform2D& transform = registry.get(childEntity); - vec2 middlePoint = (min + max) * 0.5f; + u32 distanceToMouse = static_cast(glm::distance(middlePoint, mousePos)); - u16 numParents = std::numeric_limits::max() - static_cast(transform.GetHierarchyDepth()); - u16 layer = std::numeric_limits::max() - static_cast(transform.GetLayer()); - u32 distanceToMouse = static_cast(glm::distance(middlePoint, mousePos)); // Distance in pixels - - u64 key = (static_cast(numParents) << 48) | (static_cast(layer) << 32) | distanceToMouse; + // Invert sortKey: higher sortKey draws on top, but the map dispatches smallest first. + u32 invertedSortKey = std::numeric_limits::max() - widget.sortKey; + u64 key = (static_cast(invertedSortKey) << 32) | distanceToMouse; allHoveredEntities[key] = childEntity; } diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp index 8226dc61..c700d2e1 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp @@ -309,7 +309,7 @@ namespace ECS::Systems MaterialRenderer* materialRenderer = ServiceLocator::GetGameRenderer()->GetMaterialRenderer(); - vec3 direction = GetLightDirection(dayNightCycle.timeInSeconds); + vec3 direction = GetLightDirection(dayNightCycle.GetTimeInSecondsF32()); const vec3& diffuseColor = glm::normalize(areaLightInfo.finalColorData.diffuseColor); const vec3& ambientColor = glm::normalize(areaLightInfo.finalColorData.ambientColor); vec3 groundAmbientColor = ambientColor * 0.7f; diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp index ad713e32..9921d804 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp @@ -1,5 +1,14 @@ #include "UpdateDayNightCycle.h" +#include "Game-Lib/Application/EnttRegistries.h" #include "Game-Lib/ECS/Singletons/DayNightCycle.h" +#include "Game-Lib/Scripting/Handlers/UIHandler.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" +#include "Game-Lib/Util/ServiceLocator.h" + +#include + +#include +#include #include #include @@ -34,17 +43,34 @@ namespace ECS::Systems entt::registry::context& context = registry.ctx(); auto& dayNightCycle = context.get(); - dayNightCycle.timeInSeconds += (1.0f * dayNightCycle.speedModifier) * deltaTime; + dayNightCycle.timeInSeconds += static_cast(dayNightCycle.speedModifier) * static_cast(deltaTime); while (dayNightCycle.timeInSeconds > Singletons::DayNightCycle::SecondsPerDay) { dayNightCycle.timeInSeconds -= Singletons::DayNightCycle::SecondsPerDay; } + + i32 currentInteger = static_cast(dayNightCycle.timeInSeconds); + if (currentInteger != dayNightCycle.lastIntegerSecond) + { + dayNightCycle.lastIntegerSecond = currentInteger; + + Scripting::LuaManager* luaManager = ServiceLocator::GetLuaManager(); + Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (luaManager && zenith) + { + Scripting::UI::UIHandler* uiHandler = luaManager->GetLuaHandler(static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::UI)); + if (uiHandler) + { + uiHandler->OnSecondChanged(zenith, dayNightCycle.timeInSeconds); + } + } + } } void UpdateDayNightCycle::SetTimeToDefault(entt::registry& registry) { - f32 secondsSinceMidnightUTC = static_cast(GetSecondsSinceMidnightUTC()); + f64 secondsSinceMidnightUTC = static_cast(GetSecondsSinceMidnightUTC()); while (secondsSinceMidnightUTC > Singletons::DayNightCycle::SecondsPerDay) { secondsSinceMidnightUTC -= Singletons::DayNightCycle::SecondsPerDay; @@ -53,7 +79,7 @@ namespace ECS::Systems SetTime(registry, secondsSinceMidnightUTC); } - void UpdateDayNightCycle::SetTime(entt::registry& registry, f32 time) + void UpdateDayNightCycle::SetTime(entt::registry& registry, f64 time) { entt::registry::context& context = registry.ctx(); auto& dayNightCycle = context.get(); @@ -67,9 +93,9 @@ namespace ECS::Systems dayNightCycle.speedModifier = speedModifier; } - void UpdateDayNightCycle::SetTimeAndSpeedModifier(entt::registry& registry, f32 time, f32 speedModifier) + void UpdateDayNightCycle::SetTimeAndSpeedModifier(entt::registry& registry, f64 time, f32 speedModifier) { SetTime(registry, time); SetSpeedModifier(registry, speedModifier); } -} \ No newline at end of file +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h index 069968fb..cecc5813 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h @@ -11,8 +11,8 @@ namespace ECS::Systems static void Update(entt::registry& registry, f32 deltaTime); static void SetTimeToDefault(entt::registry& registry); - static void SetTime(entt::registry& registry, f32 time); + static void SetTime(entt::registry& registry, f64 time); static void SetSpeedModifier(entt::registry& registry, f32 speedModifier); - static void SetTimeAndSpeedModifier(entt::registry& registry, f32 time, f32 speedModifier); + static void SetTimeAndSpeedModifier(entt::registry& registry, f64 time, f32 speedModifier); }; } \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.cpp b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.cpp new file mode 100644 index 00000000..49c6db91 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.cpp @@ -0,0 +1,39 @@ +#include "UIRefCleanup.h" +#include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" + +#include + +namespace ECS::Util::UIRefCleanup +{ + void ReleaseEventInputInfoRefs(entt::registry& registry, entt::entity entity) + { + // Shutdown order: the registry may destroy entities after the LuaManager + // is gone. Skip rather than crash; the Lua state is being torn down anyway. + ::Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (zenith == nullptr) return; + + auto& info = registry.get(entity); + Scripting::Util::Zenith::Unref(zenith, info.onMouseDownEvent); + Scripting::Util::Zenith::Unref(zenith, info.onMouseUpEvent); + Scripting::Util::Zenith::Unref(zenith, info.onMouseHeldEvent); + Scripting::Util::Zenith::Unref(zenith, info.onMouseScrollEvent); + Scripting::Util::Zenith::Unref(zenith, info.onHoverBeginEvent); + Scripting::Util::Zenith::Unref(zenith, info.onHoverEndEvent); + Scripting::Util::Zenith::Unref(zenith, info.onHoverHeldEvent); + Scripting::Util::Zenith::Unref(zenith, info.onFocusBeginEvent); + Scripting::Util::Zenith::Unref(zenith, info.onFocusEndEvent); + Scripting::Util::Zenith::Unref(zenith, info.onFocusHeldEvent); + Scripting::Util::Zenith::Unref(zenith, info.onKeyboardEvent); + } + + void ReleaseLayoutEventInfoRefs(entt::registry& registry, entt::entity entity) + { + ::Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (zenith == nullptr) return; + + auto& info = registry.get(entity); + Scripting::Util::Zenith::Unref(zenith, info.onLayoutRefresh); + } +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h new file mode 100644 index 00000000..19239a24 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace ECS::Util::UIRefCleanup +{ + // entt on_destroy observers attached at UI registry construction + // (Application.cpp). Each iterates the i32 Lua-registry refs on the + // outgoing component and lua_unref's the populated ones, so callbacks + // installed via SetOnMouseUp / RegisterLayoutRefresh / etc. don't + // leak the registry slot when the widget entity is destroyed. + void ReleaseEventInputInfoRefs(entt::registry& registry, entt::entity entity); + void ReleaseLayoutEventInfoRefs(entt::registry& registry, entt::entity entity); +} diff --git a/Source/Game-Lib/Game-Lib/Editor/Clock.cpp b/Source/Game-Lib/Game-Lib/Editor/Clock.cpp deleted file mode 100644 index 05825612..00000000 --- a/Source/Game-Lib/Game-Lib/Editor/Clock.cpp +++ /dev/null @@ -1,80 +0,0 @@ -#include "Clock.h" - -#include "Game-Lib/Application/EnttRegistries.h" -#include "Game-Lib/ECS/Singletons/DayNightCycle.h" -#include "Game-Lib/ECS/Systems/UpdateDayNightCycle.h" -#include "Game-Lib/Util/ServiceLocator.h" - -#include -#include - -#include - -using namespace ECS; - -namespace Editor -{ - Clock::Clock() - : BaseEditor(GetName()) - { - - } - - void Clock::DrawImGui() - { - if (ImGui::Begin(GetName(), &IsVisible())) - { - EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); - entt::registry& registry = *registries->gameRegistry; - entt::registry::context& ctx = registry.ctx(); - - auto& dayNightCycle = ctx.get(); - - // Direct Time Manipulations - { - if (ImGui::Button("Reset Time")) - { - Systems::UpdateDayNightCycle::SetTimeToDefault(registry); - } - - ImGui::SameLine(); - - if (ImGui::Button("Set Time to Noon")) - { - f32 noonTime = Singletons::DayNightCycle::SecondsPerDay / 2.0f; - Systems::UpdateDayNightCycle::SetTime(registry, noonTime); - } - } - - // Time Speed Manipulation - { - f32 timeMultiplier = dayNightCycle.speedModifier; - - bool resetTime = ImGui::Button("Reset Speed"); - if (resetTime) - { - timeMultiplier = 1.0; - } - - ImGui::SameLine(); - bool inputChanged = ImGui::InputFloat("##", &timeMultiplier, 1.0f, 10.f, "%.2f"); - - if (resetTime || inputChanged) - dayNightCycle.speedModifier = timeMultiplier; - } - - u32 totalSeconds = static_cast(dayNightCycle.timeInSeconds); - - u32 hours = totalSeconds / 60 / 60; - u32 mins = (totalSeconds / 60) - hours * 60; - u32 seconds = totalSeconds - (hours * 60 * 60) - (mins * 60); - - std::string hoursStr = hours < 10 ? std::string("0").append(std::to_string(hours)) : std::to_string(hours); - std::string minsStr = mins < 10 ? std::string("0").append(std::to_string(mins)) : std::to_string(mins); - std::string secondsStr = seconds < 10 ? std::string("0").append(std::to_string(seconds)) : std::to_string(seconds); - - ImGui::Text("%s:%s:%s", hoursStr.c_str(), minsStr.c_str(), secondsStr.c_str()); - } - ImGui::End(); - } -} \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Editor/Clock.h b/Source/Game-Lib/Game-Lib/Editor/Clock.h deleted file mode 100644 index a3d13952..00000000 --- a/Source/Game-Lib/Game-Lib/Editor/Clock.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include "BaseEditor.h" - -namespace Editor -{ - class Clock : public BaseEditor - { - public: - Clock(); - - virtual const char* GetName() override { return "Clock"; } - - virtual void DrawImGui() override; - }; -} \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp b/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp index 65e7e272..640d288a 100644 --- a/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp @@ -2,9 +2,9 @@ #include "ActionStack.h" #include "AnimationController.h" #include "AssetBrowser.h" +#include "BaseEditor.h" #include "CameraInfo.h" #include "CDBEditor.h" -#include "Clock.h" #include "CVarEditor.h" #include "EaseCurveTool.h" #include "Hierarchy.h" @@ -50,7 +50,6 @@ namespace Editor _editors.push_back(new CVarEditor()); _editors.push_back(new CameraInfo()); _editors.push_back(new CDBEditor()); - _editors.push_back(new Clock()); _editors.push_back(new PerformanceDiagnostics()); _editors.push_back(new MapSelector()); _editors.push_back(new NetworkedInfo()); diff --git a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp index c5880977..1c4d45c2 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp +++ b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp @@ -5,6 +5,7 @@ #include "Game-Lib/ECS/Components/UI/Canvas.h" #include "Game-Lib/ECS/Components/UI/Clipper.h" #include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" #include "Game-Lib/ECS/Components/UI/Panel.h" #include "Game-Lib/ECS/Components/UI/Text.h" #include "Game-Lib/ECS/Components/UI/Widget.h" @@ -15,8 +16,11 @@ #include "Game-Lib/Rendering/Debug/DebugRenderer.h" #include "Game-Lib/Rendering/GameRenderer.h" #include "Game-Lib/Rendering/RenderResources.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" #include "Game-Lib/Util/ServiceLocator.h" +#include + #include #include @@ -65,6 +69,38 @@ void CanvasRenderer::Update(f32 deltaTime) entt::registry* uiRegistry = ServiceLocator::GetEnttRegistries()->uiRegistry; ECS::Transform2DSystem& transformSystem2D = ECS::Transform2DSystem::Get(*uiRegistry); + // Lua layouts: refresh any whose Lua-side state was invalidated since the last frame. + // Layouts are lazy-refresh-on-read in Lua (LinearLayout/GridLayout), so they otherwise + // never run unless something explicitly reads GetMeasured*. The refresh closure (set + // via Widget:RegisterLayoutRefresh) runs `self:Refresh()` which writes positions/sizes + // via SetPos/SetWidth/SetHeight; those calls cascade into DirtyCanvasTag / + // DirtyCanvasSort / DirtyWidgetData and are picked up by the passes below. + { + auto layoutView = uiRegistry->view(); + if (layoutView.begin() != layoutView.end()) + { + ZoneScopedN("CanvasRenderer::Update::LayoutRefresh"); + ::Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (zenith != nullptr) + { + // TODO(layouts perf): each invalidated layout costs one Lua PCall. For + // deeply nested UI where a single mutation cascades up N levels, that's N + // PCalls per frame. Profile if it shows up; the obvious optimisation is to + // gather all dirty refresh closures into a single PCall (push the closures + // as a stack of values and let one Lua function dispatch them). + for (entt::entity entity : layoutView) + { + i32 ref = layoutView.get(entity).onLayoutRefresh; + if (ref == -1) continue; + + zenith->GetRawI(LUA_REGISTRYINDEX, ref); + zenith->PCall(0, 0); + } + uiRegistry->clear(); + } + } + } + uiRegistry->view().each([&](entt::entity entity, Widget& widget) { if (widget.type == WidgetType::Canvas) @@ -766,14 +802,20 @@ void CanvasRenderer::UpdatePanelData(entt::entity entity, ECS::Components::Trans panel.gpuDataIndex = _widgetDrawDatas.Add(); } vec2 size = transform.GetSize(); - vec2 cornerRadius = vec2(panelTemplate.cornerRadius / size.x, panelTemplate.cornerRadius / size.y); + vec2 cornerRadius = (size.x > 0.0f && size.y > 0.0f) + ? vec2(panelTemplate.cornerRadius / size.x, panelTemplate.cornerRadius / size.y) + : vec2(0.0f); + vec2 normalizedBorderSize = (panelTemplate.borderSize > 0.0f && size.x > 0.0f && size.y > 0.0f) + ? vec2(panelTemplate.borderSize / size.x, panelTemplate.borderSize / size.y) + : vec2(0.0f); // Update draw data auto& drawData = _widgetDrawDatas[panel.gpuDataIndex]; drawData.packed0.x = static_cast(WidgetDrawType::Panel); drawData.packed0.y = static_cast(panel.gpuVertexIndex); // vertexBase + drawData.packed1.y = panelTemplate.borderColor.ToRGBA32(); drawData.packed1.z = panelTemplate.color.ToRGBA32(); - drawData.cornerRadiusAndBorder = vec4(cornerRadius, 0.0f, 0.0f); + drawData.cornerRadiusAndBorder = vec4(cornerRadius, normalizedBorderSize); // Update textures u16 textureIndex = 0; diff --git a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h index 0dd718aa..a6a4674b 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h +++ b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h @@ -109,10 +109,10 @@ class CanvasRenderer { public: uvec4 packed0 = uvec4(0, 0, 0, 0xFFFFFFFFu); // x: type, y: vertexBase, z: clipMaskTextureIndex, w: worldPositionIndex (i32 reinterpret as -1) - uvec4 packed1 = uvec4(0, 0, 0, 0); // Panel: x: textureIndex|additiveTextureIndex, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor + uvec4 packed1 = uvec4(0, 0, 0, 0); // Panel: x: textureIndex|additiveTextureIndex, y: borderColor, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor vec4 texCoord = vec4(0.0f); // Panel only vec4 slicingCoord = vec4(0.0f); // Panel only - vec4 cornerRadiusAndBorder = vec4(0.0f); // Panel: xy: cornerRadius. Text: x: borderSize, zw: unitRange + vec4 cornerRadiusAndBorder = vec4(0.0f); // Panel: xy: cornerRadius, zw: borderSize (normalized per-axis). Text: x: borderSize, zw: unitRange hvec4 clipRegionRect = hvec4(0.0f, 0.0f, 1.0f, 1.0f); // xy: min, zw: max hvec4 clipMaskRegionRect = hvec4(0.0f, 0.0f, 1.0f, 1.0f); // xy: min, zw: max }; diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp index 4ddc0a89..4f316a5b 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp @@ -1,9 +1,14 @@ #include "UIHandler.h" #include "Game-Lib/Application/EnttRegistries.h" #include "Game-Lib/ECS/Components/UI/Canvas.h" +#include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" +#include "Game-Lib/ECS/Singletons/DayNightCycle.h" #include "Game-Lib/ECS/Singletons/InputSingleton.h" #include "Game-Lib/ECS/Singletons/UISingleton.h" +#include "Game-Lib/ECS/Systems/UpdateDayNightCycle.h" #include "Game-Lib/ECS/Util/Transform2D.h" +#include "Game-Lib/ECS/Util/UIRefCleanup.h" #include "Game-Lib/ECS/Util/UIUtil.h" #include "Game-Lib/Rendering/GameRenderer.h" #include "Game-Lib/Rendering/Canvas/CanvasRenderer.h" @@ -11,6 +16,7 @@ #include "Game-Lib/Scripting/UI/Canvas.h" #include "Game-Lib/Scripting/UI/Panel.h" #include "Game-Lib/Scripting/UI/Text.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" #include "Game-Lib/UI/Box.h" #include "Game-Lib/Util/ServiceLocator.h" @@ -34,6 +40,14 @@ namespace Scripting::UI registry->ctx().emplace(); registry->ctx().emplace(); + // Release Lua-registry refs (SetOnMouseUp, RegisterLayoutRefresh, etc.) + // when their owning entities are destroyed; otherwise the registry slot + // leaks for the lifetime of the Lua state. + registry->on_destroy() + .connect<&ECS::Util::UIRefCleanup::ReleaseEventInputInfoRefs>(); + registry->on_destroy() + .connect<&ECS::Util::UIRefCleanup::ReleaseLayoutEventInfoRefs>(); + // UI LuaMethodTable::Set(zenith, uiGlobalMethods, "UI"); @@ -48,6 +62,20 @@ namespace Scripting::UI CreateUIInputEventTable(zenith); + LuaManager* luaManager = ServiceLocator::GetLuaManager(); + const bool inDeveloperMode = luaManager && luaManager->IsDeveloperMode(); + const Scripting::LuaMethodFlags excludeFlags = inDeveloperMode + ? Scripting::LuaMethodFlags::None + : Scripting::LuaMethodFlags::DeveloperOnly; + + LuaMethodTable::Set(zenith, timeGlobalMethods, "Time", excludeFlags); + + zenith->GetGlobalKey("Time"); + zenith->AddTableField("SecondsPerDay", ECS::Singletons::DayNightCycle::SecondsPerDay); + zenith->Pop(); + + _timeOnSecondChangedRef = LUA_NOREF; + // Setup Cursor Canvas { auto& uiSingleton = registry->ctx().get(); @@ -80,6 +108,8 @@ namespace Scripting::UI transformSystem.ClearQueue(); ServiceLocator::GetGameRenderer()->GetCanvasRenderer()->Clear(); + _timeOnSecondChangedRef = LUA_NOREF; + if (ctx.contains()) { ECS::Singletons::UISingleton& uiSingleton = ctx.get(); @@ -1004,4 +1034,102 @@ namespace Scripting::UI zenith->Pop(); } } + + static ECS::Singletons::DayNightCycle* GetDayNightCycle() + { + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (!registries || !registries->gameRegistry) + return nullptr; + auto& ctx = registries->gameRegistry->ctx(); + if (!ctx.contains()) + return nullptr; + return &ctx.get(); + } + + i32 UIHandler::TimeGetSeconds(Zenith* zenith) + { + ECS::Singletons::DayNightCycle* dnc = GetDayNightCycle(); + zenith->Push(dnc ? dnc->timeInSeconds : 0.0); + return 1; + } + + i32 UIHandler::TimeSetSeconds(Zenith* zenith) + { + f64 seconds = zenith->CheckVal(1); + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->gameRegistry) + ECS::Systems::UpdateDayNightCycle::SetTime(*registries->gameRegistry, seconds); + return 0; + } + + i32 UIHandler::TimeReset(Zenith* /*zenith*/) + { + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->gameRegistry) + ECS::Systems::UpdateDayNightCycle::SetTimeToDefault(*registries->gameRegistry); + return 0; + } + + i32 UIHandler::TimeSetToNoon(Zenith* /*zenith*/) + { + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->gameRegistry) + { + f64 noon = static_cast(ECS::Singletons::DayNightCycle::SecondsPerDay) / 2.0; + ECS::Systems::UpdateDayNightCycle::SetTime(*registries->gameRegistry, noon); + } + return 0; + } + + i32 UIHandler::TimeGetSpeedModifier(Zenith* zenith) + { + ECS::Singletons::DayNightCycle* dnc = GetDayNightCycle(); + zenith->Push(dnc ? dnc->speedModifier : 1.0f); + return 1; + } + + i32 UIHandler::TimeSetSpeedModifier(Zenith* zenith) + { + f32 multiplier = zenith->CheckVal(1); + ECS::Singletons::DayNightCycle* dnc = GetDayNightCycle(); + if (dnc) + dnc->speedModifier = multiplier; + return 0; + } + + i32 UIHandler::TimeGetSecondsPerDay(Zenith* zenith) + { + zenith->Push(ECS::Singletons::DayNightCycle::SecondsPerDay); + return 1; + } + + i32 UIHandler::TimeSetOnSecondChanged(Zenith* zenith) + { + LuaManager* luaManager = ServiceLocator::GetLuaManager(); + UIHandler* self = luaManager + ? luaManager->GetLuaHandler(static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::UI)) + : nullptr; + if (!self) + return 0; + + Scripting::Util::Zenith::Unref(zenith, self->_timeOnSecondChangedRef); + self->_timeOnSecondChangedRef = LUA_NOREF; + + // Pass `nil` to clear; otherwise expects a function. Single-slot. + if (zenith->IsFunction(1)) + { + self->_timeOnSecondChangedRef = zenith->GetRef(1); + } + return 0; + } + + void UIHandler::OnSecondChanged(Zenith* zenith, f64 timeInSeconds) + { + if (_timeOnSecondChangedRef == LUA_NOREF) + return; + + zenith->GetRawI(LUA_REGISTRYINDEX, _timeOnSecondChangedRef); + zenith->Push(timeInSeconds); + zenith->PCall(1); + } } diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h index f3298d74..d84118fe 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h @@ -79,6 +79,16 @@ namespace Scripting::UI static i32 AddOnKeyboard(Zenith* zenith); + // Time + static i32 TimeGetSeconds(Zenith* zenith); + static i32 TimeSetSeconds(Zenith* zenith); + static i32 TimeReset(Zenith* zenith); + static i32 TimeSetToNoon(Zenith* zenith); + static i32 TimeGetSpeedModifier(Zenith* zenith); + static i32 TimeSetSpeedModifier(Zenith* zenith); + static i32 TimeGetSecondsPerDay(Zenith* zenith); + static i32 TimeSetOnSecondChanged(Zenith* zenith); + // Event calls void CallUIInputEvent(Zenith* zenith, i32 eventRef, UIInputEvent inputEvent, Widget* widget); void CallUIInputEvent(Zenith* zenith, i32 eventRef, UIInputEvent inputEvent, Widget* widget, i32 value); @@ -92,8 +102,26 @@ namespace Scripting::UI void CallSendMessageToChat(Zenith* zenith, i32 eventRef, const std::string& channel, const std::string& playerName, const std::string& text, bool isOutgoing); + public: + // Called by UpdateDayNightCycle when the integer game-second flips. + void OnSecondChanged(Zenith* zenith, f64 timeInSeconds); + private: void CreateUIInputEventTable(Zenith* zenith); + + i32 _timeOnSecondChangedRef = LUA_NOREF; + }; + + static LuaRegister<> timeGlobalMethods[] = + { + { "GetSeconds", UIHandler::TimeGetSeconds }, + { "SetSeconds", UIHandler::TimeSetSeconds, Scripting::LuaMethodFlags::DeveloperOnly }, + { "Reset", UIHandler::TimeReset, Scripting::LuaMethodFlags::DeveloperOnly }, + { "SetToNoon", UIHandler::TimeSetToNoon, Scripting::LuaMethodFlags::DeveloperOnly }, + { "GetSpeedModifier", UIHandler::TimeGetSpeedModifier }, + { "SetSpeedModifier", UIHandler::TimeSetSpeedModifier, Scripting::LuaMethodFlags::DeveloperOnly }, + { "GetSecondsPerDay", UIHandler::TimeGetSecondsPerDay }, + { "SetOnSecondChanged", UIHandler::TimeSetOnSecondChanged }, }; static LuaRegister<> uiGlobalMethods[] = diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp index 334474a4..88e9b504 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp @@ -239,5 +239,42 @@ namespace Scripting::UI return 0; } + + i32 SetBorderColor(Zenith* zenith, Panel* panel) + { + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + auto& panelTemplate = registry->get(panel->entity); + registry->get_or_emplace(panel->entity); + + vec3 colorVec = zenith->CheckVal(2); + f32 alpha = zenith->IsNumber(3) ? zenith->Get(3) : -1.0f; + + Color colorWithAlpha = Color(colorVec.r, colorVec.g, colorVec.b, panelTemplate.borderColor.a); + if (alpha >= 0.0f) + { + colorWithAlpha.a = alpha; + } + panelTemplate.borderColor = colorWithAlpha; + panelTemplate.setFlags.border = 1; + + registry->emplace_or_replace(panel->canvasEntity); + + return 0; + } + + i32 SetBorderSize(Zenith* zenith, Panel* panel) + { + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + auto& panelTemplate = registry->get(panel->entity); + registry->get_or_emplace(panel->entity); + + f32 borderSize = zenith->CheckVal(2); + panelTemplate.borderSize = glm::max(borderSize, 0.0f); + panelTemplate.setFlags.border = 1; + + registry->emplace_or_replace(panel->canvasEntity); + + return 0; + } } } \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h index 956df90a..893ebf80 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h @@ -27,6 +27,8 @@ namespace Scripting::UI i32 SetTexCoords(Zenith* zenith, Panel* panel); i32 SetColor(Zenith* zenith, Panel* panel); i32 SetAlpha(Zenith* zenith, Panel* panel); + i32 SetBorderColor(Zenith* zenith, Panel* panel); + i32 SetBorderSize(Zenith* zenith, Panel* panel); }; static LuaRegister panelMethods[] = @@ -45,6 +47,9 @@ namespace Scripting::UI { "SetTexCoords", PanelMethods::SetTexCoords }, { "SetColor", PanelMethods::SetColor }, - { "SetAlpha", PanelMethods::SetAlpha } + { "SetAlpha", PanelMethods::SetAlpha }, + + { "SetBorderColor", PanelMethods::SetBorderColor }, + { "SetBorderSize", PanelMethods::SetBorderSize } }; } \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp index 29dedf48..101191f3 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp @@ -3,6 +3,7 @@ #include "Game-Lib/ECS/Components/UI/Canvas.h" #include "Game-Lib/ECS/Components/UI/Clipper.h" #include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" #include "Game-Lib/ECS/Components/UI/Widget.h" #include "Game-Lib/ECS/Singletons/UISingleton.h" #include "Game-Lib/ECS/Util/Transform2D.h" @@ -11,6 +12,7 @@ #include "Game-Lib/Rendering/GameRenderer.h" #include "Game-Lib/Scripting/UI/Panel.h" #include "Game-Lib/Scripting/UI/Text.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" #include "Game-Lib/Util/ServiceLocator.h" #include @@ -171,6 +173,7 @@ i32 Scripting::UI::WidgetMethods::SetEnabled(Zenith* zenith, Widget* widget) registry->emplace_or_replace(widget->entity); registry->emplace_or_replace(widget->canvasEntity); + ECS::Util::UI::MarkCanvasSortDirty(registry, widget->canvasEntity); return 0; } @@ -192,6 +195,7 @@ i32 Scripting::UI::WidgetMethods::SetVisible(Zenith* zenith, Widget* widget) registry->emplace_or_replace(widget->entity); registry->emplace_or_replace(widget->canvasEntity); + ECS::Util::UI::MarkCanvasSortDirty(registry, widget->canvasEntity); return 0; } @@ -741,6 +745,28 @@ i32 Scripting::UI::WidgetMethods::ForceRefresh(Zenith* zenith, Widget* widget) return 0; } +i32 Scripting::UI::WidgetMethods::RegisterLayoutRefresh(Zenith* zenith, Widget* widget) +{ + i32 callback = zenith->IsFunction(2) ? zenith->GetRef(2) : -1; + + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + auto& layoutEventInfo = registry->get_or_emplace(widget->entity); + + // Replacing an existing ref leaks the old one; release first. + Scripting::Util::Zenith::Unref(zenith, layoutEventInfo.onLayoutRefresh); + layoutEventInfo.onLayoutRefresh = callback; + + return 0; +} + +i32 Scripting::UI::WidgetMethods::InvalidateLayout(Zenith* zenith, Widget* widget) +{ + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + registry->emplace_or_replace(widget->entity); + + return 0; +} + i32 Scripting::UI::WidgetInputMethods::SetOnMouseDown(Zenith* zenith, Widget* widget) { i32 callback = zenith->IsFunction(2) ? zenith->GetRef(2) : - 1; diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h index 7ab4c8ad..6e234087 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h @@ -92,6 +92,12 @@ namespace Scripting::UI i32 SetPos3D(Zenith* zenith, Widget* widget); i32 ForceRefresh(Zenith* zenith, Widget* widget); + + // Layout system hooks: Lua-side LinearLayout/GridLayout register a 0-arg + // refresh closure here; CanvasRenderer::Update fires it pre-frame for any + // widget tagged DirtyLayoutTag (set by InvalidateLayout from Lua's Invalidate). + i32 RegisterLayoutRefresh(Zenith* zenith, Widget* widget); + i32 InvalidateLayout(Zenith* zenith, Widget* widget); } static LuaRegister widgetMethods[] = @@ -141,7 +147,10 @@ namespace Scripting::UI { "SetPos3D", WidgetMethods::SetPos3D }, - { "ForceRefresh", WidgetMethods::ForceRefresh } + { "ForceRefresh", WidgetMethods::ForceRefresh }, + + { "RegisterLayoutRefresh", WidgetMethods::RegisterLayoutRefresh }, + { "InvalidateLayout", WidgetMethods::InvalidateLayout } }; namespace WidgetInputMethods diff --git a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp index f42768be..b6bdd87f 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp @@ -13,4 +13,12 @@ namespace Scripting::Util::Zenith ZenithInfoKey globalKey = ZenithInfoKey::MakeGlobal(0, 0); return luaManager->GetZenithStateManager().Get(globalKey); } + + void Unref(::Scripting::Zenith* zenith, i32 ref) + { + if (ref == -1) return; + // lua_State is transiently null between Zenith::Clear and the next LoadBytecode. + if (zenith == nullptr || zenith->state == nullptr) return; + lua_unref(zenith->state, ref); + } } diff --git a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h index a2cc64e2..e1f48b7a 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h +++ b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h @@ -7,5 +7,8 @@ namespace Scripting namespace Util::Zenith { ::Scripting::Zenith* GetGlobal(); + + // No-op for ref == -1 (LUA_NOREF). zenith must be non-null. + void Unref(::Scripting::Zenith* zenith, i32 ref); } } \ No newline at end of file diff --git a/Source/Resources/Scripts/API/UI/Button.luau b/Source/Resources/Scripts/API/UI/Button.luau index d95b9b0d..ef8d933c 100644 --- a/Source/Resources/Scripts/API/UI/Button.luau +++ b/Source/Resources/Scripts/API/UI/Button.luau @@ -210,6 +210,8 @@ function buttonAPI.NewButton(parent, posX, posY, sizeX, sizeY, layer, buttonTemp local textTemplate = buttonTemplateTable["textTemplate"] or "DefaultButtonText"; buttonTable.panel = parent:NewPanel(posX, posY, sizeX, sizeY, 0, panelTemplate); + -- +3 Y compensates for the drop shadow in DefaultButtonPanel — the visible + -- frame ends ~3px above the panel extent. buttonTable.text = buttonTable.panel:NewText(text, 0, 3, 0, textTemplate); buttonTable.text:SetAnchor(0.5, 0.5); buttonTable.text:SetRelativePoint(0.5, 0.5); diff --git a/Source/Resources/Scripts/API/UI/EditorButton.luau b/Source/Resources/Scripts/API/UI/EditorButton.luau new file mode 100644 index 00000000..2bbf601a --- /dev/null +++ b/Source/Resources/Scripts/API/UI/EditorButton.luau @@ -0,0 +1,118 @@ +--[[ +EditorButton — flat-panel button for dev tool UI. Default look is "invisible +until hovered" (base alpha 0, hover alpha 0.45 with a light-blue tint). +Returned table mirrors the slice of Button.luau used by MenuBar/Clock plus a +SetHighlighted(bool) for "menu open" states. +]] + +local editorButtonAPI = {} + +local DEFAULT_BASE_COLOR = vector.create(0.30, 0.30, 0.35) +local DEFAULT_HOVER_COLOR = vector.create(0.30, 0.55, 0.85) +local DEFAULT_BASE_ALPHA_INVISIBLE = 0.0 -- "invisible until hovered" — see docblock +local DEFAULT_HOVER_ALPHA = 0.45 + +function editorButtonAPI.NewEditorButton(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + + local label = opts.text or "" + local textTemplate = opts.textTemplate or "DefaultDebugText" + local textAlign = opts.textAlign or "center" -- "left", "center", "right" + local textPadX = opts.textPadX or 6 -- inset for left/right alignment + local baseColor = opts.color or DEFAULT_BASE_COLOR + local hoverColor = opts.hoverColor or DEFAULT_HOVER_COLOR + local baseAlpha = opts.alpha or DEFAULT_BASE_ALPHA_INVISIBLE + local hoverAlpha = opts.hoverAlpha or DEFAULT_HOVER_ALPHA + + local panel = parent:NewPanel(posX, posY, sizeX, sizeY, layer or 0, "DebugDarkGrey") + panel:SetColor(baseColor) + panel:SetAlpha(baseAlpha) + + if opts.borderColor ~= nil and opts.borderSize ~= nil then + panel:SetBorderColor(opts.borderColor) + panel:SetBorderSize(opts.borderSize) + end + + local text = panel:NewText(label, 0, 0, 0, textTemplate) + if textAlign == "left" then + text:SetAnchor(0.0, 0.5) + text:SetRelativePoint(0.0, 0.5) + text:SetPosX(textPadX) + elseif textAlign == "right" then + text:SetAnchor(1.0, 0.5) + text:SetRelativePoint(1.0, 0.5) + text:SetPosX(-textPadX) + else + text:SetAnchor(0.5, 0.5) + text:SetRelativePoint(0.5, 0.5) + end + + local self = { + panel = panel, + text = text, + _baseColor = baseColor, + _hoverColor = hoverColor, + _baseAlpha = baseAlpha, + _hoverAlpha = hoverAlpha, + _highlighted = false, + _hovered = false, + } + + local function ApplyVisualState() + if self._highlighted or self._hovered then + self.panel:SetColor(self._hoverColor) + self.panel:SetAlpha(self._hoverAlpha) + else + self.panel:SetColor(self._baseColor) + self.panel:SetAlpha(self._baseAlpha) + end + end + + panel:SetOnHoverBegin(function() + self._hovered = true + ApplyVisualState() + end) + panel:SetOnHoverEnd(function() + self._hovered = false + ApplyVisualState() + end) + + -- Lock the hover-style appearance on regardless of cursor state. + function self:SetHighlighted(on) + self._highlighted = on and true or false + ApplyVisualState() + end + + function self:SetText(t) self.text:SetText(t) end + function self:SetOnMouseUp(fn) self.panel:SetOnMouseUp(fn) end + function self:SetOnMouseDown(fn) self.panel:SetOnMouseDown(fn) end + function self:SetOnHoverBegin(fn) + -- Wrap so the visual state still updates alongside the user's fn. + self.panel:SetOnHoverBegin(function() + self._hovered = true + ApplyVisualState() + if fn then fn() end + end) + end + function self:SetOnHoverEnd(fn) + self.panel:SetOnHoverEnd(function() + self._hovered = false + ApplyVisualState() + if fn then fn() end + end) + end + function self:SetAnchor(x, y) self.panel:SetAnchor(x, y) end + function self:SetRelativePoint(x, y) self.panel:SetRelativePoint(x, y) end + function self:SetPos(x, y) self.panel:SetPos(x, y) end + function self:SetPosX(x) self.panel:SetPosX(x) end + function self:SetPosY(y) self.panel:SetPosY(y) end + function self:GetPosX() return self.panel:GetPosX() end + function self:GetPosY() return self.panel:GetPosY() end + function self:SetVisible(v) self.panel:SetVisible(v) end + function self:SetInteractable(v) self.panel:SetInteractable(v) end + function self:SetSize(w, h) self.panel:SetSize(w, h) end + + return self +end + +return editorButtonAPI diff --git a/Source/Resources/Scripts/API/UI/EditorRegistry.luau b/Source/Resources/Scripts/API/UI/EditorRegistry.luau new file mode 100644 index 00000000..4769743f --- /dev/null +++ b/Source/Resources/Scripts/API/UI/EditorRegistry.luau @@ -0,0 +1,48 @@ +--[[ +EditorRegistry — registry of Lua editor windows. + + local Editor = require("@src/API/UI/EditorRegistry") + Editor.Register("Clock", { Show = ..., Hide = ..., IsVisible = ... }) + Editor.Toggle("Clock") + +In user mode, Editor/-folder scripts don't load so nothing registers, and +Toggle/IsVisible naturally no-op on the empty registry. +]] + +local M = {} + +local registered = {} -- name -> { Show = fn, Hide = fn, IsVisible = fn } + +function M.Register(name, methods) + registered[name] = methods +end + +function M.Unregister(name) + registered[name] = nil +end + +function M.IsVisible(name) + local m = registered[name] + return m ~= nil and m:IsVisible() +end + +function M.Toggle(name) + local m = registered[name] + if not m then return false end + if m:IsVisible() then + m:Hide() + else + m:Show() + end + return m:IsVisible() +end + +function M.GetEditors() + local out = {} + for k in registered do + table.insert(out, k) + end + return out +end + +return M diff --git a/Source/Resources/Scripts/API/UI/GridLayout.luau b/Source/Resources/Scripts/API/UI/GridLayout.luau new file mode 100644 index 00000000..cebd3e38 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/GridLayout.luau @@ -0,0 +1,303 @@ +--[[ +GridLayout — uniform-cell grid. Pick one shape mode: + columns = N explicit column count, rows derived from child count + rows = N explicit row count, columns derived + cellSizeX/Y = N auto: derive cols/rows from container size + cell size + +Children fill in `direction` order ("rowMajor" default; "columnMajor"). Per-cell +alignment (cellAlignH / cellAlignV) places children smaller than the cell; +"stretch" resizes the child to the cell. + +Container sizing modes (widthMode / heightMode): + fixed — declared sizeX/sizeY at construction; ignores content. + shrink — sized to the cell-grid extent; ignores parent. + stretch — NOT IMPLEMENTED (errors at construction). Reserved for future + parent-driven stretch. + auto — max(grid_extent, current_panel_size). + +Same proxy + Invalidate + pre-render-driven Refresh model as LinearLayout, sharing +the LayoutCommon module's wrap and base-layout machinery. + +Coordinate system: anchor (0,0)=bottom-left, (1,1)=top-right. SetPos +Y is up. Grid +uses anchor (0,1) consistently — children are placed relative to top-left. +]] + +local LayoutCommon = require("@src/API/UI/LayoutCommon") + +export type SizeMode = "fixed" | "shrink" | "stretch" | "auto" +export type CellAlignKind = "start" | "center" | "end" | "stretch" +export type GridAlignKind = "start" | "center" | "end" + +export type GridChildOpts = +{ + skip : boolean?, + -- Per-child cell-alignment overrides. + cellAlignH : CellAlignKind?, + cellAlignV : CellAlignKind?, + aspectRatio : number?, -- shape-lock (cellAlignH/V = stretch only) +} + +export type GridLayoutOpts = +{ + spacing : number?, spacingX : number?, spacingY : number?, + padding : number?, + paddingTop : number?, paddingBottom : number?, paddingLeft : number?, paddingRight : number?, + + columns : number?, rows : number?, + cellSizeX : number?, cellSizeY : number?, + + cellAlign : CellAlignKind?, -- default "center" (both axes) + cellAlignH : CellAlignKind?, -- horizontal cell alignment + cellAlignV : CellAlignKind?, -- vertical cell alignment + + -- Where the entire cell group sits within the container, on each axis. + -- Useful when explicit cellSize fits content smaller than the container's inner area. + gridAlignH : GridAlignKind?, -- default "start" + gridAlignV : GridAlignKind?, -- default "start" + + direction : ("rowMajor" | "columnMajor")?, -- default "rowMajor" + + widthMode : SizeMode?, -- default "fixed" + heightMode : SizeMode?, -- default "fixed" + + panelTemplate : string?, + alpha : number?, +} + +local gridLayoutAPI = {} + +local function trySetWidth(raw, w) + local fn = raw.SetWidth + if type(fn) == "function" then fn(raw, w) end +end +local function trySetHeight(raw, h) + local fn = raw.SetHeight + if type(fn) == "function" then fn(raw, h) end +end + +local function PlaceChild(item, co, originX, originY, cellW, cellH, childW, childH, alignH, alignV) + -- originX/Y is the cell's top-left in "distance from container's top-left" (Y positive = down). + local raw = item.raw + local resized = false + local opts = co or {} + + local offX + if alignH == "start" then offX = 0 + elseif alignH == "end" then offX = cellW - childW + elseif alignH == "stretch" then + if opts.aspectRatio and opts.aspectRatio > 0 then + -- Aspect-locked: derive height from width if cellAlignH=stretch. + local newH = math.floor(cellW / opts.aspectRatio) + trySetWidth(raw, cellW) + trySetHeight(raw, newH) + childW = cellW + childH = newH + else + trySetWidth(raw, cellW) + childW = cellW + end + offX = 0 + resized = true + else offX = math.floor((cellW - childW) / 2) -- "center" default + end + + local offY + if alignV == "start" then offY = 0 + elseif alignV == "end" then offY = cellH - childH + elseif alignV == "stretch" then + if opts.aspectRatio and opts.aspectRatio > 0 and alignH ~= "stretch" then + local newW = math.floor(cellH * opts.aspectRatio) + trySetWidth(raw, newW) + trySetHeight(raw, cellH) + childW = newW + childH = cellH + else + trySetHeight(raw, cellH) + childH = cellH + end + offY = 0 + resized = true + else offY = math.floor((cellH - childH) / 2) + end + + -- Stretch raw-resized a nested layout's panel; re-position its grandchildren + -- against the new size via RefreshLocal (skips the upward Invalidate propagation). + if resized and item.proxy and item.proxy._innerLayout then + item.proxy._innerLayout:RefreshLocal() + end + + raw:SetAnchor(0.0, 1.0) + raw:SetRelativePoint(0.0, 1.0) + raw:SetPos(originX + offX, -(originY + offY)) +end + +local function DoRefresh(self) + self._dirty = false + local opts = self._opts + + local widthMode : SizeMode = opts.widthMode or "fixed" + local heightMode : SizeMode = opts.heightMode or "fixed" + if widthMode == "stretch" or heightMode == "stretch" then + -- Surface the unimplemented mode rather than silently behaving like "fixed". + error("GridLayout: 'stretch' container size mode is not implemented; use 'fixed', 'shrink', or 'auto'", 2) + end + + local pad = opts.padding or 0 + local pt = opts.paddingTop or pad + local pb = opts.paddingBottom or pad + local pl = opts.paddingLeft or pad + local pr = opts.paddingRight or pad + + local spacing = opts.spacing or 0 + local spacingX = opts.spacingX or spacing + local spacingY = opts.spacingY or spacing + + local defaults = nil -- Grid has no defaultChildOpts today; passing nil keeps MergeOpts a no-op. + + -- Filter active items, refresh inner layouts, auto-prune destroyed. + local activeItems = {} + local toPrune = nil + for i, item in self._items do + local ok, visible = pcall(function() return item.raw:IsVisible() end) + if not ok then + toPrune = toPrune or {} + table.insert(toPrune, i) + continue + end + + local co = item._mergedOpts + if co == nil then + co = LayoutCommon.MergeOpts(defaults, item.opts) + item._mergedOpts = co + end + + if not visible then continue end + if co and co.skip then continue end + + if item.proxy and item.proxy._innerLayout then + item.proxy._innerLayout:Refresh() + end + table.insert(activeItems, { item = item, co = co }) + end + + if toPrune then + for j = #toPrune, 1, -1 do + local idx = toPrune[j] + local proxy = self._items[idx].proxy + self._itemByProxy[proxy] = nil + table.remove(self._items, idx) + end + for i = toPrune[1], #self._items do + self._itemByProxy[self._items[i].proxy] = i + end + end + local n = #activeItems + + local containerW, containerH = self.panel:GetSize() + local innerW = containerW - pl - pr + local innerH = containerH - pt - pb + + -- Determine shape (cols, rows). + local cols, rows + if opts.columns then + cols = math.max(1, opts.columns) + rows = math.max(1, math.ceil(n / cols)) + elseif opts.rows then + rows = math.max(1, opts.rows) + cols = math.max(1, math.ceil(n / rows)) + elseif opts.cellSizeX and opts.cellSizeY then + cols = math.max(1, math.floor((innerW + spacingX) / (opts.cellSizeX + spacingX))) + rows = math.max(1, math.ceil(n / cols)) + else + -- Fallback: square-ish from sqrt(N). + cols = math.max(1, math.ceil(math.sqrt(math.max(1, n)))) + rows = math.max(1, math.ceil(n / cols)) + end + + -- Cell size: explicit overrides; otherwise distribute container space. + local cellW = opts.cellSizeX or math.floor((innerW - spacingX * (cols - 1)) / cols) + local cellH = opts.cellSizeY or math.floor((innerH - spacingY * (rows - 1)) / rows) + + -- Container size resolve (shrink/auto) — apply BEFORE per-child placement so + -- cell grouping uses the resolved container extents. + local gridW = pl + pr + (if cols > 0 then cols * cellW + (cols - 1) * spacingX else 0) + local gridH = pt + pb + (if rows > 0 then rows * cellH + (rows - 1) * spacingY else 0) + local newW, newH = containerW, containerH + if widthMode == "shrink" then newW = gridW + elseif widthMode == "auto" and gridW > containerW then newW = gridW end + if heightMode == "shrink" then newH = gridH + elseif heightMode == "auto" and gridH > containerH then newH = gridH end + if math.abs(newW - containerW) > 0.5 or math.abs(newH - containerH) > 0.5 then + self.panel:SetSize(newW, newH) + containerW, containerH = newW, newH + innerW = containerW - pl - pr + innerH = containerH - pt - pb + -- Re-derive cellW/H if they were size-driven (no explicit cellSize). + if not opts.cellSizeX then cellW = math.floor((innerW - spacingX * (cols - 1)) / cols) end + if not opts.cellSizeY then cellH = math.floor((innerH - spacingY * (rows - 1)) / rows) end + end + + local alignH = opts.cellAlignH or opts.cellAlign or "center" + local alignV = opts.cellAlignV or opts.cellAlign or "center" + local rowMajor = (opts.direction or "rowMajor") == "rowMajor" + + -- gridAlignH / gridAlignV: shift the entire cell group within the container + -- when the group is smaller than the inner area. + local groupW = cols * cellW + (cols - 1) * spacingX + local groupH = rows * cellH + (rows - 1) * spacingY + local extraX = math.max(0, innerW - groupW) + local extraY = math.max(0, innerH - groupH) + local groupOffsetX = if opts.gridAlignH == "center" then math.floor(extraX / 2) + elseif opts.gridAlignH == "end" then extraX + else 0 + local groupOffsetY = if opts.gridAlignV == "center" then math.floor(extraY / 2) + elseif opts.gridAlignV == "end" then extraY + else 0 + + for i, entry in activeItems do + local item, co = entry.item, entry.co + local zeroIdx = i - 1 + local col, row + if rowMajor then + col = zeroIdx % cols + row = math.floor(zeroIdx / cols) + else + row = zeroIdx % rows + col = math.floor(zeroIdx / rows) + end + local originX = pl + groupOffsetX + col * (cellW + spacingX) + local originY = pt + groupOffsetY + row * (cellH + spacingY) + + local childW, childH = item.raw:GetSize() + local thisAlignH = (co and co.cellAlignH) or alignH + local thisAlignV = (co and co.cellAlignV) or alignV + PlaceChild(item, co, originX, originY, cellW, cellH, childW, childH, thisAlignH, thisAlignV) + end + + self._measuredWidth = gridW + self._measuredHeight = gridH +end + +function gridLayoutAPI.NewGridLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + + local panelTemplate = opts.panelTemplate or "DialogBox" + local panel = parent:NewPanel(posX, posY, sizeX, sizeY, layer or 0, panelTemplate) + if opts.alpha ~= nil then panel:SetAlpha(opts.alpha) end + + local layoutTable = LayoutCommon.MakeBaseLayout(panel, opts, DoRefresh) + layoutTable._measuredWidth = 0 + layoutTable._measuredHeight = 0 + + function layoutTable.GetMeasuredHeight(sf) sf:Refresh(); return sf._measuredHeight end + function layoutTable.GetMeasuredWidth(sf) sf:Refresh(); return sf._measuredWidth end + + if type(panel) == "table" and panel._raw ~= nil and panel._layout ~= nil then + layoutTable._parentLayout = panel._layout + panel._innerLayout = layoutTable + end + + return layoutTable +end + +return gridLayoutAPI diff --git a/Source/Resources/Scripts/API/UI/HorizontalLayout.luau b/Source/Resources/Scripts/API/UI/HorizontalLayout.luau new file mode 100644 index 00000000..513b36b5 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/HorizontalLayout.luau @@ -0,0 +1,25 @@ +--[[ +HorizontalLayout — main axis is X. Children stack left-to-right by default; pass +`reverse=true` for right-to-left. See LinearLayout.luau for the full opts/childOpts +/algorithm reference. + +Example: + local h = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, 600, 60, 0, { + spacing = 8, padding = 4, alignV = "center", + }) + local label = h:NewText("Name:", 0, 0, 0, "YellowText") + local field = h:NewPanel(0, 0, 200, 30, 0, "InputBoxBackground") + h:SetChildOpts(field, { modeX = "flex", weightX = 1 }) -- field fills remaining horizontal space +]] + +local LinearLayout = require("@src/API/UI/LinearLayout") + +local horizontalLayoutAPI = {} + +function horizontalLayoutAPI.NewHorizontalLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + opts.axis = "x" + return LinearLayout.NewLinearLayout(parent, posX, posY, sizeX, sizeY, layer, opts) +end + +return horizontalLayoutAPI diff --git a/Source/Resources/Scripts/API/UI/LayoutCommon.luau b/Source/Resources/Scripts/API/UI/LayoutCommon.luau new file mode 100644 index 00000000..c6d1ed45 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/LayoutCommon.luau @@ -0,0 +1,225 @@ +--[[ +LayoutCommon — shared internals between LinearLayout and GridLayout. + +Owns: + * SIZE_AFFECTING setter list — proxy-intercepted to invalidate the layout. + * ResolveProxy(child) — find the layout proxy on a wrapper / userdata / proxy. + * WrapChild(raw, layout) — Lua proxy that forwards to the raw widget but invalidates + the layout on size-affecting setters. + * MergeOpts(defaults, opts) — shallow merge with `opts` winning. Used when a layout + supplies a `defaultChildOpts` table. + * MakeBaseLayout(panel, opts, doRefreshFn) + Returns a layout table preloaded with the everything-but-DoRefresh machinery: + Invalidate / Refresh / RefreshLocal / GetSize / Set{Size,Width,Height,Pos*,Anchor, + RelativePoint,Spacing,Padding,ChildOpts} / Remove / Add / NewPanel / NewText / + NewWidget / __index fall-through to .panel / RegisterLayoutRefresh hookup. + The caller (LinearLayout/GridLayout) supplies `doRefreshFn(self)` and tacks on + its own GetMeasuredHeight / GetMeasuredWidth / type-specific bits. +]] + +local layoutCommon = {} + +local SIZE_AFFECTING = { "SetSize", "SetWidth", "SetHeight", "SetText", "SetWrapWidth", "SetFontSize" } +layoutCommon.SIZE_AFFECTING = SIZE_AFFECTING + +-- Try to find the proxy associated with whatever the user handed in. +-- - A proxy itself ({ _raw = ..., _layout = ... }) → return it. +-- - A compound wrapper (UIButton/UIScrollBox/UIInputBox/UIComboBox/etc.) created with a +-- layout as its parent: its first proxy-valued field IS the layout child. Walk fields. +-- - Anything else: return nil. +local function ResolveProxy(child) + if type(child) ~= "table" then return nil end + if child._raw ~= nil then return child end + for _, v in pairs(child) do + if type(v) == "table" and v._raw ~= nil and v._layout ~= nil then return v end + end + return nil +end +layoutCommon.ResolveProxy = ResolveProxy + +local function WrapChild(raw, layout) + local proxy = setmetatable({ _raw = raw, _layout = layout }, { + __index = function(_, k) + local v = raw[k] + if type(v) ~= "function" then return v end + return function(_, ...) return v(raw, ...) end + end, + }) + for _, name in SIZE_AFFECTING do + local original = raw[name] + if type(original) == "function" then + proxy[name] = function(_, ...) + original(raw, ...) -- raw:(args), bypassing the proxy to avoid recursion + layout:Invalidate() + end + end + end + return proxy +end +layoutCommon.WrapChild = WrapChild + +-- Merge `defaults` beneath `itemOpts` (itemOpts wins). Returns nil only if both inputs +-- are nil. Result is a fresh table — DoRefresh callers should cache it on the item to +-- avoid per-frame allocation. +local function MergeOpts(defaults, itemOpts) + if not defaults then return itemOpts end + if not itemOpts then return defaults end + local merged = {} + for k, v in pairs(defaults) do merged[k] = v end + for k, v in pairs(itemOpts) do merged[k] = v end + return merged +end +layoutCommon.MergeOpts = MergeOpts + +function layoutCommon.MakeBaseLayout(panel, opts, doRefreshFn) + local self = { + panel = panel, + content = panel, -- alias for the escape-hatch usage + _opts = opts, + _items = {}, -- ordered list of { proxy, raw, opts, _mergedOpts } + _itemByProxy = {}, -- proxy -> index in _items (for SetChildOpts lookup) + -- _dirty starts FALSE so the first PushItem's Invalidate is a real false→true + -- transition that emplaces the C++ DirtyLayoutTag. Initialising to true would + -- short-circuit the dirty-guard and the C++ tag would never be emplaced, leaving + -- the layout invisible to the pre-render hook. + _dirty = false, + _parentLayout = nil, -- set when our `panel` is itself a wrapped proxy of another layout + } + + -- Invalidate is gated on `_dirty`: each dirty cycle emplaces the C++ DirtyLayoutTag + -- exactly once and propagates upward exactly once. Clearing happens at the top of + -- DoRefresh (`self._dirty = false`), so the next mutation re-arms the chain. + function self.Invalidate(sf) + if sf._dirty then return end + sf._dirty = true + sf.panel:InvalidateLayout() + if sf._parentLayout then sf._parentLayout:Invalidate() end + end + + function self.Refresh(sf) + if not sf._dirty then return end + doRefreshFn(sf) + end + + -- Force a refresh without going through Invalidate (skips the upward-propagation + -- that would re-mark our parent _dirty mid-pass). Used by an outer layout's + -- DoRefresh to re-position grandchildren after raw-resizing us. + function self.RefreshLocal(sf) + sf._dirty = true + doRefreshFn(sf) + end + + function self.GetSize(sf) + sf:Refresh() + return sf.panel:GetSize() + end + + -- Container size mutators invalidate (children may need repositioning). + function self.SetSize(sf, w, h) sf.panel:SetSize(w, h); sf:Invalidate() end + function self.SetWidth(sf, w) sf.panel:SetWidth(w); sf:Invalidate() end + function self.SetHeight(sf, h) sf.panel:SetHeight(h); sf:Invalidate() end + + -- Position / anchor mutators of the container itself don't affect the layout pass. + function self.SetPos(sf, x, y) sf.panel:SetPos(x, y) end + function self.SetPosX(sf, x) sf.panel:SetPosX(x) end + function self.SetPosY(sf, y) sf.panel:SetPosY(y) end + function self.SetAnchor(sf, x, y) sf.panel:SetAnchor(x, y) end + function self.SetRelativePoint(sf, x, y) sf.panel:SetRelativePoint(x, y) end + + function self.SetSpacing(sf, s) sf._opts.spacing = s; sf:Invalidate() end + function self.SetPadding(sf, p) sf._opts.padding = p; sf:Invalidate() end + + function self.SetChildOpts(sf, child, childOpts) + local proxy = ResolveProxy(child) + local idx = proxy and sf._itemByProxy[proxy] + if idx then + sf._items[idx].opts = childOpts + sf._items[idx]._mergedOpts = nil + sf:Invalidate() + end + end + + -- Explicit removal — symmetric with Add. Auto-prune in each layout's DoRefresh + -- (catches "destroyed without :Remove") is the safety net; this is the preferred path. + function self.Remove(sf, child) + local proxy = ResolveProxy(child) + local idx = proxy and sf._itemByProxy[proxy] + if not idx then return end + sf._itemByProxy[proxy] = nil + table.remove(sf._items, idx) + -- Reindex entries shifted by the table.remove. + for i = idx, #sf._items do + sf._itemByProxy[sf._items[i].proxy] = i + end + sf:Invalidate() + end + + local function PushItem(sf, raw, proxy, childOpts) + table.insert(sf._items, { proxy = proxy, raw = raw, opts = childOpts }) + sf._itemByProxy[proxy] = #sf._items + sf:Invalidate() + end + + function self.NewPanel(sf, ...) + local raw = sf.panel:NewPanel(...) + local proxy = WrapChild(raw, sf) + PushItem(sf, raw, proxy, nil) + return proxy + end + function self.NewText(sf, ...) + local raw = sf.panel:NewText(...) + local proxy = WrapChild(raw, sf) + PushItem(sf, raw, proxy, nil) + return proxy + end + function self.NewWidget(sf, ...) + local raw = sf.panel:NewWidget(...) + local proxy = WrapChild(raw, sf) + PushItem(sf, raw, proxy, nil) + return proxy + end + + -- Manual register: wrap (or reuse-wrap) a child added externally and append. + function self.Add(sf, child, childOpts) + local proxy = ResolveProxy(child) + if proxy then + if not sf._itemByProxy[proxy] then + PushItem(sf, proxy._raw, proxy, childOpts) + elseif childOpts then + local item = sf._items[sf._itemByProxy[proxy]] + item.opts = childOpts + item._mergedOpts = nil + sf:Invalidate() + end + return proxy + end + if type(child) == "userdata" then + local newProxy = WrapChild(child, sf) + PushItem(sf, child, newProxy, childOpts) + return newProxy + end + return child + end + + -- Fall through to .panel for any other widget method (GetWorldPos, SetClipChildren, etc.) + -- so the layout walks like a Widget when passed as a parent to compound constructors. + setmetatable(self, { + __index = function(t, k) + local p = rawget(t, "panel") + if p == nil then return nil end + local v = p[k] + if type(v) ~= "function" then return v end + return function(_, ...) return v(p, ...) end + end, + }) + + -- C++ pre-render hook: CanvasRenderer fires the registered closure for any panel + -- tagged DirtyLayoutTag. PushItem / SetChildOpts / Set* mutators all call Invalidate + -- which tags the panel via panel:InvalidateLayout, so the first frame's refresh runs + -- automatically as soon as a child is added. + panel:RegisterLayoutRefresh(function() self:Refresh() end) + + return self +end + +return layoutCommon diff --git a/Source/Resources/Scripts/API/UI/LinearLayout.luau b/Source/Resources/Scripts/API/UI/LinearLayout.luau new file mode 100644 index 00000000..2e74c407 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/LinearLayout.luau @@ -0,0 +1,481 @@ +--[[ +LinearLayout — shared implementation backing VerticalLayout and HorizontalLayout. + +Owns positioning of its direct children along a single axis (main = y for V, x for H), +with axis-absolute alignment (alignH/alignV), padding, spacing, fixed-or-flex per-child +sizing with weight + min/max + opt-in aspectRatio, and bottomUp/rightToLeft via `reverse`. + +Container sizing modes (per axis: widthMode / heightMode): + fixed — declared sizeX/sizeY at construction; ignores content and parent. + shrink — sized to content's intrinsic min; ignores parent allowance. + stretch — NOT IMPLEMENTED (errors at construction). Reserved for future + parent-driven stretch once we thread an "available space" parameter. + auto — max(content_intrinsic, current_panel_size). Fills, grows past if content demands. + +Child sizing modes (per axis: modeX / modeY): + fixed — sizeX/sizeY (or intrinsic if omitted). weight/min/max ignored. + flex — participates in slack distribution by weightX/Y; basis = sizeX/sizeY (or 0); + clamped by minX/maxX/minY/maxY. On the cross axis there's no sibling + competition so weight is silently inert and `flex` means "stretch to fill". + +Cycle rule: container shrink-on-an-axis with a same-axis flex child collapses the +child to its intrinsic size and warns. Cross-axis flex inside a main-axis shrink +container is fine — the rule is per-axis. + +aspectRatio (child opt-in): when set, cross = main / aspectRatio, clamped by cross +min/max. Overrides crossMode for that child. + +defaultChildOpts (container-level): a table of per-child opts merged into each +child's opts when the child does not override them. Useful for "default cross-fill" +patterns: e.g. `defaultChildOpts = { modeX = "flex" }` on a V layout makes every +child stretch its width. + +Children created via layout:NewPanel/NewText/NewWidget are wrapped in a Lua proxy +(see LayoutCommon.WrapChild) that forwards everything to the raw widget userdata via +__index but intercepts size-affecting setters to call layout:Invalidate(). Refresh is +event-driven via RegisterLayoutRefresh on the container's panel — the C++ pre-render +hook fires a registered closure for any panel tagged DirtyLayoutTag. + +Coordinate system: anchor/relativePoint use (0,0)=bottom-left, (1,1)=top-right. +SetPos offsets are in screen-pixel units where +X is right and +Y is UP. So a +top-anchored child needs SetPosY(-N) to move down by N pixels. +]] + +local LayoutCommon = require("@src/API/UI/LayoutCommon") + +export type SizeMode = "fixed" | "shrink" | "stretch" | "auto" +export type ChildMode = "fixed" | "flex" +export type AlignKind = "start" | "center" | "end" + +export type LinearChildOpts = +{ + modeX : ChildMode?, + modeY : ChildMode?, + sizeX : number?, + sizeY : number?, + weightX : number?, + weightY : number?, + minX : number?, maxX : number?, + minY : number?, maxY : number?, + + aspectRatio : number?, -- cross = main / aspectRatio (overrides crossMode) + alignH : AlignKind?, -- per-child cross-axis position override (V layout only) + alignV : AlignKind?, -- per-child cross-axis position override (H layout only) + skip : boolean?, +} + +export type LinearLayoutOpts = +{ + axis : ("x" | "y")?, + spacing : number?, + padding : number?, + paddingTop : number?, paddingBottom : number?, paddingLeft : number?, paddingRight : number?, + reverse : boolean?, + panelTemplate : string?, + alpha : number?, + + widthMode : SizeMode?, -- default "fixed" + heightMode : SizeMode?, -- default "fixed" + + alignH : AlignKind?, -- horizontal positioning (default "start") + alignV : AlignKind?, -- vertical positioning (default "start") + + defaultChildOpts : LinearChildOpts?, -- per-child opt defaults; child opts override +} + +local linearLayoutAPI = {} + +-- Run the layout pass. Idempotent given the same inputs; called via the +-- C++ pre-render hook (RegisterLayoutRefresh) and via outer-layout RefreshLocal. +local function DoRefresh(self) + self._dirty = false + + local opts = self._opts + local mainAxisIsY = (opts.axis == "y") + local reverse = opts.reverse or false + local spacing = opts.spacing or 0 + + local pad = opts.padding or 0 + local pt = opts.paddingTop or pad + local pb = opts.paddingBottom or pad + local pl = opts.paddingLeft or pad + local pr = opts.paddingRight or pad + + -- Leading/trailing pad along main axis (depends on direction). + local leadPad, trailPad + if mainAxisIsY then + leadPad = if reverse then pb else pt + trailPad = if reverse then pt else pb + else + leadPad = if reverse then pr else pl + trailPad = if reverse then pl else pr + end + local crossLeadPad = if mainAxisIsY then pl else pt + local crossTrailPad = if mainAxisIsY then pr else pb + + -- Translate axis-absolute opts to main/cross. + local mainMode : SizeMode = if mainAxisIsY then (opts.heightMode or "fixed") else (opts.widthMode or "fixed") + local crossMode : SizeMode = if mainAxisIsY then (opts.widthMode or "fixed") else (opts.heightMode or "fixed") + if mainMode == "stretch" or crossMode == "stretch" then + -- Surface the unimplemented mode rather than silently behaving like "fixed". + error("LinearLayout: 'stretch' container size mode is not implemented; use 'fixed', 'shrink', or 'auto'", 2) + end + local mainAlign : AlignKind = (if mainAxisIsY then opts.alignV else opts.alignH) or "start" + local crossAlign : AlignKind = (if mainAxisIsY then opts.alignH else opts.alignV) or "start" + + local defaults = opts.defaultChildOpts + + -- Phase A: enumerate active items + measure intrinsics. + -- If a child is itself a layout container, refresh it first so we measure its + -- post-refresh size. Invisible/disabled widgets are skipped. + -- A bare Widget (created via :NewWidget) has no GetSize/SetWidth/SetHeight; treat its + -- intrinsic size as 0 and skip size mutations later. + -- Auto-prune: if `raw:IsVisible()` errors (destroyed widget), drop the item from + -- _items / _itemByProxy after the pass. Explicit `:Remove` is preferred but this + -- keeps a forgotten DestroyWidget from crashing the layout. + local activeItems = {} + local toPrune = nil -- list of indices into self._items to remove after iteration + for i, item in self._items do + local ok, visible = pcall(function() return item.raw:IsVisible() end) + if not ok then + toPrune = toPrune or {} + table.insert(toPrune, i) + continue + end + + -- Lazy-merge defaultChildOpts under per-child opts; cache on the item so we don't + -- allocate a fresh table each refresh. SetChildOpts / Add nil out _mergedOpts. + local co = item._mergedOpts + if co == nil then + co = LayoutCommon.MergeOpts(defaults, item.opts) + item._mergedOpts = co + end + + if co and co.skip then continue end + if not visible then continue end + + if item.proxy and item.proxy._innerLayout then + item.proxy._innerLayout:Refresh() + end + + local hasSize = (type(item.raw.GetSize) == "function") + local ix, iy = 0, 0 + if hasSize then ix, iy = item.raw:GetSize() end + + table.insert(activeItems, { + raw = item.raw, proxy = item.proxy, opts = co, + intrinsicX = ix, intrinsicY = iy, hasSize = hasSize, + }) + end + + if toPrune then + -- Remove from highest index down so shifts don't invalidate the lower indices. + for j = #toPrune, 1, -1 do + local idx = toPrune[j] + local proxy = self._items[idx].proxy + self._itemByProxy[proxy] = nil + table.remove(self._items, idx) + end + -- Reindex everything after the lowest prune point. + for i = toPrune[1], #self._items do + self._itemByProxy[self._items[i].proxy] = i + end + end + + -- Phase B: container size resolve (shrink/auto write back to self.panel). + local intrinsicMainSum = 0 + local maxIntrinsicCross = 0 + for _, it in activeItems do + local im = if mainAxisIsY then it.intrinsicY else it.intrinsicX + local ic = if mainAxisIsY then it.intrinsicX else it.intrinsicY + intrinsicMainSum += im + if ic > maxIntrinsicCross then maxIntrinsicCross = ic end + end + local mainGap = if #activeItems > 1 then spacing * (#activeItems - 1) else 0 + local intrinsicMainExtent = leadPad + trailPad + intrinsicMainSum + mainGap + local intrinsicCrossExtent = crossLeadPad + crossTrailPad + maxIntrinsicCross + + local containerW, containerH = self.panel:GetSize() + local containerMain = if mainAxisIsY then containerH else containerW + local containerCross = if mainAxisIsY then containerW else containerH + + if mainMode == "shrink" then + containerMain = intrinsicMainExtent + elseif mainMode == "auto" then + if intrinsicMainExtent > containerMain then containerMain = intrinsicMainExtent end + end + + if crossMode == "shrink" then + containerCross = intrinsicCrossExtent + elseif crossMode == "auto" then + if intrinsicCrossExtent > containerCross then containerCross = intrinsicCrossExtent end + end + + -- Persist resolved container size to the panel widget. Calling Set on the panel + -- (which may be a proxy if our parent is a layout) propagates Invalidate upward; + -- the parent's Invalidate guard early-outs while the parent itself is mid-refresh. + local newW = if mainAxisIsY then containerCross else containerMain + local newH = if mainAxisIsY then containerMain else containerCross + if math.abs(newW - containerW) > 0.5 or math.abs(newH - containerH) > 0.5 then + self.panel:SetSize(newW, newH) + end + + local innerMain = containerMain - leadPad - trailPad + local innerCross = containerCross - crossLeadPad - crossTrailPad + + -- Cycle rule: shrink-on-main + flex children → coerce flex to fixed (intrinsic). + local containerShrinksOnMain = (mainMode == "shrink") + + -- Phase C: resolve per-child main-axis sizes. + local fixedSum = 0 + local flexItems = {} + local warnedShrinkFlex = false + for _, it in activeItems do + local co = it.opts or {} + local modeMain : ChildMode = (if mainAxisIsY then co.modeY else co.modeX) or "fixed" + local sizeMain = if mainAxisIsY then co.sizeY else co.sizeX + local intrinsicMain = if mainAxisIsY then it.intrinsicY else it.intrinsicX + + if containerShrinksOnMain and modeMain == "flex" then + if not warnedShrinkFlex then + warn("LinearLayout: flex children require a bounded main axis; container is shrink, falling back to intrinsic") + warnedShrinkFlex = true + end + modeMain = "fixed" + end + + if modeMain == "flex" then + it.modeMain = "flex" + it.basisMain = sizeMain or 0 + it.weight = (if mainAxisIsY then co.weightY else co.weightX) or 1 + it.minMain = if mainAxisIsY then co.minY else co.minX + it.maxMain = if mainAxisIsY then co.maxY else co.maxX + it.resolvedMain = it.basisMain + table.insert(flexItems, it) + else + it.modeMain = "fixed" + it.resolvedMain = sizeMain or intrinsicMain + fixedSum += it.resolvedMain + end + end + + -- Distribute slack to flex children — weight ratio with CSS-flex-style clamp loop. + if #flexItems > 0 then + local availableMain = innerMain - fixedSum - mainGap + local sumBasis = 0 + local sumWeight = 0 + for _, it in flexItems do + sumBasis += it.basisMain + sumWeight += it.weight + end + local slack = availableMain - sumBasis + for _, it in flexItems do + local share = if sumWeight > 0 then slack * it.weight / sumWeight else 0 + it.resolvedMain = it.basisMain + share + it._unclamped = true + end + + -- Iterate: each pass clamps min/max violations and redistributes the + -- freed/owed space to remaining unclamped flex items by weight. + for iter = 1, #flexItems + 2 do + local toRedistribute = 0 + local anyClampedThisPass = false + for _, it in flexItems do + if it._unclamped then + if it.minMain and it.resolvedMain < it.minMain then + toRedistribute -= (it.minMain - it.resolvedMain) + it.resolvedMain = it.minMain + it._unclamped = false + anyClampedThisPass = true + elseif it.maxMain and it.resolvedMain > it.maxMain then + toRedistribute += (it.resolvedMain - it.maxMain) + it.resolvedMain = it.maxMain + it._unclamped = false + anyClampedThisPass = true + end + end + end + if not anyClampedThisPass then break end + local sumW = 0 + for _, it in flexItems do + if it._unclamped then sumW += it.weight end + end + if sumW <= 0 then break end + for _, it in flexItems do + if it._unclamped then + it.resolvedMain += toRedistribute * it.weight / sumW + end + end + end + + for _, it in flexItems do + it.resolvedMain = math.floor(math.max(0, it.resolvedMain)) + it._unclamped = nil + end + end + + -- Phase D: resolve per-child cross-axis sizes. + for _, it in activeItems do + local co = it.opts or {} + local crossModeChild : ChildMode = (if mainAxisIsY then co.modeX else co.modeY) or "fixed" + local sizeCross = if mainAxisIsY then co.sizeX else co.sizeY + local intrinsicCross = if mainAxisIsY then it.intrinsicX else it.intrinsicY + local minCross = if mainAxisIsY then co.minX else co.minY + local maxCross = if mainAxisIsY then co.maxX else co.maxY + + local crossSize + if co.aspectRatio and co.aspectRatio > 0 then + crossSize = it.resolvedMain / co.aspectRatio + elseif crossModeChild == "flex" then + crossSize = innerCross + else + crossSize = sizeCross or intrinsicCross + end + + if minCross and crossSize < minCross then crossSize = minCross end + if maxCross and crossSize > maxCross then crossSize = maxCross end + it.resolvedCross = math.floor(math.max(0, crossSize)) + it.crossModeResolved = crossModeChild + end + + -- Apply size mutations to raw children (only when changed, to avoid extra + -- DirtyWidgetData churn). We mutate the RAW widget — proxy interception + -- targets the layout itself, not the raw, so this doesn't re-Invalidate. + for _, it in activeItems do + if it.hasSize then + -- Only resize an axis when the resolved size actually differs from + -- intrinsic — avoids redundant DirtyWidgetData churn and avoids + -- calling SetWidth/SetHeight on widgets that don't support them + -- (e.g. Text exposes GetSize but not the size setters; we leave + -- such widgets at their intrinsic size). + local intrinsicMain = if mainAxisIsY then it.intrinsicY else it.intrinsicX + local intrinsicCross = if mainAxisIsY then it.intrinsicX else it.intrinsicY + local mainSetterName = if mainAxisIsY then "SetHeight" else "SetWidth" + local crossSetterName = if mainAxisIsY then "SetWidth" else "SetHeight" + + if math.abs(it.resolvedMain - intrinsicMain) > 0.5 then + local setter = it.raw[mainSetterName] + if type(setter) == "function" then setter(it.raw, it.resolvedMain) end + end + if math.abs(it.resolvedCross - intrinsicCross) > 0.5 then + local setter = it.raw[crossSetterName] + if type(setter) == "function" then setter(it.raw, it.resolvedCross) end + end + end + + -- If this child is itself a layout, the raw resize bypassed its + -- layout-aware proxy and didn't invalidate it. Re-position its + -- grandchildren via RefreshLocal (skips the upward-propagation that + -- would re-mark our own _dirty mid-refresh). + if it.proxy and it.proxy._innerLayout then + it.proxy._innerLayout:RefreshLocal() + end + end + + -- Phase E: place children. + -- Anchor placed at the leading corner along main, and at start cross edge. + -- V topDown: (0, 1) V bottomUp: (0, 0) H L->R: (0, 1) H R->L: (1, 1) + local anchorMain = if mainAxisIsY then (if reverse then 0 else 1) else (if reverse then 1 else 0) + local anchorCross = if mainAxisIsY then 0 else 1 + local anchorX = if mainAxisIsY then anchorCross else anchorMain + local anchorY = if mainAxisIsY then anchorMain else anchorCross + + local cursor = leadPad + -- mainAlign shifts the starting cursor when content is shorter than the container + -- and there are no flex children (flex would consume all leftover otherwise). + if (mainAlign == "center" or mainAlign == "end") and #flexItems == 0 then + local content = 0 + for _, it in activeItems do content += it.resolvedMain end + content += mainGap + local leftover = math.max(0, innerMain - content) + cursor += if mainAlign == "center" then math.floor(leftover / 2) else leftover + end + + local maxChildCross = 0 + + for _, it in activeItems do + it.raw:SetAnchor(anchorX, anchorY) + it.raw:SetRelativePoint(anchorX, anchorY) + + local co = it.opts or {} + local childCrossAlignOpt : AlignKind? = if mainAxisIsY then co.alignH else co.alignV + local childCrossAlign = childCrossAlignOpt or crossAlign + + local crossDist + if it.crossModeResolved == "flex" and not co.aspectRatio then + -- Cross-axis flex fills the cross extent — sit at the cross start edge. + crossDist = crossLeadPad + elseif childCrossAlign == "center" then + crossDist = crossLeadPad + math.floor((innerCross - it.resolvedCross) / 2) + elseif childCrossAlign == "end" then + crossDist = containerCross - crossTrailPad - it.resolvedCross + else -- "start" + crossDist = crossLeadPad + end + + -- Convert (cursor, crossDist) → SetPos(x, y). + -- V (anchor.x=0): setX = +crossDist; H (anchor.y=1): setY = -crossDist + -- V topDown (ay=1): setY = -cursor; V bottomUp (ay=0): setY = +cursor + -- H L->R (ax=0): setX = +cursor; H R->L (ax=1): setX = -cursor + local setX, setY + if mainAxisIsY then + setX = crossDist + setY = if reverse then cursor else -cursor + else + setX = if reverse then -cursor else cursor + setY = -crossDist + end + it.raw:SetPos(setX, setY) + + cursor += it.resolvedMain + spacing + if it.resolvedCross > maxChildCross then maxChildCross = it.resolvedCross end + end + + -- Phase F: measured extents. + local mainExtent = leadPad + trailPad + if #activeItems > 0 then + local sum = 0 + for _, it in activeItems do sum += it.resolvedMain end + mainExtent += sum + spacing * (#activeItems - 1) + end + local crossExtent = crossLeadPad + maxChildCross + crossTrailPad + + self._measuredMain = mainExtent + self._measuredCross = crossExtent +end + +function linearLayoutAPI.NewLinearLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + if opts.axis ~= "x" and opts.axis ~= "y" then opts.axis = "y" end + + local panelTemplate = opts.panelTemplate or "DialogBox" + local panel = parent:NewPanel(posX, posY, sizeX, sizeY, layer or 0, panelTemplate) + if opts.alpha ~= nil then panel:SetAlpha(opts.alpha) end + + local layoutTable = LayoutCommon.MakeBaseLayout(panel, opts, DoRefresh) + layoutTable._measuredMain = 0 + layoutTable._measuredCross = 0 + + function layoutTable.GetMeasuredHeight(sf) + sf:Refresh() + return if sf._opts.axis == "y" then sf._measuredMain else sf._measuredCross + end + function layoutTable.GetMeasuredWidth(sf) + sf:Refresh() + return if sf._opts.axis == "y" then sf._measuredCross else sf._measuredMain + end + + -- If our container panel is itself a wrapped proxy (i.e. parent was a layout that just + -- wrapped us in PushItem), hook the upward propagation chain so a deep mutation invalidates + -- every enclosing layout. + if type(panel) == "table" and panel._raw ~= nil and panel._layout ~= nil then + layoutTable._parentLayout = panel._layout + -- Tag the proxy with a back-reference so the outer layout's DoRefresh can refresh + -- us before measuring (handles shrink-driven inner panel sizes correctly). + panel._innerLayout = layoutTable + end + + return layoutTable +end + +return linearLayoutAPI diff --git a/Source/Resources/Scripts/API/UI/MenuBar.luau b/Source/Resources/Scripts/API/UI/MenuBar.luau new file mode 100644 index 00000000..d6425c8e --- /dev/null +++ b/Source/Resources/Scripts/API/UI/MenuBar.luau @@ -0,0 +1,206 @@ +--[[ +MenuBar — horizontal menu bar with click-to-open dropdowns. + +Click-outside dismissal uses a transparent full-screen "shield" panel as a +sibling of the bar. Bar buttons and dropdown items still receive their clicks +because they sit deeper in the widget hierarchy than the shield (hierarchy +depth dominates the canvas hit-test ranking). + + local bar = MenuBar.NewMenuBar(parent, 0, -22, 1280, 22, 1000, { + menus = { + { label = "Debug", items = { + { label = "Reload Scripts", onClick = fn, enabled = true }, + { label = "Reload Shaders", enabled = false }, -- greyed + }}, + }, + }) +]] + +local EditorButton = require("@src/API/UI/EditorButton") + +local menuBarAPI = {} + +UI.RegisterTextTemplate("DevToolsText", +{ + font = "Data/Fonts/OpenSans-Regular.ttf", + size = 8.0, + borderSize = 1.0, + borderColor = vector.create(0.0, 0.0, 0.0), +}) + +local TOP_BTN_W = 56 +local TOP_BTN_LAYER = 1 +local TEXT_TEMPLATE = "DevToolsText" +local ITEM_H = 18 +local ITEM_PAD_X = 16 -- horizontal padding around the longest label in a dropdown +local ITEM_MIN_W = 60 -- floor for very short labels +local DROPDOWN_LAYER = 2 +local SHIELD_DRAW_OFFSET = -1 +local SHIELD_SIZE = 8192 + +local BAR_BG_COLOR = vector.create(0.10, 0.10, 0.12) +local BAR_BG_ALPHA = 0.92 +local DROPDOWN_BG_COLOR = vector.create(0.10, 0.10, 0.12) +local DROPDOWN_BG_ALPHA = 0.96 +local BORDER_COLOR = vector.create(0.30, 0.30, 0.35) +local BORDER_SIZE = 1 + +local function CloseMenu(self) + if self._dropdown ~= nil then + UI.DestroyWidget(self._dropdown) + self._dropdown = nil + end + if self._shield ~= nil then + UI.DestroyWidget(self._shield) + self._shield = nil + end + if self._openIdx ~= nil then + self._buttons[self._openIdx]:SetHighlighted(false) + self._openIdx = nil + end +end + +local function CreateDropdown(self, idx) + local menuDef = self._menus[idx] + local items = menuDef.items or {} + local btn = self._buttons[idx] + + local widest = 0 + for _, it in items do + local w, _ = UI.CalculateTextSize(it.label or "", TEXT_TEMPLATE) + if w > widest then widest = w end + end + local itemW = math.max(ITEM_MIN_W, math.ceil(widest) + ITEM_PAD_X) + + -- Child of the bar so hierarchy depth ranks dropdown clicks above the shield. + local dropdownH = #items * ITEM_H + local btnX = btn:GetPosX() + local dropdown = self.panel:NewPanel(btnX, 0, itemW, dropdownH, DROPDOWN_LAYER, "DebugDarkGrey") + dropdown:SetAnchor(0.0, 0.0) + dropdown:SetRelativePoint(0.0, 1.0) + dropdown:SetColor(DROPDOWN_BG_COLOR) + dropdown:SetAlpha(DROPDOWN_BG_ALPHA) + dropdown:SetBorderColor(BORDER_COLOR) + dropdown:SetBorderSize(BORDER_SIZE) + dropdown:SetClipChildren(true) + + for i, it in items do + local itemBtn = EditorButton.NewEditorButton(dropdown, 0, -((i - 1) * ITEM_H), itemW, ITEM_H, 0, { + text = it.label or "", + textTemplate = TEXT_TEMPLATE, + textAlign = "left", + }) + itemBtn:SetAnchor(0.0, 1.0) + itemBtn:SetRelativePoint(0.0, 1.0) + + if it.enabled == false then + itemBtn.text:SetAlpha(0.4) + itemBtn:SetInteractable(false) + else + local onClick = it.onClick + itemBtn:SetOnMouseUp(function() + CloseMenu(self) + if onClick ~= nil then onClick() end + end) + end + end + + return dropdown +end + +local function CreateShield(self) + local shield = self._parent:NewPanel(0, 0, SHIELD_SIZE, SHIELD_SIZE, self._layer + SHIELD_DRAW_OFFSET, "DebugDarkGrey") + shield:SetAnchor(0.0, 0.0) + shield:SetRelativePoint(0.0, 0.0) + shield:SetAlpha(0) + shield:SetOnMouseUp(function() CloseMenu(self) end) + return shield +end + +local function OpenDropdown(self, idx) + if self._openIdx == idx then return end + + if self._dropdown ~= nil then + UI.DestroyWidget(self._dropdown) + self._dropdown = nil + end + + if self._openIdx ~= nil then + self._buttons[self._openIdx]:SetHighlighted(false) + end + + self._openIdx = idx + self._buttons[idx]:SetHighlighted(true) + self._dropdown = CreateDropdown(self, idx) + + if self._shield == nil then + self._shield = CreateShield(self) + end +end + +local function ToggleDropdown(self, idx) + if self._openIdx == idx then + CloseMenu(self) + else + OpenDropdown(self, idx) + end +end + +function menuBarAPI.NewMenuBar(parent, posX, posY, width, height, layer, opts) + opts = opts or {} + + local barPanel = parent:NewPanel(posX, posY, width, height, layer, "DebugDarkGrey") + barPanel:SetColor(BAR_BG_COLOR) + barPanel:SetAlpha(BAR_BG_ALPHA) + + local self = { + panel = barPanel, + _parent = parent, + _opts = opts, + _menus = opts.menus or {}, + _buttons = {}, + _dropdown = nil, + _shield = nil, + _openIdx = nil, + _layer = layer, + } + + local paddingLeft = opts.paddingLeft or 4 + local spacing = opts.spacing or 0 + + local cursorX = paddingLeft + for i, m in self._menus do + local btn = EditorButton.NewEditorButton(barPanel, cursorX, 0, TOP_BTN_W, height, TOP_BTN_LAYER, { + text = m.label or "", + textTemplate = TEXT_TEMPLATE, + }) + btn:SetAnchor(0.0, 0.0) + btn:SetRelativePoint(0.0, 0.0) + + btn:SetOnMouseUp(function() ToggleDropdown(self, i) end) + btn:SetOnHoverBegin(function() + -- While a dropdown is open, hovering a sibling switches without a click. + if self._openIdx ~= nil and self._openIdx ~= i then + OpenDropdown(self, i) + end + end) + + self._buttons[i] = btn + cursorX += TOP_BTN_W + spacing + end + + -- Forward unknown method calls to .panel so the bar walks like a Widget when used as a parent. + setmetatable(self, { + __index = function(t, k) + local p = rawget(t, "panel") + if p == nil then return nil end + local v = p[k] + if type(v) ~= "function" then return v end + return function(_, ...) return v(p, ...) end + end, + }) + + return self +end + +return menuBarAPI diff --git a/Source/Resources/Scripts/API/UI/VerticalLayout.luau b/Source/Resources/Scripts/API/UI/VerticalLayout.luau new file mode 100644 index 00000000..dca324c7 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/VerticalLayout.luau @@ -0,0 +1,25 @@ +--[[ +VerticalLayout — main axis is Y. Children stack top-down by default; pass `reverse=true` +for bottom-up. See LinearLayout.luau for the full opts/childOpts/algorithm reference. + +Example: + local v = UIVerticalLayout.NewVerticalLayout(parent, 0, 0, 400, 600, 0, { + spacing = 10, padding = 5, heightMode = "shrink", alignH = "center", + }) + local row1 = v:NewPanel(0, 0, 0, 40, 0, "DialogBox") + local row2 = v:NewText("hello", 0, 0, 0, "DefaultButtonText") + v:SetChildOpts(row1, { modeY = "flex", weightY = 1 }) -- row1 fills remaining vertical space + print(v:GetMeasuredHeight()) -- triggers Refresh, returns total content height +]] + +local LinearLayout = require("@src/API/UI/LinearLayout") + +local verticalLayoutAPI = {} + +function verticalLayoutAPI.NewVerticalLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + opts.axis = "y" + return LinearLayout.NewLinearLayout(parent, posX, posY, sizeX, sizeY, layer, opts) +end + +return verticalLayoutAPI diff --git a/Source/Resources/Scripts/API/UI/WidgetExtensions.luau b/Source/Resources/Scripts/API/UI/WidgetExtensions.luau new file mode 100644 index 00000000..60aa8dac --- /dev/null +++ b/Source/Resources/Scripts/API/UI/WidgetExtensions.luau @@ -0,0 +1,64 @@ +--[[ +WidgetExtensions — adds Lua-defined methods to widget metatables in place. + +luaL_sandbox seals globals and the string metatable but does NOT iterate +LUA_REGISTRYINDEX, so the per-type *MetaTable entries that LuaMetaTable:: +Register stores in the registry stay writable post-sandbox. We grab them via +getmetatable() on a probe instance and write the methods directly. After +Install(), every Panel — including ones created later — has the methods. + +Currently Panel only; Text/Widget have separate metatables. + +Methods on Panel: + panel:MakeDraggable(handle?) + Wire up drag behavior. The handle drives the drag; the panel updates + its own SetPos every frame while held. Omit `handle` to use the panel + itself. Engine implicitly captures the mouse — Held keeps firing past + the handle's edges until MouseUp. +]] + +local M = {} + +local function MakeDraggable(self, handle) + handle = handle or self + local mx0, my0 = 0, 0 + local wx0, wy0 = 0, 0 + + handle:SetOnMouseDown(function() + mx0, my0 = UI.GetMousePos() + wx0, wy0 = self:GetPosX(), self:GetPosY() + end) + handle:SetOnMouseHeld(function() + local mx, my = UI.GetMousePos() + self:SetPos(wx0 + (mx - mx0), wy0 + (my - my0)) + end) +end + +M.MakeDraggable = MakeDraggable + +local function ExtendMetatable(probe, methods) + local mt = getmetatable(probe) + if mt == nil then + error("WidgetExtensions: probe widget has no metatable; sandbox or registration changed?", 2) + end + for name, fn in methods do + mt[name] = fn + end +end + +local installed = false +function M.Install() + if installed then return end + installed = true + + -- Probe to grab the Panel metatable. Canvas leaks (no Lua API to destroy + -- a canvas) but is 1x1 and off-screen; the probe panel is destroyed below. + local probeCanvas = UI.GetCanvas("__widget_ext_probe", 0, 0, 1, 1, false) + local probePanel = probeCanvas:NewPanel(0, 0, 1, 1, 0, "DebugDarkGrey") + ExtendMetatable(probePanel, { MakeDraggable = MakeDraggable }) + UI.DestroyWidget(probePanel) +end + +M.Install() + +return M diff --git a/Source/Resources/Scripts/Bootstrap/Init.luau b/Source/Resources/Scripts/Bootstrap/Init.luau index c768d3f2..49180a9d 100644 --- a/Source/Resources/Scripts/Bootstrap/Init.luau +++ b/Source/Resources/Scripts/Bootstrap/Init.luau @@ -1,6 +1,7 @@ local UITemplates = require("@src/API/UI/Templates") UITemplates.RegisterTemplates(); +local _ = require("@src/API/UI/WidgetExtensions") local _ = require("@src/API/UI/CursorInfo") local _ = require("@src/API/UI/ItemSlot") local _ = require("@src/API/UI/ActionBar") diff --git a/Source/Resources/Scripts/Editor/ClockEditor.luau b/Source/Resources/Scripts/Editor/ClockEditor.luau new file mode 100644 index 00000000..3ed676ec --- /dev/null +++ b/Source/Resources/Scripts/Editor/ClockEditor.luau @@ -0,0 +1,175 @@ +--[[ +ClockEditor — Luau Clock window, hidden by default; toggled via EditorRegistry. + +Layer 1100 sits above the dev top bar (1000) so the window draws over the bar +and open dropdowns. Speed control is +/- step buttons rather than a slider: +the Slider widget's inner-panel anchoring doesn't compose with non-default +anchors on the slider itself, follow-up once NumericInput exists. +]] + +local EditorButton = require("@src/API/UI/EditorButton") +local EditorRegistry = require("@src/API/UI/EditorRegistry") + +-- MenuBar's module-load side effect registers the DevToolsText template we use below. +require("@src/API/UI/MenuBar") + +local PANEL_W = 220 +local PANEL_H = 80 +local TITLE_H = 16 +local ROW_H = 20 +local SIDE_PAD = 6 +local ROW3_PAD = 5 +local BTN_TEXT_TPL = "DevToolsText" +local SPEED_STEP = 0.25 +local LAYER = 1100 + +local WINDOW_BG_COLOR = vector.create(0.10, 0.10, 0.12) +local WINDOW_BG_ALPHA = 0.96 +local BORDER_COLOR = vector.create(0.30, 0.30, 0.35) +local BORDER_SIZE = 1 + +local BTN_OPTS = { + textTemplate = BTN_TEXT_TPL, + borderColor = BORDER_COLOR, + borderSize = BORDER_SIZE, +} + +local function MakeBtnOpts(text) + -- Shallow-copy so per-button `text` doesn't smash the shared template. + return { + text = text, + textTemplate = BTN_OPTS.textTemplate, + borderColor = BTN_OPTS.borderColor, + borderSize = BTN_OPTS.borderSize, + } +end + +local function FormatTime(seconds) + local total = math.floor(seconds) + local h = math.floor(total / 3600) + local m = math.floor(total / 60) % 60 + local s = total % 60 + return string.format("%02d:%02d:%02d", h, m, s) +end + +local function FormatSpeed(speed) + return string.format("Speed: %.2fx", speed) +end + +local canvas = UI.GetCanvas("ClockEditor", 0, 0, 1920, 1080, false) + +local windowPanel = canvas:NewPanel(420, -80, PANEL_W, PANEL_H, LAYER, "DebugDarkGrey") +windowPanel:SetAnchor(0.0, 1.0) +windowPanel:SetRelativePoint(0.0, 1.0) +windowPanel:SetVisible(false) +windowPanel:SetColor(WINDOW_BG_COLOR) +windowPanel:SetAlpha(WINDOW_BG_ALPHA) +windowPanel:SetBorderColor(BORDER_COLOR) +windowPanel:SetBorderSize(BORDER_SIZE) + +-- Title bar — drag handle for the window. +local titleBar = windowPanel:NewPanel(0, 0, PANEL_W, TITLE_H, 1, "DebugDarkGrey") +titleBar:SetAnchor(0.0, 1.0) +titleBar:SetRelativePoint(0.0, 1.0) +titleBar:SetColor(vector.create(0.06, 0.06, 0.08)) +titleBar:SetAlpha(WINDOW_BG_ALPHA) + +local titleText = titleBar:NewText("Clock", SIDE_PAD, 0, 1, BTN_TEXT_TPL) +titleText:SetAnchor(0.0, 0.5) +titleText:SetRelativePoint(0.0, 0.5) +-- Without this, clicking the label would block the title-bar drag. +titleText:SetInteractable(false) + +local CLOSE_BTN_SIZE = 12 +local closeBtn = EditorButton.NewEditorButton(titleBar, -2, 0, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE, 1, MakeBtnOpts("X")) +closeBtn:SetAnchor(1.0, 0.5) +closeBtn:SetRelativePoint(1.0, 0.5) +-- Single-glyph widgets bake the glyph's right-side bearing into their width, +-- which leaves the visible glyph ~1px left of center. +closeBtn.text:SetPosX(1) + +windowPanel:MakeDraggable(titleBar) + +-- Row 1: Reset Time | Set Time to Noon +local row1Y = -(TITLE_H + 2) +local btnW = (PANEL_W - SIDE_PAD * 2 - 4) / 2 +local resetTimeBtn = EditorButton.NewEditorButton(windowPanel, SIDE_PAD, row1Y, btnW, ROW_H, 1, MakeBtnOpts("Reset Time")) +resetTimeBtn:SetAnchor(0.0, 1.0) +resetTimeBtn:SetRelativePoint(0.0, 1.0) +resetTimeBtn:SetOnMouseUp(function() Time.Reset() end) + +local setNoonBtn = EditorButton.NewEditorButton(windowPanel, -SIDE_PAD, row1Y, btnW, ROW_H, 1, MakeBtnOpts("Set To Noon")) +setNoonBtn:SetAnchor(1.0, 1.0) +setNoonBtn:SetRelativePoint(1.0, 1.0) +setNoonBtn:SetOnMouseUp(function() Time.SetToNoon() end) + +-- Row 2: Reset Speed | Speed - | Speed + +local row2Y = row1Y - ROW_H - 2 +local stepBtnW = (btnW - 4) / 2 + +local resetSpeedBtn = EditorButton.NewEditorButton(windowPanel, SIDE_PAD, row2Y, btnW, ROW_H, 1, MakeBtnOpts("Reset Speed")) +resetSpeedBtn:SetAnchor(0.0, 1.0) +resetSpeedBtn:SetRelativePoint(0.0, 1.0) + +local speedDownBtn = EditorButton.NewEditorButton(windowPanel, -SIDE_PAD - stepBtnW - 4, row2Y, stepBtnW, ROW_H, 1, MakeBtnOpts("-")) +speedDownBtn:SetAnchor(1.0, 1.0) +speedDownBtn:SetRelativePoint(1.0, 1.0) +speedDownBtn.text:SetPosX(1) -- single-glyph nudge, see closeBtn + +local speedUpBtn = EditorButton.NewEditorButton(windowPanel, -SIDE_PAD, row2Y, stepBtnW, ROW_H, 1, MakeBtnOpts("+")) +speedUpBtn:SetAnchor(1.0, 1.0) +speedUpBtn:SetRelativePoint(1.0, 1.0) +speedUpBtn.text:SetPosX(1) + +-- Row 3: Speed readout (left) and HH:MM:SS readout (right). +local row3Y = row2Y - ROW_H - ROW3_PAD +local speedText = windowPanel:NewText("Speed: 1.00x", SIDE_PAD, row3Y, 1, BTN_TEXT_TPL) +speedText:SetAnchor(0.0, 1.0) +speedText:SetRelativePoint(0.0, 1.0) + +local timeText = windowPanel:NewText("00:00:00", -SIDE_PAD, row3Y, 1, BTN_TEXT_TPL) +timeText:SetAnchor(1.0, 1.0) +timeText:SetRelativePoint(1.0, 1.0) + +local function RefreshTimeText(seconds) + timeText:SetText(FormatTime(seconds)) +end + +local function RefreshSpeedText() + speedText:SetText(FormatSpeed(Time.GetSpeedModifier())) +end + +resetSpeedBtn:SetOnMouseUp(function() + Time.SetSpeedModifier(1.0) + RefreshSpeedText() +end) +speedDownBtn:SetOnMouseUp(function() + Time.SetSpeedModifier(math.max(0.0, Time.GetSpeedModifier() - SPEED_STEP)) + RefreshSpeedText() +end) +speedUpBtn:SetOnMouseUp(function() + Time.SetSpeedModifier(Time.GetSpeedModifier() + SPEED_STEP) + RefreshSpeedText() +end) + +local methods = {} + +function methods:Show() + windowPanel:SetVisible(true) + RefreshTimeText(Time.GetSeconds()) + RefreshSpeedText() + Time.SetOnSecondChanged(RefreshTimeText) +end + +function methods:Hide() + windowPanel:SetVisible(false) + Time.SetOnSecondChanged(nil) +end + +function methods:IsVisible() + return windowPanel:IsVisible() +end + +closeBtn:SetOnMouseUp(function() methods:Hide() end) + +EditorRegistry.Register("Clock", methods) diff --git a/Source/Resources/Scripts/Editor/DevTopBar.luau b/Source/Resources/Scripts/Editor/DevTopBar.luau new file mode 100644 index 00000000..cab00082 --- /dev/null +++ b/Source/Resources/Scripts/Editor/DevTopBar.luau @@ -0,0 +1,41 @@ +--[[ +DevTopBar — dev menu bar drawn below the ImGui main menu during the +ImGui->Lua migration. Greyed entries are not yet ported. +]] + +local MenuBar = require("@src/API/UI/MenuBar") +local EditorRegistry = require("@src/API/UI/EditorRegistry") + +-- TODO: query ImGui::GetFrameHeight() instead of hardcoding 11. +local IMGUI_MENU_BAR_HEIGHT = 11 +local BAR_LAYER = 1000 +local BAR_HEIGHT = 22 +local CANVAS_W = 1920 +local CANVAS_H = 1080 + +local canvas = UI.GetCanvas("DevTopBar", 0, 0, CANVAS_W, CANVAS_H, false) + +local bar = MenuBar.NewMenuBar(canvas, 0, -IMGUI_MENU_BAR_HEIGHT, CANVAS_W, BAR_HEIGHT, BAR_LAYER, { + paddingLeft = 8, + spacing = 4, + menus = { + { label = "Debug", items = { + { label = "Reload Scripts", enabled = false }, + { label = "Reload Shaders", enabled = false }, + }}, + { label = "Window", items = { + { label = "Clock", enabled = true, onClick = function() EditorRegistry.Toggle("Clock") end }, + }}, + { label = "Editor", items = { + { label = "(none)", enabled = false }, + }}, + { label = "Theme", items = { + { label = "(none)", enabled = false }, + }}, + { label = "Help", items = { + { label = "(none)", enabled = false }, + }}, + }, +}) +bar:SetAnchor(0.0, 1.0) +bar:SetRelativePoint(0.0, 1.0) diff --git a/Source/Resources/Scripts/UI/CharacterCreation.luau b/Source/Resources/Scripts/UI/CharacterCreation.luau index af4614da..97f9feaf 100644 --- a/Source/Resources/Scripts/UI/CharacterCreation.luau +++ b/Source/Resources/Scripts/UI/CharacterCreation.luau @@ -4,6 +4,9 @@ local UIComboBox = require("@src/API/UI/ComboBox") local UIInputBox = require("@src/API/UI/InputBox") local UIScrollBox : ScrollBoxAPI = require("@src/API/UI/ScrollBox") local UIStack : StackAPI = require("@src/API/UI/UIStack") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") +local UIHorizontalLayout = require("@src/API/UI/HorizontalLayout") +local UIGridLayout = require("@src/API/UI/GridLayout") type ClassInfo = { @@ -269,45 +272,27 @@ local appearanceOptions : AppearanceOptions = { } } -local function CreateFactionSelectPanel(characterCreationScreen : CharacterCreationScreen, factionInfoIndex : number, currentX : number, currentY : number, width : number, panelHeight : number, maxNumRaces : number) +local function CreateFactionSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, factionInfoIndex : number, columnWidth : number, panelHeight : number, maxNumRaces : number) local factionInfo : FactionInfo = factionInfos[factionInfoIndex]; - - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(currentX, currentY, width, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.0, 1.0); - panel:SetRelativePoint(0.0, 1.0); - panel:SetAlpha(0.0); - - -- Create header text at the top of the column for the faction name. - -- Here, 1.0 on the Y-axis is the top of the panel, so we anchor the label there. - local nameLabel : Text = panel:NewText(factionInfo.name, 0, 0, 0, "YellowText"); - nameLabel:SetAnchor(0.5, 1.0); - nameLabel:SetRelativePoint(0.5, 1.0); - -- Measure the text height - local headerHeight = select(2, nameLabel:GetSize()) + 10; + -- Square race icon: constrained by column width and a fraction of available height. + -- Approximates the original gapY-spread; spacing is uniform here rather than auto-distributed. + local s = math.floor(math.min(columnWidth, panelHeight / (maxNumRaces + 1))); + local spacing = math.floor((panelHeight - maxNumRaces * s - 30) / math.max(1, maxNumRaces)); - -- Calculate how much space is left for icons (below the header) - local availableHeight = panelHeight - headerHeight; - - -- Determine the size of each square race panel based on maxNumRaces - local s = math.min(width, availableHeight / (maxNumRaces + 1)); - local gapY = (availableHeight - maxNumRaces * s) / (maxNumRaces); + local column = UIVerticalLayout.NewVerticalLayout(parent, 0, 0, columnWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + alignH = "center", + spacing = spacing, + paddingTop = 0, + }); - -- The top "usable" coordinate for icons (just below the header) - local topOfIcons = panelHeight - headerHeight; + column:NewText(factionInfo.name, 0, 0, 0, "YellowText"); - -- Create each race panel as a child of the parent panel. - -- We'll position them from the top downward, leaving room for the header. for i = 1, #factionInfo.races do local race = factionInfo.races[i]; - local childX = (width - s) / 2; -- Center horizontally - -- We move downward from topOfIcons by i*(s + gapY), - -- so that the first icon is right under the header, and subsequent icons stack down. - local childY = topOfIcons - (i * s + (i - 1) * gapY); - - local raceButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, childX, childY, s, s, 0, { + local raceButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(column, 0, 0, s, s, 0, { texture = factionInfo.raceAtlasTexture, numTexturesX = factionInfo.raceAtlasTextureNumTexturesX, numTexturesY = factionInfo.raceAtlasTextureNumTexturesY, @@ -321,113 +306,102 @@ local function CreateFactionSelectPanel(characterCreationScreen : CharacterCreat characterCreationScreen.raceIndexToFactionIndex[raceIndex] = factionInfoIndex; characterCreationScreen.raceIndexToFactionLocalRaceIndex[raceIndex] = i; end + + return column; end -local function CreateRaceSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number) +local function CreateRaceSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number) characterCreationScreen.buttons.raceButtons = {}; characterCreationScreen.raceIndexToFactionIndex = {}; characterCreationScreen.raceIndexToFactionLocalRaceIndex = {}; - - -- Number of factions - local numFactions = #factionInfos; - -- Max races in a faction + local numFactions = #factionInfos; local maxNumRaces = 0; for i = 1, numFactions do local numRaces = #factionInfos[i].races; - if numRaces > maxNumRaces then - maxNumRaces = numRaces; - end + if numRaces > maxNumRaces then maxNumRaces = numRaces end end - -- Adjustable horizontal padding + local optionsWidth = parent:GetWidth(); local paddingX = 5; + local factionSelectWidth = math.floor((optionsWidth - (numFactions + 1) * paddingX) / numFactions); - -- Calculate each faction column's width after subtracting all padding. - local factionSelectWidth = (optionsWidth - (numFactions + 1) * paddingX) / numFactions; - - -- Start at the left padding. - local currentX = paddingX; + -- Outer H layout splits the row into N equal-width columns with paddingX gutters. + local row = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, optionsWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + spacing = paddingX, paddingLeft = paddingX, paddingRight = paddingX, + defaultChildOpts = { modeY = "flex" }, -- columns fill the row height + }); for i = 1, numFactions do - CreateFactionSelectPanel(characterCreationScreen, i, currentX, currentY, factionSelectWidth, panelHeight, maxNumRaces); - -- Move currentX by adding the width of the column plus an extra paddingX. - currentX = currentX + factionSelectWidth + paddingX; + local column = CreateFactionSelectPanel(characterCreationScreen, row, i, factionSelectWidth, panelHeight, maxNumRaces); + row:SetChildOpts(column, { sizeX = factionSelectWidth, modeY = "flex" }); end + + return row; end -local function CreateGenderSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number) +local function CreateGenderSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number) characterCreationScreen.buttons.genderButtons = {}; - - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(0, currentY, optionsWidth, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.5, 1.0); - panel:SetRelativePoint(0.5, 1.0); - panel:SetAlpha(0.0); local padding = 4; local iconSize = panelHeight - padding; - local iconOffsetX = (iconSize / 2) + (padding / 2); - local iconOffsetY = -padding / 2; - -- Male button - local maleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, -iconOffsetX, iconOffsetY, iconSize, iconSize, 0, { + -- H layout that shrinks horizontally to fit its 2 icons + spacing. The parent V layout + -- centers it horizontally via alignH="center" set by the caller (CreateLeftPanel). + -- H layout fills the full parent width; alignH="center" packs the icon pair into the + -- middle of the row instead of starting at the left edge. + local row = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, parent:GetWidth(), panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + spacing = padding, alignV = "center", alignH = "center", + }); + + local maleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(row, 0, 0, iconSize, iconSize, 0, { texture = "Data/Texture/interface/glues/charactercreate/ui-charactercreate-gender.dds", - numTexturesX = 2, - numTexturesY = 1, + numTexturesX = 2, numTexturesY = 1, }); - maleButton:SetAnchor(0.5, 1.0); - maleButton:SetRelativePoint(0.5, 1.0); maleButton:SetOnMouseUp(function(eventID, widget, ...) characterCreationScreen:SetGender(1); end); table.insert(characterCreationScreen.buttons.genderButtons, maleButton); - -- Female button - local femaleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, iconOffsetX, iconOffsetY, iconSize, iconSize, 0, { + local femaleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(row, 0, 0, iconSize, iconSize, 0, { texture = "Data/Texture/interface/glues/charactercreate/ui-charactercreate-gender.dds", - numTexturesX = 2, - numTexturesY = 1, - atlasIndex = 2, + numTexturesX = 2, numTexturesY = 1, atlasIndex = 2, }); - femaleButton:SetAnchor(0.5, 1.0); - femaleButton:SetRelativePoint(0.5, 1.0); femaleButton:SetOnMouseUp(function(eventID, widget, ...) characterCreationScreen:SetGender(2); end); table.insert(characterCreationScreen.buttons.genderButtons, femaleButton); + + return row; end -local function CreateClassSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number, maxIconSize : number) +local function CreateClassSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number, maxIconSize : number) characterCreationScreen.buttons.classButtons = {}; - - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(0, currentY, optionsWidth, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.5, 1.0); - panel:SetRelativePoint(0.5, 1.0); - panel:SetAlpha(0.0); - - local padding = 5; -- adjustable padding variable + -- Compute per-cell size from class count + maxIconSize so the grid still hits the target + -- icon dimensions; GridLayout positions the cells with cellAlign="center". local numClasses = #classInfos; + local padding = 5; local columns = math.ceil(math.sqrt(numClasses)); local rows = math.ceil(numClasses / columns); + local optionsWidth = parent:GetWidth(); local availableWidthPerIcon = (optionsWidth - (columns - 1) * padding) / columns; local availableHeightPerIcon = (panelHeight - (rows - 1) * padding) / rows; - local iconSize = math.floor(math.min(availableWidthPerIcon, availableHeightPerIcon)); - iconSize = math.min(iconSize, maxIconSize); - - local totalGridWidth = columns * iconSize + (columns - 1) * padding; - local xOffset = (optionsWidth - totalGridWidth) / 2; - local totalGridHeight = rows * iconSize + (rows - 1) * padding; - local yOffset = (panelHeight - totalGridHeight) / 2; + local iconSize = math.min(math.floor(math.min(availableWidthPerIcon, availableHeightPerIcon)), maxIconSize); + + local grid = UIGridLayout.NewGridLayout(parent, 0, 0, optionsWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + columns = columns, + cellSizeX = iconSize, cellSizeY = iconSize, + spacing = padding, + cellAlign = "center", + gridAlignH = "center", gridAlignV = "center", + }); for i, classInfo in classInfos do - local col = (i - 1) % columns; - local row = math.floor((i - 1) / columns); - local x = xOffset + col * (iconSize + padding); - local y = yOffset + (rows - 1 - row) * (iconSize + padding); - local classButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, x, y, iconSize, iconSize, 0, { + local classButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(grid, 0, 0, iconSize, iconSize, 0, { texture = classInfo.iconAtlasTexture, numTexturesX = classInfo.iconAtlasTextureNumTexturesX, numTexturesY = classInfo.iconAtlasTextureNumTexturesY, @@ -439,85 +413,88 @@ local function CreateClassSelectPanel(characterCreationScreen : CharacterCreatio end); table.insert(characterCreationScreen.buttons.classButtons, classButton); end -end -local function CreateAppearanceSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number) - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(0, currentY, optionsWidth, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.5, 1.0); - panel:SetRelativePoint(0.5, 1.0); - panel:SetAlpha(0.0); + return grid; +end +local function CreateAppearanceSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number) + local optionsWidth = parent:GetWidth(); local padding = 5; - local currentY = 0; - local height = (panelHeight / #appearanceOptions) - padding; - local width = 200; - + local rowHeight = math.floor((panelHeight / #appearanceOptions) - padding); + local comboBoxWidth = 200; + + local panel = UIVerticalLayout.NewVerticalLayout(parent, 0, 0, optionsWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + spacing = padding, + }); + + -- World position used to anchor the combobox dropdowns to the right of the options panel. + -- Computed up-front because the combobox.selectionPanel positioning is independent of the row layout. local sidePanelPosX, sidePanelPosY = panel:GetWorldPos(); sidePanelPosX += optionsWidth; sidePanelPosY += panelHeight; - for i=1, #appearanceOptions do - local availableSpaceForName = optionsWidth - width; - - local optionName : Text = panel:NewText(appearanceOptions[i].name, availableSpaceForName/2.0, currentY, 0, "YellowText"); - optionName:SetAnchor(0.0, 1.0); - optionName:SetRelativePoint(0.5, 1.0); + for i = 1, #appearanceOptions do + local row = UIHorizontalLayout.NewHorizontalLayout(panel, 0, 0, optionsWidth, rowHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + alignV = "center", + paddingLeft = 5, paddingRight = padding, + }); + panel:SetChildOpts(row, { sizeY = rowHeight, modeX = "flex" }); - local comboBox = UIComboBox.NewComboBox(panel, -padding, currentY, width, height, 0, { + -- Order matters: create label, spacer (flex, fills remaining), then combobox at the right. + local optionName : Text = row:NewText(appearanceOptions[i].name, 0, 0, 0, "YellowText"); + local spacer = row:NewWidget(0, 0, 0); + local comboBox = UIComboBox.NewComboBox(row, 0, 0, comboBoxWidth, rowHeight, 0, { options = appearanceOptions[i].options, disableButtons = true, }); - comboBox:SetAnchor(1.0, 1.0); - comboBox:SetRelativePoint(1.0, 1.0); + + row:SetChildOpts(spacer, { modeX = "flex", weightX = 1 }); + row:SetChildOpts(comboBox, { sizeX = comboBoxWidth }); comboBox.selectionPanel:SetWorldPos(sidePanelPosX, sidePanelPosY); comboBox.selectionPanel:SetAnchor(0.0, 0.0); comboBox.selectionPanel:SetRelativePoint(0.0, 1.0); - - currentY -= height + padding; end + + return panel; end local function CreateLeftPanel(characterCreationScreen : CharacterCreationScreen) - -- Options panel local optionsWidth = 400; local optionsHeight = 980; - characterCreationScreen.optionsPanel = characterCreationScreen.widget:NewPanel(20, -80, optionsWidth, optionsHeight, 0, "DialogBox"); - characterCreationScreen.optionsPanel:SetAnchor(0.0, 1.0); - characterCreationScreen.optionsPanel:SetRelativePoint(0.0, 1.0); - - local currentY = 0; local maxIconSize = 80; + local spacing = 10; - -- Race select panel - local raceSelectHeight = 400; - CreateRaceSelectPanel(characterCreationScreen, currentY, optionsWidth, raceSelectHeight); - currentY -= raceSelectHeight; - - -- Padding - currentY -= 10; - - -- Gender select panel + local raceSelectHeight = 400; local genderSelectHeight = 80; - CreateGenderSelectPanel(characterCreationScreen, currentY, optionsWidth, genderSelectHeight); - currentY -= genderSelectHeight; + local classSelectHeight = 250; + -- Appearance fills whatever's left after the three above + spacing gutters. + local appearanceHeight = optionsHeight - raceSelectHeight - genderSelectHeight - classSelectHeight - spacing * 3; + + -- optionsPanel is a V layout: subpanels become its children stacked top-down. + characterCreationScreen.optionsPanel = UIVerticalLayout.NewVerticalLayout( + characterCreationScreen.widget, 20, -80, optionsWidth, optionsHeight, 0, + { panelTemplate = "DialogBox", spacing = spacing }); + characterCreationScreen.optionsPanel:SetAnchor(0.0, 1.0); + characterCreationScreen.optionsPanel:SetRelativePoint(0.0, 1.0); + characterCreationScreen.optionsPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + characterCreationScreen.optionsPanel:SetBorderSize(2); + + local optionsPanel = characterCreationScreen.optionsPanel; - -- Padding - currentY -= 10; + local raceSelect = CreateRaceSelectPanel(characterCreationScreen, optionsPanel, raceSelectHeight); + optionsPanel:SetChildOpts(raceSelect, { sizeY = raceSelectHeight, modeX = "flex" }); - -- Class select panel - local classSelectHeight = 250; - CreateClassSelectPanel(characterCreationScreen, currentY, optionsWidth, classSelectHeight, maxIconSize); - currentY -= classSelectHeight; + local genderSelect = CreateGenderSelectPanel(characterCreationScreen, optionsPanel, genderSelectHeight); + optionsPanel:SetChildOpts(genderSelect, { sizeY = genderSelectHeight, alignH = "center" }); - -- Padding - currentY -= 10; + local classSelect = CreateClassSelectPanel(characterCreationScreen, optionsPanel, classSelectHeight, maxIconSize); + optionsPanel:SetChildOpts(classSelect, { sizeY = classSelectHeight, modeX = "flex" }); - -- Appearance select panel - local appearanceSelectHeight = optionsHeight - math.abs(currentY); -- currentY to the end - CreateAppearanceSelectPanel(characterCreationScreen, currentY, optionsWidth, appearanceSelectHeight); - currentY -= appearanceSelectHeight; + local appearance = CreateAppearanceSelectPanel(characterCreationScreen, optionsPanel, appearanceHeight); + optionsPanel:SetChildOpts(appearance, { sizeY = appearanceHeight, modeX = "flex" }); end local function CreateMiddlePanel(characterCreationScreen : CharacterCreationScreen) @@ -532,6 +509,8 @@ local function CreateMiddlePanel(characterCreationScreen : CharacterCreationScre }); characterCreationScreen.nameInputBox.inputBox:SetAnchor(0.5, 0.0); characterCreationScreen.nameInputBox.button:SetRelativePoint(0.5, 0.0); + characterCreationScreen.nameInputBox.button.panel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + characterCreationScreen.nameInputBox.button.panel:SetBorderSize(2); end local function UpdateScrollBoxBasedOnContent(scrollBox : ScrollBox, description : Text) @@ -560,6 +539,8 @@ local function CreateFactionInfoPanel(characterCreationScreen : CharacterCreatio local factionInfoPanel : Panel = characterCreationScreen.characterInfoPanel:NewPanel(0, currentY, characterInfoWidth, factionInfoHeight, 0, "DialogBox"); factionInfoPanel:SetAnchor(1.0, 1.0); factionInfoPanel:SetRelativePoint(1.0, 1.0); + factionInfoPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + factionInfoPanel:SetBorderSize(2); -- Faction icon local factionIconSize = 50; @@ -596,6 +577,8 @@ local function CreateFactionInfoPanel(characterCreationScreen : CharacterCreatio characterCreationScreen.selectedFactionDescription:SetRelativePoint(0.0, 1.0); UpdateScrollBoxBasedOnContent(characterCreationScreen.selectedFactionDescriptionScrollBox, characterCreationScreen.selectedFactionDescription); + + return factionInfoPanel; end local function CreateRaceInfoPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, characterInfoWidth : number, raceInfoHeight : number) @@ -605,6 +588,8 @@ local function CreateRaceInfoPanel(characterCreationScreen : CharacterCreationSc local raceInfoPanel : Panel = characterCreationScreen.characterInfoPanel:NewPanel(0, currentY, characterInfoWidth, raceInfoHeight, 0, "DialogBox"); raceInfoPanel:SetAnchor(1.0, 1.0); raceInfoPanel:SetRelativePoint(1.0, 1.0); + raceInfoPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + raceInfoPanel:SetBorderSize(2); -- Race icon local raceIconSize = 50; @@ -639,6 +624,8 @@ local function CreateRaceInfoPanel(characterCreationScreen : CharacterCreationSc characterCreationScreen.selectedRaceDescription:SetRelativePoint(0.0, 1.0); UpdateScrollBoxBasedOnContent(characterCreationScreen.selectedRaceDescriptionScrollBox, characterCreationScreen.selectedRaceDescription); + + return raceInfoPanel; end local function CreateClassInfoPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, characterInfoWidth : number, classInfoHeight : number) @@ -647,6 +634,8 @@ local function CreateClassInfoPanel(characterCreationScreen : CharacterCreationS local classInfoPanel : Panel = characterCreationScreen.characterInfoPanel:NewPanel(0, currentY, characterInfoWidth, classInfoHeight, 0, "DialogBox"); classInfoPanel:SetAnchor(1.0, 1.0); classInfoPanel:SetRelativePoint(1.0, 1.0); + classInfoPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + classInfoPanel:SetBorderSize(2); -- Class icon local classIconSize = 50; @@ -681,39 +670,38 @@ local function CreateClassInfoPanel(characterCreationScreen : CharacterCreationS characterCreationScreen.selectedClassDescription:SetRelativePoint(0.0, 1.0); UpdateScrollBoxBasedOnContent(characterCreationScreen.selectedClassDescriptionScrollBox, characterCreationScreen.selectedClassDescription); + + return classInfoPanel; end local function CreateRightPanel(characterCreationScreen : CharacterCreationScreen) - -- Character info panel local characterInfoWidth = 430; local characterInfoPanelHeight = 900; - characterCreationScreen.characterInfoPanel = characterCreationScreen.widget:NewPanel(-20, -40, characterInfoWidth, characterInfoPanelHeight, 0, "DialogBox"); - characterCreationScreen.characterInfoPanel:SetAnchor(1.0, 1.0); - characterCreationScreen.characterInfoPanel:SetRelativePoint(1.0, 1.0); - characterCreationScreen.characterInfoPanel:SetAlpha(0.2); + local spacing = 15; - local currentY = 0; - - -- Faction info panel local factionInfoHeight = 250; - CreateFactionInfoPanel(characterCreationScreen, currentY, characterInfoWidth, factionInfoHeight); - currentY -= factionInfoHeight; + local raceInfoHeight = 410; + local classInfoHeight = characterInfoPanelHeight - factionInfoHeight - raceInfoHeight - spacing * 2; - -- Padding - currentY -= 15; + -- characterInfoPanel becomes a V layout. The three info subpanels stack with `spacing` between them. + -- alpha=0.2 preserves the dialog tint that the original SetAlpha(0.2) set on the container. + characterCreationScreen.characterInfoPanel = UIVerticalLayout.NewVerticalLayout( + characterCreationScreen.widget, -20, -40, characterInfoWidth, characterInfoPanelHeight, 0, + { panelTemplate = "DialogBox", alpha = 0.2, spacing = spacing }); + characterCreationScreen.characterInfoPanel:SetAnchor(1.0, 1.0); + characterCreationScreen.characterInfoPanel:SetRelativePoint(1.0, 1.0); - -- Race info panel - local raceInfoHeight = 410; - CreateRaceInfoPanel(characterCreationScreen, currentY, characterInfoWidth, raceInfoHeight); - currentY -= raceInfoHeight; + local right = characterCreationScreen.characterInfoPanel; - -- Padding - currentY -= 15; + -- Sub-info functions still take (currentY, width, height) but currentY is ignored — + -- the V layout owns positioning. Each returns the panel it created so we can SetChildOpts. + local factionPanel = CreateFactionInfoPanel(characterCreationScreen, 0, characterInfoWidth, factionInfoHeight); + local racePanel = CreateRaceInfoPanel(characterCreationScreen, 0, characterInfoWidth, raceInfoHeight); + local classPanel = CreateClassInfoPanel(characterCreationScreen, 0, characterInfoWidth, classInfoHeight); - -- Class info panel - local classInfoHeight = characterInfoPanelHeight - math.abs(currentY); -- currentY to the end - CreateClassInfoPanel(characterCreationScreen, currentY, characterInfoWidth, classInfoHeight); - currentY -= classInfoHeight; + right:SetChildOpts(factionPanel, { sizeY = factionInfoHeight, modeX = "flex" }); + right:SetChildOpts(racePanel, { sizeY = raceInfoHeight, modeX = "flex" }); + right:SetChildOpts(classPanel, { sizeY = classInfoHeight, modeX = "flex" }); -- Accept button local acceptButton = UIButton.NewButton(characterCreationScreen.widget, -20, 80, 200, 40, 0, { @@ -878,6 +866,15 @@ local function CreateCharacterCreationScreen(stack : Stack) -- Methods characterCreationScreen = CreateCharacterCreationScreenMethods(characterCreationScreen); + -- Force the layouts to settle before the first SetText calls below. Text widgets emit + -- their GPU vertices using the parent's world position at SetText time, and a parent + -- transform change does not propagate DirtyWidgetTransform to descendants — so without + -- this, descriptions whose owning sub-panel moves during the first layout pass end up + -- with stale vertices and stay invisible until the user manually re-triggers SetText + -- (e.g. by selecting a different race/class). + characterCreationScreen.optionsPanel:Refresh(); + characterCreationScreen.characterInfoPanel:Refresh(); + -- Default options characterCreationScreen:SetRace(1); characterCreationScreen:SetGender(1); diff --git a/Source/Resources/Scripts/UI/CharacterSelect.luau b/Source/Resources/Scripts/UI/CharacterSelect.luau index 5bcdaa50..0e9d2d49 100644 --- a/Source/Resources/Scripts/UI/CharacterSelect.luau +++ b/Source/Resources/Scripts/UI/CharacterSelect.luau @@ -1,6 +1,7 @@ local UIButton = require("@src/API/UI/Button") local UIScrollBox = require("@src/API/UI/ScrollBox") local UIStack = require("@src/API/UI/UIStack") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") type CharacterSelectScreen = { @@ -12,21 +13,22 @@ type CharacterSelectScreen = canvas : Canvas, widget : Widget, - currentY : number, characters : { Character }, selectedCharacterName : string | nil, selectedCharacterNameText : TextWidget, characterListScrollBox : UIScrollBox.ScrollBox, + characterListLayout : any, -- VerticalLayout instance } -type Character = +type Character = { index : number, name : string, level : number, class : string, location : string, - panel : Panel | nil, + entryLayout : any, -- VerticalLayout per-entry; nil until first build + panel : Panel | nil, -- alias of entryLayout.panel kept for SelectCharacterByName nameText : TextWidget | nil, levelAndClassText : TextWidget | nil, locationText : TextWidget | nil, @@ -137,54 +139,34 @@ local function RegisterTemplates() end local function CreateOrModifyCharacterEntry(characterSelectScreen : CharacterSelectScreen, character : Character) - local localY = -15; - if (character.panel == nil) then - -- Background - character.panel = characterSelectScreen.characterListScrollBox.content:NewPanel(0, characterSelectScreen.currentY, 390, 100, 0, "CharacterEntryButtonPanel"); - character.panel:SetAnchor(0.5, 1.0); - character.panel:SetRelativePoint(0.5, 1.0); - character.panel:SetOnMouseUp(function(eventID, widget, ...) + if (character.entryLayout == nil) then + -- Each entry is a VerticalLayout: panel template provides the background, paddings + -- frame the texts (top=15, bottom=25, left=25), heightMode="shrink" sizes to content. + local entry = UIVerticalLayout.NewVerticalLayout( + characterSelectScreen.characterListLayout, 0, 0, 390, 0, 0, + { + panelTemplate = "CharacterEntryButtonPanel", + heightMode = "shrink", + paddingTop = 15, paddingBottom = 25, paddingLeft = 25, + spacing = 0, + }); + entry:SetOnMouseUp(function(eventID, widget, ...) characterSelectScreen:SelectCharacterByName(character.name); end); - -- Name - character.nameText = character.panel:NewText(character.name, 25, localY, 0, "NameText"); - character.nameText:SetAnchor(0.0, 1.0); - character.nameText:SetRelativePoint(0.0, 1.0); - localY -= select(2, character.nameText:GetSize()); - - -- Level and class - character.levelAndClassText = character.panel:NewText(`Level {character.level} {character.class}`, 25, localY, 0, "LevelText"); - character.levelAndClassText:SetAnchor(0.0, 1.0); - character.levelAndClassText:SetRelativePoint(0.0, 1.0); - localY -= select(2, character.levelAndClassText:GetSize()); - - -- Location - character.locationText = character.panel:NewText(character.location, 25, localY, 0, "LocationText"); - character.locationText:SetAnchor(0.0, 1.0); - character.locationText:SetRelativePoint(0.0, 1.0); - localY -= select(2, character.locationText:GetSize()); - - local padding = 25; - localY -= padding; - character.panel:SetHeight(math.abs(localY)); - else - character.panel:SetPos(0, characterSelectScreen.currentY); + character.nameText = entry:NewText(character.name, 0, 0, 0, "NameText"); + character.levelAndClassText = entry:NewText(`Level {character.level} {character.class}`, 0, 0, 0, "LevelText"); + character.locationText = entry:NewText(character.location, 0, 0, 0, "LocationText"); + character.entryLayout = entry; + character.panel = entry.panel; -- alias kept so SelectCharacterByName's :SetForeground still works + else + -- Update path: just swap text on the existing proxies. SetText is size-affecting, + -- so the proxy invalidates the entry layout (and the outer list layout) automatically. character.nameText:SetText(character.name); - character.nameText:SetPos(25, localY); - localY -= select(2, character.nameText:GetSize()); - character.levelAndClassText:SetText(`Level {character.level} {character.class}`); - character.levelAndClassText:SetPos(25, localY); - localY -= select(2, character.levelAndClassText:GetSize()); - character.locationText:SetText(character.location); - character.locationText:SetPos(25, localY); - localY -= select(2, character.locationText:GetSize()); end - - characterSelectScreen.currentY += localY; end local function CreateLeftPanel(characterSelectScreen : CharacterSelectScreen) @@ -250,6 +232,8 @@ local function CreateRightPanel(characterSelectScreen : CharacterSelectScreen) local characterSelectPanel = characterSelectScreen.widget:NewPanel(-20, -40, 400, 850, 0, "DialogBox"); characterSelectPanel:SetAnchor(1.0, 1.0); characterSelectPanel:SetRelativePoint(1.0, 1.0); + characterSelectPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + characterSelectPanel:SetBorderSize(2); local currentY = 0; @@ -276,6 +260,16 @@ local function CreateRightPanel(characterSelectScreen : CharacterSelectScreen) characterSelectScreen.characterListScrollBox:SetAnchor(0.0, 1.0); characterSelectScreen.characterListScrollBox:SetRelativePoint(0.0, 1.0); + -- Outer list layout: stacks character entries top-down inside the scrollbox content. + -- heightMode="shrink" makes its measured height match the sum of entry heights, which we + -- feed back to the scrollbox in RefreshCharacterList. + characterSelectScreen.characterListLayout = UIVerticalLayout.NewVerticalLayout( + characterSelectScreen.characterListScrollBox.content, 0, 0, + characterSelectScreen.characterListScrollBox.width, 0, 0, + { heightMode = "shrink", spacing = 0 }); + characterSelectScreen.characterListLayout:SetAnchor(0.0, 1.0); + characterSelectScreen.characterListLayout:SetRelativePoint(0.0, 1.0); + -- Create New Character button local loginButton = UIButton.NewButton(characterSelectPanel, -40, 20, 250, 40, 0, { panelTemplate = "DefaultButtonPanel", @@ -337,22 +331,19 @@ local function CreateCharacterSelectScreen(stack : UIStack.Stack) characterSelectScreen.widget:SetRelativePoint(0.5, 0.5); -- Variables - characterSelectScreen.currentY = 0; characterSelectScreen.characters = setmetatable({}, {__index = table}); -- Methods characterSelectScreen.RefreshCharacterList = function(self : CharacterSelectScreen) - self.currentY = 0; - for i, character in self.characters do CreateOrModifyCharacterEntry(self, character); end + local contentHeight = self.characterListLayout:GetMeasuredHeight(); local scrollBoxHeight = self.characterListScrollBox.height; - local visibleScrollBar = math.abs(self.currentY) > scrollBoxHeight; - self.characterListScrollBox:SetVerticalScrollBarVisible(visibleScrollBar); - self.characterListScrollBox:SetContentHeight(math.abs(self.currentY)); + self.characterListScrollBox:SetVerticalScrollBarVisible(contentHeight > scrollBoxHeight); + self.characterListScrollBox:SetContentHeight(contentHeight); end characterSelectScreen.AddCharacter = function(self : CharacterSelectScreen, name : string, level : number, class : string, location : string) diff --git a/Source/Resources/Scripts/UI/Chat.luau b/Source/Resources/Scripts/UI/Chat.luau index 609ad413..b522e902 100644 --- a/Source/Resources/Scripts/UI/Chat.luau +++ b/Source/Resources/Scripts/UI/Chat.luau @@ -1,6 +1,7 @@ local UIButton = require("@src/API/UI/Button"); local UIInputBox = require("@src/API/UI/InputBox"); --local UIScrollBox : ScrollBoxAPI = require("@src/API/UI/ScrollBox"); +local UIVerticalLayout = require("@src/API/UI/VerticalLayout"); local ChatListener = require("@src/API/Game/ChatListener"); local OptionsContext = require("@src/API/OptionsContext"); @@ -79,10 +80,7 @@ local function FormatMessage(message : ChatMessage, showTimestamp : boolean) : s end local function RefreshText(chat : Chat) - local yPadding = 5; - local currentY = yPadding; -- Add a bit of padding to the bottom of the "scroll box" - - local startIndex = #chat.chatHistoryQueue - chat.chatScrollIndex + 1; -- Calculate the starting index based on the scroll index + local startIndex = #chat.chatHistoryQueue - chat.chatScrollIndex + 1; local optionCategory = OptionsContext:GetCategory(OptionCategoryType.Gameplay); local optionGroup = optionCategory:Get("Chat"); @@ -91,41 +89,36 @@ local function RefreshText(chat : Chat) local showTimestamp = (optionSection.options:Has("Show Timestamps") and optionSection:GetOption("Show Timestamps"):Get()) or false; local textSize = (optionSection.options:Has("Text Size") and optionSection:GetOption("Text Size"):Get()) or 18.0; - -- Loop through the chat history queue backwards beginning at the startIndex + -- Iterate newest-to-oldest. With the layout's reverse=true the first-added child sits + -- at the bottom, so the newest message ends up at the bottom (matches original behavior). + -- Each text mutation invalidates the layout via the proxy; GetMeasuredHeight triggers + -- a Refresh and tells us when we've filled the visible area so we can stop. local widgetIndex = 1; for i = startIndex, 1, -1 do - if (currentY <= chat.scrollBoxHeight) then - local message = chat.chatHistoryQueue[i]; - - -- Create a new text widget if we need to display more messages than we have text widgets - if (chat.chatHistoryTexts[widgetIndex] == nil) then - chat.chatHistoryTexts[widgetIndex] = chat.clipPanel:NewText("", 0, currentY, 0, "ChatText"); - chat.chatHistoryTexts[widgetIndex]:SetAnchor(0.0, 0.0); - chat.chatHistoryTexts[widgetIndex]:SetRelativePoint(0.0, 0.0); - chat.chatHistoryTexts[widgetIndex]:SetWrapWidth(chat.scrollBoxWidth); -- Set the wrap width to fit the scroll box - chat.chatHistoryTexts[widgetIndex]:SetWrapIndent(4); - end - chat.chatHistoryTexts[widgetIndex]:SetPosY(currentY); -- Set the Y position of the text widget + local message = chat.chatHistoryQueue[i]; - -- Update the text widget with the message content - chat.chatHistoryTexts[widgetIndex]:SetFontSize(textSize); -- Set the font size from options - chat.chatHistoryTexts[widgetIndex]:SetText(FormatMessage(message, showTimestamp)); + if (chat.chatHistoryTexts[widgetIndex] == nil) then + local text = chat.layout:NewText("", 0, 0, 0, "ChatText"); + text:SetWrapWidth(chat.scrollBoxWidth); + text:SetWrapIndent(4); + chat.chatHistoryTexts[widgetIndex] = text; + end - -- Update the text color based on the message - local textColor = GetTextColor(message); - chat.chatHistoryTexts[widgetIndex]:SetColor(textColor); - chat.chatHistoryTexts[widgetIndex]:SetEnabled(true); -- Enable the text widget + local text = chat.chatHistoryTexts[widgetIndex]; + text:SetEnabled(true); + text:SetFontSize(textSize); + text:SetText(FormatMessage(message, showTimestamp)); + text:SetColor(GetTextColor(message)); - currentY += chat.chatHistoryTexts[widgetIndex]:GetHeight(); -- Move down for the next message - widgetIndex += 1; - else + widgetIndex += 1; + + if chat.layout:GetMeasuredHeight() > chat.scrollBoxHeight then break; end end for j = widgetIndex, #chat.chatHistoryTexts do - -- Disable any remaining text widget that are not used - chat.chatHistoryTexts[j]:SetEnabled(false); -- Disable the text widget + chat.chatHistoryTexts[j]:SetEnabled(false); end end @@ -198,6 +191,15 @@ local function CreateChat(chatCanvas : Canvas) chat.clipPanel:SetClipChildren(true); -- Enable clipping for the panel chat.clipPanel:SetAlpha(0.3); + -- VerticalLayout(reverse=true): children stack bottom-up so newest message is at the bottom. + -- heightMode="shrink" lets the layout grow upward as messages arrive; the clipPanel's clip + -- mask hides anything that grows past its top edge. + chat.layout = UIVerticalLayout.NewVerticalLayout(chat.clipPanel, 0, 0, chat.scrollBoxWidth, chat.scrollBoxHeight, 0, { + reverse = true, heightMode = "shrink", + }); + chat.layout:SetAnchor(0.0, 0.0); + chat.layout:SetRelativePoint(0.0, 0.0); + -- Add scroll handlers chat.clipPanel:SetOnMouseScroll(function(eventID : number, widget : Widget, deltaX : number, deltaY : number) -- Handle scrolling with mouse wheel diff --git a/Source/Resources/Scripts/UI/Demo.luau b/Source/Resources/Scripts/UI/Demo.luau index 8b625061..2a0ec7ef 100644 --- a/Source/Resources/Scripts/UI/Demo.luau +++ b/Source/Resources/Scripts/UI/Demo.luau @@ -1,6 +1,9 @@ local UIStack = require("@src/API/UI/UIStack") local UIButton = require("@src/API/UI/Button") local UILoadingScreen = require("@src/API/UI/LoadingScreen") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") +local UIHorizontalLayout = require("@src/API/UI/HorizontalLayout") +local UIGridLayout = require("@src/API/UI/GridLayout") -- TODO: Borders :( @@ -110,6 +113,28 @@ local function Demo() CreateMultiLineDemo(rtCanvas) end +-- 3x3 demo cell grid shared by SortingDemo and LayoutsDemo. Cells read +-- left-to-right, top-to-bottom; row 0 is the top row. +local DEMO_CANVAS_H = 1080 +local DEMO_TOP_MARGIN = 28 +local DEMO_CELL_W, DEMO_CELL_H = 618, 330 +local DEMO_PAD_X, DEMO_PAD_Y = 16, 16 + +local function MakeDemoCell(canvas, col, row, label, bgTemplate) + local x = DEMO_PAD_X + col * (DEMO_CELL_W + DEMO_PAD_X) + local y = (DEMO_CANVAS_H - DEMO_TOP_MARGIN) - (DEMO_CELL_H + DEMO_PAD_Y) * (row + 1) + + local cell = canvas:NewPanel(x, y, DEMO_CELL_W, DEMO_CELL_H, 0, bgTemplate) + cell:SetAnchor(0.0, 0.0); cell:SetRelativePoint(0.0, 0.0) + cell:SetBorderColor(vector.create(0.75, 0.75, 0.75)) + cell:SetBorderSize(2) + + local titleWidget = cell:NewText(label, 10, -8, 0, "DefaultButtonText") + titleWidget:SetAnchor(0.0, 1.0); titleWidget:SetRelativePoint(0.0, 1.0) + + return cell +end + -- ===================================================================== -- SortingDemo -- @@ -180,27 +205,7 @@ local function CreateSortingDemo() -- 3 columns x 3 rows. row 0 = TOP, row 2 = bottom. col 0 = left, col 2 = right. -- Numbering reads left-to-right, top-to-bottom (row*3 + col + 1). - local canvasH = 1080 - local topMargin = 28 -- room for title bar above the cells - local cellW, cellH = 618, 330 - local padX, padY = 16, 16 - - local function makeCell(col, row, label) - local x = padX + col * (cellW + padX) - local y = (canvasH - topMargin) - (cellH + padY) * (row + 1) - - -- Cell background frame - local cell = canvas:NewPanel(x, y, cellW, cellH, 0, "SortDemoCellBG") - cell:SetAnchor(0.0, 0.0) - cell:SetRelativePoint(0.0, 0.0) - - -- Cell title (top-left corner of cell) - local titleWidget = cell:NewText(label, 10, -8, 0, "DefaultButtonText") - titleWidget:SetAnchor(0.0, 1.0) - titleWidget:SetRelativePoint(0.0, 1.0) - - return cell - end + local function makeCell(col, row, label) return MakeDemoCell(canvas, col, row, label, "SortDemoCellBG") end -- ========================================================= -- Test 1 [top-left]: Sibling panels - 4-step staircase @@ -512,6 +517,407 @@ the two canvases interleaves at the leaves.]] end +-- ===================================================================== +-- LayoutsDemo +-- +-- Visual reference / regression case for Vertical/Horizontal/GridLayout. +-- Each cell is a focused feature combination; the hint text on the right +-- describes what a correct render should look like. If you change any +-- LinearLayout / GridLayout code, run this and eyeball every cell. +-- +-- 3x3 grid (618x330 cells), reading left-to-right, top-to-bottom. +-- ===================================================================== +local function CreateLayoutsDemo() + UI.RegisterPanelTemplate("LayoutDemoCellBG", { cornerRadius = 0.0, color = vector.create(0.10, 0.10, 0.13) }) + UI.RegisterPanelTemplate("LayoutDemoFrame", { cornerRadius = 0.0, color = vector.create(0.20, 0.20, 0.26) }) + UI.RegisterPanelTemplate("LayoutDemoChild", { cornerRadius = 0.0, color = vector.create(0.30, 0.55, 0.85) }) + UI.RegisterPanelTemplate("LayoutDemoChildAlt", { cornerRadius = 0.0, color = vector.create(0.85, 0.55, 0.30) }) + + local canvas : Canvas = UI.GetCanvas("LayoutsDemo", 0, 0, 1920, 1080) + + local title = canvas:NewText("LayoutsDemo - V/H/Grid layout features", 0, -6, 0, "DefaultButtonText") + title:SetAnchor(0.5, 1.0); title:SetRelativePoint(0.5, 1.0) + + local function makeCell(col, row, label) return MakeDemoCell(canvas, col, row, label, "LayoutDemoCellBG") end + + -- Add a child panel to a layout with an optional centered label. + local function addChild(layout, w, h, label, template) + template = template or "LayoutDemoChild" + local p = layout:NewPanel(0, 0, w, h, 0, template) + if label then + local t = p:NewText(label, 0, 0, 0, "DefaultButtonText") + t:SetAnchor(0.5, 0.5); t:SetRelativePoint(0.5, 0.5) + end + return p + end + + -- Hint text in the right half of the cell, top-down. + local function makeHint(cell, text) + local hint = cell:NewText(text, 310, -30, 0, "DefaultDebugText") + hint:SetAnchor(0.0, 1.0); hint:SetRelativePoint(0.0, 1.0) + end + + -- ========================================================= + -- Test 1 [top-left]: V layout - spacing + padding + alignH=center + -- 3 children of differing widths stacked top-down. alignH=center + -- centers each child horizontally regardless of own width. + -- padding leaves a uniform 12px frame inside. + -- ========================================================= + do + local cell = makeCell(0, 0, "1: V spacing + padding + alignH=center") + + local v = UIVerticalLayout.NewVerticalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 8, padding = 12, alignH = "center", + }) + v:SetAnchor(0.0, 0.0); v:SetRelativePoint(0.0, 0.0) + addChild(v, 80, 40, "A") + addChild(v, 200, 40, "B (wide)") + addChild(v, 120, 40, "C") + + makeHint(cell, [[ +V layout (medium-grey frame): + spacing = 8 + padding = 12 + alignH = center + +Children top-down: + A (80w) X-centered + B (wide) (200w) X-centered + C (120w) X-centered + +Frame leaves a uniform 12px border; +gaps between children are 8px.]]) + end + + -- ========================================================= + -- Test 2 [top-mid]: H layout - mixed flex weight + intrinsic + -- Two intrinsic-sized children (60w each) bracket two flex + -- children (weight 1, 2). After fixed sums + spacing, the + -- remaining width is split 1:2 between the flex ones. + -- defaultChildOpts.modeY=flex stretches children to full row height. + -- ========================================================= + do + local cell = makeCell(1, 0, "2: H flex weight + fixed (modeY=flex)") + + local h = UIHorizontalLayout.NewHorizontalLayout(cell, 10, 30, 290, 60, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 6, padding = 6, + defaultChildOpts = { modeY = "flex" }, + }) + h:SetAnchor(0.0, 0.0); h:SetRelativePoint(0.0, 0.0) + + addChild(h, 60, 0, "60") + local w1 = addChild(h, 0, 0, "w=1", "LayoutDemoChildAlt") + local w2 = addChild(h, 0, 0, "w=2", "LayoutDemoChildAlt") + addChild(h, 60, 0, "60") + h:SetChildOpts(w1, { modeX = "flex", weightX = 1 }) + h:SetChildOpts(w2, { modeX = "flex", weightX = 2 }) + + makeHint(cell, [[ +H layout, 4 children: + [ 60 | w=1 | w=2 | 60 ] + spacing = 6, padding = 6 + defaultChildOpts: modeY=flex + (children stretch row height) + +Inner = 290 - 12 - 3*6 = 260 +Fixed total = 120, remainder = 140 + w=1 -> ~46 w=2 -> ~93 + +Orange = flex children +Blue = fixed children]]) + end + + -- ========================================================= + -- Test 3 [top-right]: V alignV (main-axis position) start/center/end + -- Three V samples side by side inside an outer H. Each sample + -- has identical content but a different alignV, so the same + -- content block sits at top / middle / bottom of its column. + -- Also exercises nesting. + -- ========================================================= + do + local cell = makeCell(2, 0, "3: V alignV (start/center/end)") + + local h = UIHorizontalLayout.NewHorizontalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoCellBG", alpha = 0.0, + spacing = 8, + }) + h:SetAnchor(0.0, 0.0); h:SetRelativePoint(0.0, 0.0) + + local sampleW = 91 -- (290 - 2*8) / 3 + local function makeSample(alignV) + local v = UIVerticalLayout.NewVerticalLayout(h, 0, 0, sampleW, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 6, padding = 6, + alignH = "center", alignV = alignV, + }) + v:NewText(alignV, 0, 0, 0, "DefaultDebugText") + addChild(v, 50, 30) + addChild(v, 50, 30) + end + + makeSample("start") + makeSample("center") + makeSample("end") + + makeHint(cell, [[ +3 vertical samples (91w each, fixed) +in an outer H. Outer H spacing=8. + +Same content per column: + text label + 2 small panels. + +alignV positions the block on the +main (vertical) axis: + start - block flush to top + center - block vertically centered + end - block flush to bottom + +Outer H exercises layout nesting.]]) + end + + -- ========================================================= + -- Test 4 [mid-left]: reverse on H and on V + -- Left half: H with reverse=true. Insertion order 1..4 lays + -- out RIGHT to LEFT, so "1" sits at the right edge. + -- Right half: V with reverse=true. Insertion order 1..4 + -- lays out BOTTOM to TOP, so "1" sits at the bottom. + -- ========================================================= + do + local cell = makeCell(0, 1, "4: reverse (RTL + bottom-up)") + + local outer = UIHorizontalLayout.NewHorizontalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoCellBG", alpha = 0.0, + spacing = 10, + }) + outer:SetAnchor(0.0, 0.0); outer:SetRelativePoint(0.0, 0.0) + + -- Inner layouts get explicit sizes (see Test 3 for why). + local halfW = 140 -- (290 - 10) / 2 + local hRev = UIHorizontalLayout.NewHorizontalLayout(outer, 0, 0, halfW, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 4, padding = 6, reverse = true, alignV = "center", + }) + for i = 1, 4 do addChild(hRev, 26, 26, tostring(i)) end + + local vRev = UIVerticalLayout.NewVerticalLayout(outer, 0, 0, halfW, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 4, padding = 6, reverse = true, alignH = "center", + }) + for i = 1, 4 do addChild(vRev, 50, 26, tostring(i)) end + + makeHint(cell, [[ +Left half: H reverse=true + insertion order 1,2,3,4 lays out + RIGHT to LEFT -> "1" at right. + +Right half: V reverse=true + insertion order 1,2,3,4 lays out + BOTTOM to TOP -> "1" at bottom. + +Outer H holds two equal-width +columns (140w each, spacing=10).]]) + end + + -- ========================================================= + -- Test 5 [mid-mid]: per-child cross-axis position overrides + -- Layout default is alignH=start, but each child overrides + -- with its own alignH (start / center / end). The last child + -- sets modeX=flex to stretch its width to the container. + -- ========================================================= + do + local cell = makeCell(1, 1, "5: per-child alignH overrides + modeX=flex") + + local v = UIVerticalLayout.NewVerticalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 8, padding = 8, alignH = "start", + }) + v:SetAnchor(0.0, 0.0); v:SetRelativePoint(0.0, 0.0) + + local cs = addChild(v, 80, 30, "start"); v:SetChildOpts(cs, { alignH = "start" }) + local cc = addChild(v, 80, 30, "center"); v:SetChildOpts(cc, { alignH = "center" }) + local ce = addChild(v, 80, 30, "end"); v:SetChildOpts(ce, { alignH = "end" }) + local css = addChild(v, 80, 30, "stretch", "LayoutDemoChildAlt"); v:SetChildOpts(css, { modeX = "flex" }) + + makeHint(cell, [[ +V alignH=start (default); each child +overrides cross-axis position: + + alignH=start -> flush left + alignH=center -> X-centered + alignH=end -> flush right + modeX=flex -> width = inner + (orange to highlight) + +Stretched child's intrinsic 80w is +overridden by the layout to 274w.]]) + end + + -- ========================================================= + -- Test 6 [mid-right]: Grid columns + per-cell cellAlignH + -- 4-column grid, 8 children. Top row uses cellAlignH=start + -- (children flush to left of each cell). Bottom row uses + -- cellAlignH=stretch (children resized to cell width). + -- ========================================================= + do + local cell = makeCell(2, 1, "6: Grid columns + per-cell cellAlignH") + + local grid = UIGridLayout.NewGridLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + columns = 4, spacing = 6, padding = 8, + cellAlign = "center", + }) + grid:SetAnchor(0.0, 0.0); grid:SetRelativePoint(0.0, 0.0) + + for i = 1, 4 do + local p = addChild(grid, 30, 30, tostring(i)) + grid:SetChildOpts(p, { cellAlignH = "start" }) + end + for i = 5, 8 do + local p = addChild(grid, 30, 30, tostring(i), "LayoutDemoChildAlt") + grid:SetChildOpts(p, { cellAlignH = "stretch" }) + end + + makeHint(cell, [[ +Grid: columns=4, 8 children. +Layout default cellAlign=center. + +Row 1 (1..4): cellAlignH=start + 30w panel pushed to LEFT of cell. + +Row 2 (5..8): cellAlignH=stretch + panel resized to cell width + (orange to highlight). + +Y axis still uses default center +on both rows.]]) + end + + -- ========================================================= + -- Test 7 [bot-left]: Grid columnMajor + asymmetric spacing + -- rows=3 + direction=columnMajor means fill goes down then + -- right: column 0 is 1,2,3 top-bottom; column 1 is 4,5,6. + -- spacingX=24 vs spacingY=4 makes the column gap visibly + -- larger than the row gap. + -- ========================================================= + do + local cell = makeCell(0, 2, "7: Grid columnMajor + asym spacing") + + local grid = UIGridLayout.NewGridLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + rows = 3, direction = "columnMajor", + spacingX = 24, spacingY = 4, + padding = 8, + cellAlign = "center", + }) + grid:SetAnchor(0.0, 0.0); grid:SetRelativePoint(0.0, 0.0) + + for i = 1, 6 do addChild(grid, 60, 60, tostring(i)) end + + makeHint(cell, [[ +Grid: rows=3, direction=columnMajor. +6 children fill DOWN then RIGHT: + col0 (top-bottom) = 1, 2, 3 + col1 (top-bottom) = 4, 5, 6 + +spacingX=24, spacingY=4 + -> the gap BETWEEN COLUMNS is + much wider than the gap + between rows (visually clear). + +Children are 60x60 in cells of +~125x82 (cellAlign=center).]]) + end + + -- ========================================================= + -- Test 8 [bot-mid]: Grid cellSize + gridAlignH/V + cellAlign=stretch + -- Explicit cellSize=50x50, 6 children in 3 columns. The + -- 162x106 group is much smaller than the inner area, so + -- gridAlignH / gridAlignV center the whole group. + -- cellAlign=stretch forces children to fill each cell. + -- ========================================================= + do + local cell = makeCell(1, 2, "8: Grid cellSize + gridAlignH/V=center") + + local grid = UIGridLayout.NewGridLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + columns = 3, cellSizeX = 50, cellSizeY = 50, + spacing = 6, padding = 12, + gridAlignH = "center", gridAlignV = "center", + cellAlign = "stretch", + }) + grid:SetAnchor(0.0, 0.0); grid:SetRelativePoint(0.0, 0.0) + + for i = 1, 6 do addChild(grid, 0, 0, tostring(i)) end + + makeHint(cell, [[ +Grid: explicit cellSizeX/Y=50. +columns=3, 6 children, padding=12. + +Group size = 3*50 + 2*6 = 162 wide + 2*50 + 1*6 = 106 tall +Inner area = 266 x 236 (lots extra). + +gridAlignH=center + -> group X-centered in container. +gridAlignV=center + -> group Y-centered in container. + +cellAlign=stretch fills each cell.]]) + end + + -- ========================================================= + -- Test 9 [bot-right]: NewWidget spacer (flex) + skip + -- Header / subheader at top, footer at bottom, separated + -- by an invisible NewWidget spacer with modeY=flex weightY=1 + -- that consumes all remaining vertical space. A skipped child + -- between spacer and footer is removed from layout entirely. + -- defaultChildOpts.modeX=flex stretches each child's width. + -- ========================================================= + do + local cell = makeCell(2, 2, "9: NewWidget flex spacer + skip") + + local v = UIVerticalLayout.NewVerticalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 6, padding = 8, + defaultChildOpts = { modeX = "flex" }, + }) + v:SetAnchor(0.0, 0.0); v:SetRelativePoint(0.0, 0.0) + + addChild(v, 0, 30, "header") + addChild(v, 0, 30, "subheader") + + local spacer = v:NewWidget(0, 0, 0) + v:SetChildOpts(spacer, { modeY = "flex", weightY = 1 }) + + local skipped = addChild(v, 0, 30, "SKIPPED", "LayoutDemoChildAlt") + v:SetChildOpts(skipped, { skip = true }) + + addChild(v, 0, 30, "footer") + + makeHint(cell, [[ +V padding=8, spacing=6. +defaultChildOpts: modeX=flex + (each child fills width) + +Children in insertion order: + header panel (visible) + subheader panel (visible) + spacer NewWidget flex weight=1 + (no size, fills slot) + SKIPPED panel skip=true + (orange, INVISIBLE, + removed from layout) + footer panel (visible) + +Result: header and subheader at top, +footer pushed to bottom by spacer's +expanded slot. Skipped contributes +zero space.]]) + end +end + local function CreateClippingDemo() local canvas = UI.GetCanvas("ClippingDemo", 0, 0, 1920, 1080); @@ -543,6 +949,7 @@ local function OnGameLoaded(eventID : number, data : any) --CreateClippingDemo(); --Demo(); --CreateSortingDemo(); + --CreateLayoutsDemo(); --CreateGameMenu(stack); --CreateOptionsMenu(stack); diff --git a/Source/Resources/Scripts/UI/GameMenu.luau b/Source/Resources/Scripts/UI/GameMenu.luau index ca5d92cc..ac9b1507 100644 --- a/Source/Resources/Scripts/UI/GameMenu.luau +++ b/Source/Resources/Scripts/UI/GameMenu.luau @@ -5,159 +5,131 @@ local UISlider = require("@src/API/UI/Slider") local UICheckbox = require("@src/API/UI/Checkbox") local UIComboBox = require("@src/API/UI/ComboBox") local UIInputBox = require("@src/API/UI/InputBox") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") +local UIHorizontalLayout = require("@src/API/UI/HorizontalLayout") local OptionsContext = require("@src/API/OptionsContext") -local function CreateOption(optionsMenu, currentY, option) +local function CreateOption(parent : any, option) local optionType = option.type; local optionName = option.name; local optionValue = option.value; - local optionDefaultValue = option.defaultValue; - local currentX = 0; + -- Each option is a horizontal row: label first, then control(s). Row height = 40 to fit + -- the tallest control (input box / combo box). alignV="center" vertically centers everything. + local rowHeight = 40; + local row = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, parent:GetWidth(), rowHeight, 0, { + alignV = "center", spacing = 5, + }); + parent:SetChildOpts(row, { sizeY = rowHeight, modeX = "flex" }); - local optionText = optionsMenu.mainPanelContent.content:NewText(optionName, currentX, currentY, 0, "DefaultButtonText"); - optionText:SetRelativePoint(0.0, 1.0); - currentX += select(1, optionText:GetSize()) + 5; + row:NewText(optionName, 0, 0, 0, "DefaultButtonText"); if (optionType == OptionType.InputBox) then - local inputBox = UIInputBox.NewInputBox(optionsMenu.mainPanelContent.content, currentX, currentY, 200, 40, 0, { + local inputBox = UIInputBox.NewInputBox(row, 0, 0, 200, 40, 0, { defaultText = optionValue, }); - inputBox.button:SetRelativePoint(0.0, 1.0); + row:SetChildOpts(inputBox, { sizeX = 200 }); inputBox:SetOnSubmit(function(inputBoxTable) local previousValue = option.value; - option.value = inputBoxTable.text; - local result = option:InvokeOnValueChanged(inputBoxTable.text); if (not result) then - option.value = previousValue; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousValue; + return false; end - - return true; -- Returning false will prevent the value from changing + return true; end) elseif (optionType == OptionType.Checkbox) then - local checkbox = UICheckbox.NewCheckbox(optionsMenu.mainPanelContent.content, currentX, currentY, 24, 24, 0, - { + local checkbox = UICheckbox.NewCheckbox(row, 0, 0, 24, 24, 0, { backgroundTemplate = "DefaultCheckboxBackground", fillTemplate = "DefaultCheckboxFill", isChecked = optionValue, }); - checkbox.background:SetRelativePoint(0.0, 1.0); + row:SetChildOpts(checkbox, { sizeX = 24 }); checkbox:SetOnValueChanged(function(checkboxTable, state) local previousState = option.value; - option.value = state; - local result = option:InvokeOnValueChanged(state); if (not result) then - option.value = previousState; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousState; + return false; end - - return true; -- Returning false will prevent the value from changing + return true; end) elseif (optionType == OptionType.Slider) then - local valueText = optionsMenu.mainPanelContent.content:NewText(optionValue, currentX, currentY, 0, "DefaultButtonText"); - valueText:SetRelativePoint(0.0, 1.0); - currentX += select(1, valueText:GetSize()) + 20; - - local minValueText = optionsMenu.mainPanelContent.content:NewText(option.settings.Min, currentX, currentY, 0, "DefaultButtonText"); - minValueText:SetRelativePoint(0.0, 1.0); - currentX += select(1, minValueText:GetSize()) + 5; - - local slider = UISlider.NewSlider(optionsMenu.mainPanelContent.content, currentX, currentY, 200, 25, 0, { + local valueText = row:NewText(optionValue, 0, 0, 0, "DefaultButtonText"); + local minValueText = row:NewText(option.settings.Min, 0, 0, 0, "DefaultButtonText"); + local slider = UISlider.NewSlider(row, 0, 0, 200, 25, 0, { backgroundTemplate = "DefaultSliderBackground", fillTemplate = "DefaultSliderFill", Min = option.settings.Min, Max = option.settings.Max, Step = option.settings.Step, }); - slider.background:SetRelativePoint(0.0, 1.0); slider:SetValue(optionValue); - - local maxValueText = optionsMenu.mainPanelContent.content:NewText(option.settings.Max, currentX + 200, currentY, 0, "DefaultButtonText"); - maxValueText:SetRelativePoint(0.0, 1.0); - currentX += select(1, maxValueText:GetSize()) + 5; - - --slider:SetProgress(optionValue); - --slider:SetMinMax(option.settings.Min, option.settings.Max, option.settings.Step); + local maxValueText = row:NewText(option.settings.Max, 0, 0, 0, "DefaultButtonText"); + row:SetChildOpts(slider, { sizeX = 200 }); slider:SetOnValueChanged(function(sliderTable, value) local previousValue = option.value; - option.value = value; - local result = option:InvokeOnValueChanged(value); if (not result) then - option.value = previousValue; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousValue; + return false; end - - valueText:SetText(value); -- Update text with new value - return true; -- Returning false will prevent the value from changing + valueText:SetText(value); + return true; end) elseif (optionType == OptionType.ComboBox) then - local comboBox = UIComboBox.NewComboBox(optionsMenu.mainPanelContent.content, currentX, currentY, 200, 40, 0, { + local comboBox = UIComboBox.NewComboBox(row, 0, 0, 200, 40, 0, { options = option.settings.options, disableButtons = true, }); - comboBox.button:SetRelativePoint(0.0, 1.0); + row:SetChildOpts(comboBox, { sizeX = 200 }); comboBox:SelectOption(optionValue); comboBox:SetOnValueChanged(function(comboBoxTable, index) local previousValue = option.value; - option.value = index; - local result = option:InvokeOnValueChanged(index); if (not result) then - option.value = previousValue; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousValue; + return false; end - - return true; -- Returning false will prevent the value from changing + return true; end) end - - currentY -= select(2, optionText:GetSize()) + 5; - return currentY; end local function UpdateMainPanel(optionsMenu, category, group, section) - -- Set title local title = category.name; - if (group) then - title = title .. "/" .. group.name; - end - if (section) then - title = title .. "/" .. section.name; - end + if (group) then title = title .. "/" .. group.name end + if (section) then title = title .. "/" .. section.name end optionsMenu.mainPanelTitle:SetText(title); - -- Clear content + -- Clear previous content (rebuilds the V layout from scratch each navigation). for i, child in optionsMenu.mainPanelContent.content:GetChildrenRecursive() do UI.DestroyWidget(child); end - -- Create new content - local currentY = 0; + local content = optionsMenu.mainPanelContent.content; + local layout = UIVerticalLayout.NewVerticalLayout(content, 0, 0, content:GetWidth(), 0, 0, { + heightMode = "shrink", spacing = 5, paddingTop = 5, + }); + layout:SetAnchor(0.0, 1.0); + layout:SetRelativePoint(0.0, 1.0); for i, option in section.options.list do - currentY = CreateOption(optionsMenu, currentY, option); + CreateOption(layout, option); end - -- Update scrollbox - local currentHeight = math.abs(currentY); - if (currentHeight > optionsMenu.mainPanelContent.content:GetHeight()) then - optionsMenu.mainPanelContent:SetVerticalScrollBarVisible(true); - else - optionsMenu.mainPanelContent:SetVerticalScrollBarVisible(false); - end - optionsMenu.mainPanelContent:SetContentHeight(currentHeight+5); + -- Implicit Refresh via GetMeasuredHeight feeds the scrollbox. + local currentHeight = layout:GetMeasuredHeight(); + optionsMenu.mainPanelContent:SetVerticalScrollBarVisible(currentHeight > optionsMenu.mainPanelContent.content:GetHeight()); + optionsMenu.mainPanelContent:SetContentHeight(currentHeight + 5); end function ToggleEscapeMenu(panelWrapper) @@ -313,50 +285,43 @@ local function CreateSidePanel(parent : Panel, width : number, optionsMenu) optionsMenu.sidePanel:SetRelativePoint(0.0, 1.0); optionsMenu.sidePanel.scrollBox:SetAlpha(1.0); - -- Categories + -- Tree: categories at root indent, groups indented +15, sections indented +15 again. + -- Nested V layouts let each level's paddingLeft compose naturally instead of every line + -- carrying its own absolute X offset. + local treeLayout = UIVerticalLayout.NewVerticalLayout(optionsMenu.sidePanel.content, 0, 0, width, 0, 0, { + heightMode = "shrink", spacing = 5, + }); + treeLayout:SetAnchor(0.0, 1.0); + treeLayout:SetRelativePoint(0.0, 1.0); + local categories = OptionsContext:GetCategories(); - local currentY = 0; for i, category in categories do - local categoryText = optionsMenu.sidePanel.content:NewText(category.name, 0, currentY, 0, "DefaultButtonText"); - categoryText:SetAnchor(0.0, 1.0); - categoryText:SetRelativePoint(0.0, 1.0); - --categoryText:SetOnMouseUp(function(eventID, widget, ...) - -- UpdateMainPanel(optionsMenu, category, nil, nil); - --end) + treeLayout:NewText(category.name, 0, 0, 0, "DefaultButtonText"); - currentY -= select(2, categoryText:GetSize()) + 5; + local groupsContainer = UIVerticalLayout.NewVerticalLayout(treeLayout, 0, 0, width, 0, 0, { + heightMode = "shrink", paddingLeft = 15, spacing = 5, + }); for j, group in category.groups.list do - local groupText = optionsMenu.sidePanel.content:NewText(group.name, 15, currentY, 0, "DefaultButtonText"); - groupText:SetAnchor(0.0, 1.0); - groupText:SetRelativePoint(0.0, 1.0); - --groupText:SetOnMouseUp(function(eventID, widget, ...) - -- UpdateMainPanel(optionsMenu, category, group, nil); - --end) + groupsContainer:NewText(group.name, 0, 0, 0, "DefaultButtonText"); - currentY -= select(2, groupText:GetSize()) + 5; + local sectionsContainer = UIVerticalLayout.NewVerticalLayout(groupsContainer, 0, 0, width, 0, 0, { + heightMode = "shrink", paddingLeft = 15, spacing = 5, + }); for k, section in group.sections.list do - local sectionText = optionsMenu.sidePanel.content:NewText(section.name, 30, currentY, 0, "DefaultButtonText"); - sectionText:SetAnchor(0.0, 1.0); - sectionText:SetRelativePoint(0.0, 1.0); + local sectionText = sectionsContainer:NewText(section.name, 0, 0, 0, "DefaultButtonText"); sectionText:SetOnMouseUp(function(eventID, widget, ...) UpdateMainPanel(optionsMenu, category, group, section); end) - - currentY -= select(2, sectionText:GetSize()) + 5; end end end - local currentHeight = math.abs(currentY); - if (currentHeight > height) then - optionsMenu.sidePanel:SetVerticalScrollBarVisible(true); - else - optionsMenu.sidePanel:SetVerticalScrollBarVisible(false); - end - optionsMenu.sidePanel:SetContentHeight(currentHeight+5); + local currentHeight = treeLayout:GetMeasuredHeight(); + optionsMenu.sidePanel:SetVerticalScrollBarVisible(currentHeight > height); + optionsMenu.sidePanel:SetContentHeight(currentHeight + 5); return optionsMenu; end diff --git a/Source/Shaders/Shaders/UI/Widget.ps.slang b/Source/Shaders/Shaders/UI/Widget.ps.slang index bc648d90..0e1cdc72 100644 --- a/Source/Shaders/Shaders/UI/Widget.ps.slang +++ b/Source/Shaders/Shaders/UI/Widget.ps.slang @@ -7,10 +7,10 @@ struct WidgetDrawData { uint4 packed0; // x: type, y: vertexBase, z: clipMaskTextureIndex, w: worldPositionIndex (int reinterpret) - uint4 packed1; // Panel: x: textureIndex & additiveTextureIndex, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor + uint4 packed1; // Panel: x: textureIndex & additiveTextureIndex, y: borderColor, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor float4 texCoord; // Panel only float4 slicingCoord; // Panel only - float4 cornerRadiusAndBorder; // Panel: xy: cornerRadius. Text: x: borderSize, zw: unitRange + float4 cornerRadiusAndBorder; // Panel: xy: cornerRadius, zw: borderSize (normalized per-axis). Text: x: borderSize, zw: unitRange uint4 packed2; // x: clipRegionMinXY, y: clipRegionMaxXY, z: clipMaskRegionMinXY, w: clipMaskRegionMaxXY }; [[vk::binding(2, PER_PASS)]] StructuredBuffer _widgetDrawDatas; @@ -95,21 +95,49 @@ float4 ShadePanel(WidgetDrawData drawData, VertexOutput input) color.a = max(color.a, additiveIntensity); float2 cornerRadius = drawData.cornerRadiusAndBorder.xy; + float2 normalizedBorderSize = drawData.cornerRadiusAndBorder.zw; // Calculate distance to nearest edge float2 edgeDist = min(uv, 1.0 - uv); - if (cornerRadius.x > 0 && cornerRadius.y > 0) + bool inCornerZone = cornerRadius.x > 0 && cornerRadius.y > 0 + && edgeDist.x < cornerRadius.x && edgeDist.y < cornerRadius.y; + + if (inCornerZone) + { + float2 normalizedDist = 1.0 - (edgeDist / cornerRadius); + float distToCorner = length(normalizedDist); + + if (distToCorner > 1.0) + { + discard; + } + } + + if (normalizedBorderSize.x > 0.0 || normalizedBorderSize.y > 0.0) { - if (edgeDist.x < cornerRadius.x && edgeDist.y < cornerRadius.y) + float4 borderColor = PackedUnormsToFloat4(drawData.packed1.y); + bool inBorder; + + if (inCornerZone) + { + // Corner zone: pixel is in the border ring if it lies outside + // the inner corner ellipse (radii shrunken by borderSize). + float2 innerR = max(cornerRadius - normalizedBorderSize, float2(1e-5, 1e-5)); + float2 toInner = 1.0 - edgeDist / innerR; + bool insideInnerBox = toInner.x > 0.0 && toInner.y > 0.0; + inBorder = !insideInnerBox || length(toInner) > 1.0; + } + else { - float2 normalizedDist = 1.0 - ((edgeDist) / cornerRadius); - float distToCorner = length(normalizedDist); + inBorder = edgeDist.x < normalizedBorderSize.x + || edgeDist.y < normalizedBorderSize.y; + } - if (distToCorner > 1.0) - { - discard; - } + if (inBorder) + { + color.rgb = lerp(color.rgb, borderColor.rgb, borderColor.a); + color.a = max(color.a, borderColor.a); } } diff --git a/Submodules/Engine b/Submodules/Engine index 644e9784..97c1cbe8 160000 --- a/Submodules/Engine +++ b/Submodules/Engine @@ -1 +1 @@ -Subproject commit 644e9784925f632dd9764b4cfd29c98f42aa0760 +Subproject commit 97c1cbe84fa2b056ff663226f9b8fb608625dca4