diff --git a/src/Client/Test/window/Pages/page/Components/testing.lua b/src/Client/Test/window/Pages/page/Components/testing.lua index 21d854d..fd759ad 100644 --- a/src/Client/Test/window/Pages/page/Components/testing.lua +++ b/src/Client/Test/window/Pages/page/Components/testing.lua @@ -1,5 +1,8 @@ +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") local replicated = game:GetService("ReplicatedStorage") +local Draggable = require(replicated.Modules.Managers.UIManager.UIObjects.Draggable) local types = require(replicated.Modules.Managers.UIManager.Types) local Component = require(replicated.Modules.Managers.UIManager.Component) @@ -15,11 +18,33 @@ component:OnBuild(function(self: types.Component): () self.Frame.Parent = self.Parent.Frame - task.delay(2, self.Update, self, "Size") + -- local draggablePage = Draggable.new("Test") + -- draggablePage:OnBuild(function(draggable: types.Draggable) + -- local button = Instance.new("TextButton") + -- button.Size = UDim2.fromScale(1, 1) + -- button.Position = UDim2.fromScale(0, 0) + -- button.BackgroundColor3 = Color3.fromHSV(0.805556, 0.611765, 1.000000) + -- button.Parent = self.Frame + -- draggable.DragButton = button + -- end) + -- draggablePage:OnDragStart(function(draggable: types.Draggable) + -- self:Update("DragLoop", { DragState = true }) + -- end) + -- draggablePage:OnDragStop(function(draggable: types.Draggable) + -- print("stopped!!!") + -- end) + + -- draggablePage:Build(self) end) -component:OnUpdate("Size", function(self: types.Component, _: {}?) - self.Frame.Size = UDim2.fromScale(0.1, 0.1) +component:OnUpdate("DragLoop", function(self: types.Component, parameters: { DragState: boolean }?) + if parameters and parameters.DragState then + self.Container:Connect(RunService.Heartbeat, function() + print(UserInputService:GetMouseLocation().X) + end) + else + self.Container:Clean() + end end) return component \ No newline at end of file diff --git a/src/Client/Test/window/Pages/page/init.lua b/src/Client/Test/window/Pages/page/init.lua index f4e679b..1f5d794 100644 --- a/src/Client/Test/window/Pages/page/init.lua +++ b/src/Client/Test/window/Pages/page/init.lua @@ -6,19 +6,21 @@ local Page = require(replicated.Modules.Managers.UIManager.Page) local page = Page.new(script.Name) page:OnBuild(function(self: types.Page): () - self.Frame = Instance.new("Frame") + local frame = Instance.new("Frame") + frame.Visible = false + frame.Size = UDim2.fromScale(0.5, 0.5) + frame.Position = UDim2.fromScale(0.5, 0.5) + frame.Parent = self.Parent.ScreenGui - self.Frame.Visible = false - self.Frame.Size = UDim2.fromScale(0.2, 0.2) - self.Frame.Position = UDim2.fromScale(0.5, 0.5) - - self.Frame.Parent = self.Parent.ScreenGui + self.Frame = frame + self.Container:Add(frame) --Because we creating it each time, and not just locating it from the cloned ScreenGui, we need to make sure it gets cleaned on page close. for _, component: Instance in script.Components:GetChildren() do - if not component:IsA("ModuleScript") then continue end + if not component:IsA("ModuleScript") then + continue + end - self:AddComponent(require(component)) - self:OpenComponent(component.Name) + self:AddComponent(require(component)).AsOpened() end end) diff --git a/src/Client/Test/window/init.lua b/src/Client/Test/window/init.lua index 2f3df66..c986bb8 100644 --- a/src/Client/Test/window/init.lua +++ b/src/Client/Test/window/init.lua @@ -15,10 +15,13 @@ window:OnBuild(function(self: types.Window): () for _, page: Instance in script.Pages:GetChildren() do if not page:IsA("ModuleScript") then continue end - self:AddPage(require(page)) + + if page.Name == DEFAULT_PAGE then + self:AddPage(require(page)).AsOpened() + else + self:AddPage(require(page)) + end end - - self:OpenPage(DEFAULT_PAGE) end) return window \ No newline at end of file diff --git a/src/Client/test.client.lua b/src/Client/test.client.lua index 4d6f1fb..231807d 100644 --- a/src/Client/test.client.lua +++ b/src/Client/test.client.lua @@ -5,12 +5,3 @@ uiManager:Build(script.Parent.Test:GetChildren() :: {}) local default = uiManager:GetWindow("window") uiManager:OpenWindow(default) - -task.wait(10) - -uiManager:CloseWindow(default) - -task.wait(10) - -uiManager:OpenWindow(default) - diff --git a/src/Shared/Modules/Managers/UIManager/Page.lua b/src/Shared/Modules/Managers/UIManager/Page.lua index 85618a5..6bcbbb7 100644 --- a/src/Shared/Modules/Managers/UIManager/Page.lua +++ b/src/Shared/Modules/Managers/UIManager/Page.lua @@ -33,6 +33,8 @@ function page:BuildButtons() end end + + function page:AddButton(button: types.Button) self.Buttons.Stored[button.Name] = button end @@ -81,14 +83,29 @@ end --[[ Components ]] -function page:BuildComponents() +local function addComponentTo(self: types.Page, component: types.Component) + component:Build(self) + component:Open() + self.Components.Open[component.Name] = component +end + +--[[ Component Methods ]] + +function page:BuildComponents(): () for _, component: types.Button in self.Components.Stored do component:Build(self) end end -function page:AddComponent(component: types.Component) - self.Components.Stored[component.Name] = component +function page:AddComponent(component: types.Component): {AsOpened: () -> ()} + local name: string = component.Name + self.Components.Stored[name] = component + + return { + AsOpened = function(): () + addComponentTo(self, component) + end + } end function page:GetComponent(component: string): types.Component @@ -98,9 +115,7 @@ end function page:OpenComponent(component: string) local componentModule= self.Components.Stored[component] if componentModule then - componentModule:Build(self) - componentModule:Open() - self.Components.Open[component] = componentModule + addComponentTo(self, componentModule) end end diff --git a/src/Shared/Modules/Managers/UIManager/Types.lua b/src/Shared/Modules/Managers/UIManager/Types.lua index 75d3130..4b625b2 100644 --- a/src/Shared/Modules/Managers/UIManager/Types.lua +++ b/src/Shared/Modules/Managers/UIManager/Types.lua @@ -1,7 +1,41 @@ local replicated = game:GetService("ReplicatedStorage") +local signal = require(replicated.Modules.Utility.Signal) local trove = require(replicated.Modules.Utility.Trove) +-- + +--[[ UIObjects testing]] + +export type DraggableClass = { + Parent: Page | Component, + Name: string, + DragButton: GuiButton, + DragEvent: signal.Signal, + + _: { + IsDragging: boolean, + DragStartTime: number, + + OnBuild: (self: Draggable) -> ()?, + }, + + OnBuild: (self: Draggable, callback: (self: Draggable) -> ()) -> (), + + Build: (self: Draggable, parent: Page | Component) -> (), + IsDragging: (self: Draggable) -> (boolean), + + _DragStart: (self: Draggable) -> (), + _DragStop: (self: Draggable) -> (), + + _MobileDrag: (self: Draggable, touchPositions: {Vector2}, scale: number, velocity: number, state: Enum.UserInputState) -> (), + + Clean: (self: Draggable) -> (), + Destroy: (self: Draggable) -> (), +} + +export type Draggable = typeof(setmetatable({} :: DraggableClass, {})) + -- export type ButtonClass = { @@ -115,7 +149,9 @@ export type PageClass = { }, BuildComponents: (self: Page) -> (), - AddComponent: (self: Page, component: Component) -> (), + AddComponent: (self: Page, component: Component) -> ( + {AsOpened: () -> ()} + ), GetComponent: (self: Page, component: string) -> (), OpenComponent: (self: Page, component: string) -> (), CloseComponent: (self: Page, component: string) -> (), @@ -156,8 +192,15 @@ export type WindowClass = { Stored: { [string]: Page }, }, + Events: { + PageOpened: signal.Signal, + PageClosed: signal.Signal, + }, + BuildPages: (self: Window) -> (), - AddPage: (self: Window, page: Page) -> (), + AddPage: (self: Window, page: Page) -> ( + {AsOpened: () -> ()} + ), GetPage: (self: Window, page: string) -> (), OpenPage: (self: Window, page: string, command: "Weighted" | "Forced"?) -> (), OpenLastPage: (self: Window) -> (), @@ -200,6 +243,11 @@ export type Window = typeof(setmetatable({} :: WindowClass, {})) -- export type Manager = { + Events: { + WindowOpened: signal.Signal, + WindowClosed: signal.Signal, + }, + --[=[ Useful packages that may be used globally by all windows, pages, and so forth. ]=] @@ -257,6 +305,9 @@ export type Manager = { Clean: (self: Manager) -> (), } + + + -- return {} \ No newline at end of file diff --git a/src/Shared/Modules/Managers/UIManager/UIObjects/Draggable.lua b/src/Shared/Modules/Managers/UIManager/UIObjects/Draggable.lua new file mode 100644 index 0000000..5b55b88 --- /dev/null +++ b/src/Shared/Modules/Managers/UIManager/UIObjects/Draggable.lua @@ -0,0 +1,94 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local uis = game:GetService("UserInputService") +local types = require(ReplicatedStorage.Modules.Managers.UIManager.Types) +local Signal = require(ReplicatedStorage.Modules.Utility.Signal) +local module = {} + +local draggable: types.DraggableClass = {} :: types.DraggableClass +draggable["__index"] = draggable + +function module.new(name: string): types.Draggable + local self = setmetatable({ + Name = name, + DragEvent = Signal.new(), + + _ = { + IsDragging = false, + DragStartTime = 0, + }, + }, draggable) + + return self +end + +--[[ Draggable ]] + +--[[ + Use TouchSwipe for mobile, and MouseButton1Down with MouseButton1Up for PC +]] + +function draggable:OnBuild(callback: (self: types.Draggable) -> ()) + self._.OnBuild = callback +end + +function draggable:Build(parent: types.Page | types.Component) + self.Parent = parent + + if self._.OnBuild then + self._.OnBuild(self) + end + + if self.DragButton then + if uis.MouseEnabled then + self.Parent.Container:ConnectMethod(self.DragButton.MouseButton1Down, "_DragStart", self, {}) + self.Parent.Container:ConnectMethod(self.DragButton.MouseButton1Up, "_DragStop", self, {}) + else + self.Parent.Container:Connect(self.DragButton.TouchPan, function(touchPositions: {Vector2}, scale: number, velocity: number, state: Enum.UserInputState) + self:_MobileDrag(touchPositions, scale, velocity, state) + end) + end + end +end + +function draggable:IsDragging(): boolean + return self._.IsDragging +end + +function draggable:_DragStart() + if self._.IsDragging then + return + end + + self._.IsDragging = true + self._.DragStartTime = os.clock() + + self.DragEvent:Fire(Enum.UserInputType.Touch, true) +end + +function draggable:_DragStop() + self._.IsDragging = false + + self.DragEvent:Fire(Enum.UserInputType.Touch, false) +end + +function draggable:_MobileDrag(touchPositions: {Vector2}, scale: number, velocity: number, state: Enum.UserInputState) + if self._.IsDragging and state == Enum.UserInputState.Begin then + return + end + + self._.IsDragging = state == Enum.UserInputState.Begin + self._.DragStartTime = self._.IsDragging and os.clock() or 0 + + self.DragEvent:Fire(Enum.UserInputType.Touch, state, touchPositions, scale, velocity) +end + +function draggable:Clean() + self.DragEvent:DisconnectAll() +end + +function draggable:Destroy() + self:Clean() + setmetatable(getmetatable(self), nil) +end + +return module \ No newline at end of file diff --git a/src/Shared/Modules/Utility/Signal.lua b/src/Shared/Modules/Utility/Signal.lua new file mode 100644 index 0000000..ed5c6ac --- /dev/null +++ b/src/Shared/Modules/Utility/Signal.lua @@ -0,0 +1,405 @@ +-- ----------------------------------------------------------------------------- +-- Batched Yield-Safe Signal Implementation -- +-- This is a Signal class which has effectively identical behavior to a -- +-- normal RBXScriptSignal, with the only difference being a couple extra -- +-- stack frames at the bottom of the stack trace when an error is thrown. -- +-- This implementation caches runner coroutines, so the ability to yield in -- +-- the signal handlers comes at minimal extra cost over a naive signal -- +-- implementation that either always or never spawns a thread. -- +-- -- +-- API: -- +-- local Signal = require(THIS MODULE) -- +-- local sig = Signal.new() -- +-- local connection = sig:Connect(function(arg1, arg2, ...) ... end) -- +-- sig:Fire(arg1, arg2, ...) -- +-- connection:Disconnect() -- +-- sig:DisconnectAll() -- +-- local arg1, arg2, ... = sig:Wait() -- +-- -- +-- License: -- +-- Licensed under the MIT license. -- +-- -- +-- Authors: -- +-- stravant - July 31st, 2021 - Created the file. -- +-- sleitnick - August 3rd, 2021 - Modified for Knit. -- +-- ----------------------------------------------------------------------------- + +-- Signal types +export type Connection = { + Disconnect: (self: Connection) -> (), + Destroy: (self: Connection) -> (), + Connected: boolean, +} + +export type Signal = { + Fire: (self: Signal, T...) -> (), + FireDeferred: (self: Signal, T...) -> (), + Connect: (self: Signal, fn: (T...) -> ()) -> Connection, + Once: (self: Signal, fn: (T...) -> ()) -> Connection, + DisconnectAll: (self: Signal) -> (), + GetConnections: (self: Signal) -> { Connection }, + Destroy: (self: Signal) -> (), + Wait: (self: Signal) -> T..., +} + +-- The currently idle thread to run the next handler on +local freeRunnerThread = nil + +-- Function which acquires the currently idle handler runner thread, runs the +-- function fn on it, and then releases the thread, returning it to being the +-- currently idle one. +-- If there was a currently idle runner thread already, that's okay, that old +-- one will just get thrown and eventually GCed. +local function acquireRunnerThreadAndCallEventHandler(fn, ...) + local acquiredRunnerThread = freeRunnerThread + freeRunnerThread = nil + fn(...) + -- The handler finished running, this runner thread is free again. + freeRunnerThread = acquiredRunnerThread +end + +-- Coroutine runner that we create coroutines of. The coroutine can be +-- repeatedly resumed with functions to run followed by the argument to run +-- them with. +local function runEventHandlerInFreeThread(...) + acquireRunnerThreadAndCallEventHandler(...) + while true do + acquireRunnerThreadAndCallEventHandler(coroutine.yield()) + end +end + +--[=[ + @within Signal + @interface SignalConnection + .Connected boolean + .Disconnect (SignalConnection) -> () + + Represents a connection to a signal. + ```lua + local connection = signal:Connect(function() end) + print(connection.Connected) --> true + connection:Disconnect() + print(connection.Connected) --> false + ``` +]=] + +-- Connection class +local Connection = {} +Connection.__index = Connection + +function Connection.new(signal, fn) + return setmetatable({ + Connected = true, + _signal = signal, + _fn = fn, + _next = false, + }, Connection) +end + +function Connection:Disconnect() + if not self.Connected then + return + end + self.Connected = false + + -- Unhook the node, but DON'T clear it. That way any fire calls that are + -- currently sitting on this node will be able to iterate forwards off of + -- it, but any subsequent fire calls will not hit it, and it will be GCed + -- when no more fire calls are sitting on it. + if self._signal._handlerListHead == self then + self._signal._handlerListHead = self._next + else + local prev = self._signal._handlerListHead + while prev and prev._next ~= self do + prev = prev._next + end + if prev then + prev._next = self._next + end + end +end + +Connection.Destroy = Connection.Disconnect + +-- Make Connection strict +setmetatable(Connection, { + __index = function(_tb, key) + error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) + end, + __newindex = function(_tb, key, _value) + error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) + end, +}) + +--[=[ + @within Signal + @type ConnectionFn (...any) -> () + + A function connected to a signal. +]=] + +--[=[ + @class Signal + + Signals allow events to be dispatched and handled. + + For example: + ```lua + local signal = Signal.new() + + signal:Connect(function(msg) + print("Got message:", msg) + end) + + signal:Fire("Hello world!") + ``` +]=] +local Signal = {} +Signal.__index = Signal + +--[=[ + Constructs a new Signal + + @return Signal +]=] +function Signal.new(): Signal + local self = setmetatable({ + _handlerListHead = false, + _proxyHandler = nil, + }, Signal) + return self +end + +--[=[ + Constructs a new Signal that wraps around an RBXScriptSignal. + + @param rbxScriptSignal RBXScriptSignal -- Existing RBXScriptSignal to wrap + @return Signal + + For example: + ```lua + local signal = Signal.Wrap(workspace.ChildAdded) + signal:Connect(function(part) print(part.Name .. " added") end) + Instance.new("Part").Parent = workspace + ``` +]=] +function Signal.Wrap(rbxScriptSignal: RBXScriptSignal): Signal + assert( + typeof(rbxScriptSignal) == "RBXScriptSignal", + "Argument #1 to Signal.Wrap must be a RBXScriptSignal; got " .. typeof(rbxScriptSignal) + ) + local signal = Signal.new() + signal._proxyHandler = rbxScriptSignal:Connect(function(...) + signal:Fire(...) + end) + return signal +end + +--[=[ + Checks if the given object is a Signal. + + @param obj any -- Object to check + @return boolean -- `true` if the object is a Signal. +]=] +function Signal.Is(obj: any): boolean + return type(obj) == "table" and getmetatable(obj) == Signal +end + +--[=[ + @param fn ConnectionFn + @return SignalConnection + + Connects a function to the signal, which will be called anytime the signal is fired. + ```lua + signal:Connect(function(msg, num) + print(msg, num) + end) + + signal:Fire("Hello", 25) + ``` +]=] +function Signal:Connect(fn) + local connection = Connection.new(self, fn) + if self._handlerListHead then + connection._next = self._handlerListHead + self._handlerListHead = connection + else + self._handlerListHead = connection + end + return connection +end + +--[=[ + @deprecated v1.3.0 -- Use `Signal:Once` instead. + @param fn ConnectionFn + @return SignalConnection +]=] +function Signal:ConnectOnce(fn) + return self:Once(fn) +end + +--[=[ + @param fn ConnectionFn + @return SignalConnection + + Connects a function to the signal, which will be called the next time the signal fires. Once + the connection is triggered, it will disconnect itself. + ```lua + signal:Once(function(msg, num) + print(msg, num) + end) + + signal:Fire("Hello", 25) + signal:Fire("This message will not go through", 10) + ``` +]=] +function Signal:Once(fn) + local connection + local done = false + connection = self:Connect(function(...) + if done then + return + end + done = true + connection:Disconnect() + fn(...) + end) + return connection +end + +function Signal:GetConnections() + local items = {} + local item = self._handlerListHead + while item do + table.insert(items, item) + item = item._next + end + return items +end + +-- Disconnect all handlers. Since we use a linked list it suffices to clear the +-- reference to the head handler. +--[=[ + Disconnects all connections from the signal. + ```lua + signal:DisconnectAll() + ``` +]=] +function Signal:DisconnectAll() + local item = self._handlerListHead + while item do + item.Connected = false + item = item._next + end + self._handlerListHead = false +end + +-- Signal:Fire(...) implemented by running the handler functions on the +-- coRunnerThread, and any time the resulting thread yielded without returning +-- to us, that means that it yielded to the Roblox scheduler and has been taken +-- over by Roblox scheduling, meaning we have to make a new coroutine runner. +--[=[ + @param ... any + + Fire the signal, which will call all of the connected functions with the given arguments. + ```lua + signal:Fire("Hello") + + -- Any number of arguments can be fired: + signal:Fire("Hello", 32, {Test = "Test"}, true) + ``` +]=] +function Signal:Fire(...) + local item = self._handlerListHead + while item do + if item.Connected then + if not freeRunnerThread then + freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) + end + task.spawn(freeRunnerThread, item._fn, ...) + end + item = item._next + end +end + +--[=[ + @param ... any + + Same as `Fire`, but uses `task.defer` internally & doesn't take advantage of thread reuse. + ```lua + signal:FireDeferred("Hello") + ``` +]=] +function Signal:FireDeferred(...) + local item = self._handlerListHead + while item do + task.defer(item._fn, ...) + item = item._next + end +end + +--[=[ + @return ... any + @yields + + Yields the current thread until the signal is fired, and returns the arguments fired from the signal. + Yielding the current thread is not always desirable. If the desire is to only capture the next event + fired, using `Once` might be a better solution. + ```lua + task.spawn(function() + local msg, num = signal:Wait() + print(msg, num) --> "Hello", 32 + end) + signal:Fire("Hello", 32) + ``` +]=] +function Signal:Wait() + local waitingCoroutine = coroutine.running() + local connection + local done = false + connection = self:Connect(function(...) + if done then + return + end + done = true + connection:Disconnect() + task.spawn(waitingCoroutine, ...) + end) + return coroutine.yield() +end + +--[=[ + Cleans up the signal. + + Technically, this is only necessary if the signal is created using + `Signal.Wrap`. Connections should be properly GC'd once the signal + is no longer referenced anywhere. However, it is still good practice + to include ways to strictly clean up resources. Calling `Destroy` + on a signal will also disconnect all connections immediately. + ```lua + signal:Destroy() + ``` +]=] +function Signal:Destroy() + self:DisconnectAll() + local proxyHandler = rawget(self, "_proxyHandler") + if proxyHandler then + proxyHandler:Disconnect() + end +end + +-- Make signal strict +setmetatable(Signal, { + __index = function(_tb, key) + error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) + end, + __newindex = function(_tb, key, _value) + error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) + end, +}) + +return { + new = Signal.new, + Wrap = Signal.Wrap, + Is = Signal.Is, +}