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