UI Manager
This commit is contained in:
parent
d48160c61e
commit
13a17ee7a4
9 changed files with 1162 additions and 0 deletions
15
src/Client/Test/window.lua
Normal file
15
src/Client/Test/window.lua
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
local replicated = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
|
local types = require(replicated.Modules.Managers.UIManager.Types)
|
||||||
|
local Window = require(replicated.Modules.Managers.UIManager.Window)
|
||||||
|
|
||||||
|
local window = Window.new(script.Name)
|
||||||
|
|
||||||
|
window:OnBuild(function(self: types.Window): ()
|
||||||
|
self.ScreenGui = Instance.new("ScreenGui")
|
||||||
|
|
||||||
|
self.ScreenGui.Enabled = false
|
||||||
|
self.ScreenGui.Parent = game:GetService("Players").LocalPlayer.PlayerGui
|
||||||
|
end)
|
||||||
|
|
||||||
|
return window
|
11
src/Client/test.client.lua
Normal file
11
src/Client/test.client.lua
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
local replicated = game:GetService("ReplicatedStorage")
|
||||||
|
local uiManager = require(replicated.Modules.Managers.UIManager)
|
||||||
|
uiManager:Build(script.Parent.Test:GetChildren() :: {})
|
||||||
|
|
||||||
|
local default = uiManager:GetWindow("window")
|
||||||
|
|
||||||
|
uiManager:OpenWindow(default)
|
||||||
|
|
||||||
|
task.wait(10)
|
||||||
|
|
||||||
|
uiManager:CloseWindow(default)
|
101
src/Shared/Modules/Managers/UIManager/Button.lua
Normal file
101
src/Shared/Modules/Managers/UIManager/Button.lua
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
local types = require(script.Parent.Types)
|
||||||
|
|
||||||
|
local button: types.ButtonClass = {} :: types.ButtonClass
|
||||||
|
button["__index"] = button
|
||||||
|
|
||||||
|
local module = {}
|
||||||
|
|
||||||
|
function module.new(name: string): types.Button
|
||||||
|
local self = setmetatable({
|
||||||
|
Name= name,
|
||||||
|
_ = {},
|
||||||
|
}, button)
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:OnBuild(callback: (self: types.Button) -> ())
|
||||||
|
self._.OnBuild = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:OnOpen(callback: (self: types.Button) -> ())
|
||||||
|
self._.OnOpen = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:OnClose(callback: (self: types.Button) -> ())
|
||||||
|
self._.OnClose = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:Build(parent: types.Page | types.Component)
|
||||||
|
self.Parent = parent
|
||||||
|
|
||||||
|
if self._.OnBuild then
|
||||||
|
self._.OnBuild(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:Open()
|
||||||
|
if self.ButtonGui then
|
||||||
|
self.ButtonGui.Active = true
|
||||||
|
self.ButtonGui.Visible = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnOpen then
|
||||||
|
self._.OnOpen(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:Close()
|
||||||
|
if self.ButtonGui then
|
||||||
|
self.ButtonGui.Active = false
|
||||||
|
self.ButtonGui.Visible = false
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnClose then
|
||||||
|
self._.OnClose(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[ Events ]]
|
||||||
|
|
||||||
|
function button:OnPressed(callback: (self: types.Button) -> ())
|
||||||
|
self._.OnPressed = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:OnReleased(callback: (self: types.Button) -> ())
|
||||||
|
self._.OnReleased = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:OnHovered(callback: (self: types.Button) -> ())
|
||||||
|
self._.OnHovered = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:OnHoverLeft(callback: (self: types.Button) -> ())
|
||||||
|
self._.OnHoverLeft = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:Pressed()
|
||||||
|
if self._.OnPressed then
|
||||||
|
self._.OnPressed(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:Released()
|
||||||
|
if self._.OnReleased then
|
||||||
|
self._.OnReleased(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:Hovered()
|
||||||
|
if self._.OnHovered then
|
||||||
|
self._.OnHovered(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function button:HoverLeft()
|
||||||
|
if self._.OnHoverLeft then
|
||||||
|
self._.OnHoverLeft(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return module
|
101
src/Shared/Modules/Managers/UIManager/Component.lua
Normal file
101
src/Shared/Modules/Managers/UIManager/Component.lua
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
local types = require(script.Parent.Types)
|
||||||
|
|
||||||
|
local component: types.ComponentClass = {} :: types.ComponentClass
|
||||||
|
component["__index"] = component
|
||||||
|
|
||||||
|
local module = {}
|
||||||
|
|
||||||
|
function module.new(name: string): types.Component
|
||||||
|
local self = setmetatable({
|
||||||
|
Name= name,
|
||||||
|
_ = {},
|
||||||
|
}, component)
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[ Buttons ]]
|
||||||
|
|
||||||
|
function component:BuildButtons()
|
||||||
|
for _, button: types.Button in self.Buttons.Stored do
|
||||||
|
button:Build(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:AddButton(button: types.Button)
|
||||||
|
self.Buttons.Stored[button.Name] = button
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:RemoveButtons()
|
||||||
|
self.Buttons.Active = {}
|
||||||
|
self.Buttons.Stored = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
function component:OnBuild(callback: (self: types.Component) -> ()): ()
|
||||||
|
self._.OnBuild = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:OnUpdate(command: string, callback: (self: types.Component, parameters: {}) -> ()): ()
|
||||||
|
self._.UpdateCallbacks[command] = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:OnOpen(callback: (self: types.Component) -> ()): ()
|
||||||
|
self._.OnOpen = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:OnClose(callback: (self: types.Component) -> ()): ()
|
||||||
|
self._.OnClose = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:Build(parent: types.Page): ()
|
||||||
|
self:Clean()
|
||||||
|
|
||||||
|
self.Parent = parent
|
||||||
|
|
||||||
|
local window = parent.Parent
|
||||||
|
self.Container = window.Manager.Packages.Trove.new()
|
||||||
|
|
||||||
|
if self._.OnBuild then
|
||||||
|
self._.OnBuild(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:Update(command: string, parameters: {}): ()
|
||||||
|
if self._.UpdateCallbacks[command] then
|
||||||
|
self._.UpdateCallbacks[command](self, parameters)
|
||||||
|
else
|
||||||
|
warn("No update function exists inside of component.", self.Name, "| command:", command)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:Open(): ()
|
||||||
|
if self.Frame then
|
||||||
|
self.Frame.Visible = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnOpen then
|
||||||
|
self._.OnOpen(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:Close(): ()
|
||||||
|
if self.Frame then
|
||||||
|
self.Frame.Visible = false
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnClose then
|
||||||
|
self._.OnClose(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function component:Clean(): ()
|
||||||
|
if self.Container then
|
||||||
|
self.Container:Remove()
|
||||||
|
end
|
||||||
|
|
||||||
|
self:RemoveButtons()
|
||||||
|
end
|
||||||
|
|
||||||
|
return module
|
139
src/Shared/Modules/Managers/UIManager/Page.lua
Normal file
139
src/Shared/Modules/Managers/UIManager/Page.lua
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
local types = require(script.Parent.Types)
|
||||||
|
|
||||||
|
local page: types.PageClass = {} :: types.PageClass
|
||||||
|
page["__index"] = page
|
||||||
|
|
||||||
|
local module = {}
|
||||||
|
|
||||||
|
function module.new(name: string): types.Page
|
||||||
|
local self = setmetatable({
|
||||||
|
Name= name,
|
||||||
|
Buttons = {
|
||||||
|
Active = {},
|
||||||
|
Stored = {},
|
||||||
|
},
|
||||||
|
Components = {
|
||||||
|
Open = {},
|
||||||
|
Stored = {},
|
||||||
|
},
|
||||||
|
|
||||||
|
_ = {},
|
||||||
|
}, page)
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[ Buttons ]]
|
||||||
|
|
||||||
|
function page:BuildButtons()
|
||||||
|
for _, button: types.Button in self.Buttons.Stored do
|
||||||
|
button:Build(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:AddButton(button: types.Button)
|
||||||
|
self.Buttons.Stored[button.Name] = button
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:RemoveButtons()
|
||||||
|
self.Buttons.Active = {}
|
||||||
|
self.Buttons.Stored = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
--[[ Components ]]
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:GetComponent(component: string): types.Component
|
||||||
|
return self.Components.Stored[component]
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:RemoveComponents()
|
||||||
|
for _, component: types.Component in self.Components.Open do
|
||||||
|
component:Clean()
|
||||||
|
end
|
||||||
|
|
||||||
|
self.Components.Open = {}
|
||||||
|
self.Components.Stored = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
--[[ Page ]]
|
||||||
|
|
||||||
|
function page:OnBuild(callback: (self: types.Page) -> ())
|
||||||
|
self._.OnBuild = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:OnOpen(callback: (self: types.Page) -> ())
|
||||||
|
self._.OnOpen = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:OnClose(callback: (self: types.Page) -> ())
|
||||||
|
self._.OnClose = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:Build(parent: types.Window)
|
||||||
|
self:Clean()
|
||||||
|
|
||||||
|
self.Parent = parent
|
||||||
|
|
||||||
|
self.Container = parent.Manager.Packages.Trove.new()
|
||||||
|
|
||||||
|
if self._.OnBuild then
|
||||||
|
self._.OnBuild(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:Open()
|
||||||
|
if self.Frame then
|
||||||
|
self.Frame.Visible = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnOpen then
|
||||||
|
self._.OnOpen(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:Close()
|
||||||
|
if self.Frame then
|
||||||
|
self.Frame.Visible = false
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnClose then
|
||||||
|
self._.OnClose(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:Clean()
|
||||||
|
self:RemoveComponents()
|
||||||
|
self:RemoveButtons()
|
||||||
|
|
||||||
|
self.Container:Remove()
|
||||||
|
end
|
||||||
|
|
||||||
|
function page:Remove()
|
||||||
|
self:Clean()
|
||||||
|
|
||||||
|
if self.Frame then
|
||||||
|
self.Frame:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[ Helper ]]
|
||||||
|
|
||||||
|
function page:GetManager(): types.Manager
|
||||||
|
return self.Parent.Manager
|
||||||
|
end
|
||||||
|
|
||||||
|
return module
|
228
src/Shared/Modules/Managers/UIManager/Types.lua
Normal file
228
src/Shared/Modules/Managers/UIManager/Types.lua
Normal file
|
@ -0,0 +1,228 @@
|
||||||
|
local replicated = game:GetService("ReplicatedStorage")
|
||||||
|
local trove = require(replicated.Modules.Utility.Trove)
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
export type ButtonClass = {
|
||||||
|
Parent: Page | Component,
|
||||||
|
Name: string,
|
||||||
|
ButtonGui: GuiButton,
|
||||||
|
|
||||||
|
_: {
|
||||||
|
OnBuild: (self: Button) -> ()?,
|
||||||
|
OnOpen: (self: Button) -> ()?,
|
||||||
|
OnClose: (self: Button) -> ()?,
|
||||||
|
|
||||||
|
OnPressed: (self: Button) -> ()?,
|
||||||
|
OnReleased: (self: Button) -> ()?,
|
||||||
|
OnHovered: (self: Button) -> ()?,
|
||||||
|
OnHoverLeft: (self: Button) -> ()?,
|
||||||
|
},
|
||||||
|
|
||||||
|
OnBuild: (self: Button, callback: (self: Button) -> ()) -> (),
|
||||||
|
OnOpen: (self: Button, callback: (self: Button) -> ()) -> (),
|
||||||
|
OnClose: (self: Button, callback: (self: Button) -> ()) -> (),
|
||||||
|
|
||||||
|
Build: (self: Button, parent: Page | Component) -> (),
|
||||||
|
Open: (self: Button) -> (),
|
||||||
|
Close: (self: Button) -> (),
|
||||||
|
|
||||||
|
OnPressed: (self: Button, callback: (self: Button) -> ()) -> (),
|
||||||
|
OnReleased: (self: Button, callback: (self: Button) -> ()) -> (),
|
||||||
|
OnHovered: (self: Button, callback: (self: Button) -> ()) -> (),
|
||||||
|
OnHoverLeft: (self: Button, callback: (self: Button) -> ()) -> (),
|
||||||
|
|
||||||
|
Pressed: (self: Button) -> (),
|
||||||
|
Released: (self: Button) -> (),
|
||||||
|
Hovered: (self: Button) -> (),
|
||||||
|
HoverLeft: (self: Button) -> (),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Button = typeof(setmetatable({} :: ButtonClass, {}))
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
export type ComponentClass = {
|
||||||
|
Parent: Page,
|
||||||
|
Name: string,
|
||||||
|
Frame: Frame,
|
||||||
|
|
||||||
|
Container: trove.Trove,
|
||||||
|
|
||||||
|
Buttons: {
|
||||||
|
Active: { [string]: Button },
|
||||||
|
Stored: { [string]: Button },
|
||||||
|
},
|
||||||
|
|
||||||
|
BuildButtons: (self: Component) -> (),
|
||||||
|
AddButton: (self: Component, button: Button) -> (),
|
||||||
|
RemoveButtons: (self: Component) -> (),
|
||||||
|
|
||||||
|
_: {
|
||||||
|
OnBuild: (self: Component) -> ()?,
|
||||||
|
OnOpen: (self: Component) -> ()?,
|
||||||
|
OnClose: (self: Component) -> ()?,
|
||||||
|
|
||||||
|
UpdateCallbacks: { [string]: (self: Page, parameters: {}) -> () },
|
||||||
|
},
|
||||||
|
|
||||||
|
OnBuild: (self: Component, callback: (self: Component) -> ()) -> (),
|
||||||
|
OnUpdate: (self: Component, command: string, callback: (self: Component) -> ()) -> (),
|
||||||
|
OnOpen: (self: Component, callback: (self: Component) -> ()) -> (),
|
||||||
|
OnClose: (self: Component, callback: (self: Component) -> ()) -> (),
|
||||||
|
|
||||||
|
Build: (self: Component, parent: Page) -> (),
|
||||||
|
Update: (self: Component, command: string, parameters: {}) -> (),
|
||||||
|
Open: (self: Component) -> (),
|
||||||
|
Close: (self: Component) -> (),
|
||||||
|
|
||||||
|
Clean: (self: Component) -> (),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Component = typeof(setmetatable({} :: ComponentClass, {}))
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
export type PageClass = {
|
||||||
|
Parent: Window,
|
||||||
|
Name: string,
|
||||||
|
Frame: Frame,
|
||||||
|
|
||||||
|
Container: trove.Trove,
|
||||||
|
|
||||||
|
Buttons: {
|
||||||
|
Active: { [string]: Button },
|
||||||
|
Stored: { [string]: Button },
|
||||||
|
},
|
||||||
|
|
||||||
|
BuildButtons: (self: Page) -> (),
|
||||||
|
AddButton: (self: Page, button: Button) -> (),
|
||||||
|
RemoveButtons: (self: Page) -> (),
|
||||||
|
|
||||||
|
Components: {
|
||||||
|
Open: { [string]: Component },
|
||||||
|
Stored: { [string]: Component },
|
||||||
|
},
|
||||||
|
|
||||||
|
BuildComponents: (self: Page) -> (),
|
||||||
|
AddComponent: (self: Page, component: Component) -> (),
|
||||||
|
GetComponent: (self: Page, component: string) -> (),
|
||||||
|
RemoveComponents: (self: Page) -> (),
|
||||||
|
|
||||||
|
_: {
|
||||||
|
OnBuild: (self: Page) -> ()?,
|
||||||
|
OnOpen: (self: Page) -> ()?,
|
||||||
|
OnClose: (self: Page) -> ()?,
|
||||||
|
},
|
||||||
|
|
||||||
|
OnBuild: (self: Page, callback: (self: Page) -> ()) -> (),
|
||||||
|
OnOpen: (self: Page, callback: (self: Page) -> ()) -> (),
|
||||||
|
OnClose: (self: Page, callback: (self: Page) -> ()) -> (),
|
||||||
|
|
||||||
|
Build: (self: Page, parent: Window) -> (),
|
||||||
|
Open: (self: Page) -> (),
|
||||||
|
Close: (self: Page) -> (),
|
||||||
|
|
||||||
|
Clean: (self: Page) -> (),
|
||||||
|
Remove: (self: Page) -> (),
|
||||||
|
|
||||||
|
GetManager: (self: Window) -> (Manager),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Page = typeof(setmetatable({} :: PageClass, {}))
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
export type WindowClass = {
|
||||||
|
Manager: Manager,
|
||||||
|
Name: string,
|
||||||
|
ScreenGui: ScreenGui,
|
||||||
|
Pages: {
|
||||||
|
Open: { [string]: Page },
|
||||||
|
Stored: { [string]: Page },
|
||||||
|
},
|
||||||
|
|
||||||
|
BuildPages: (self: Window) -> (),
|
||||||
|
AddPage: (self: Window, page: Page) -> (),
|
||||||
|
GetPage: (self: Page, page: string) -> (),
|
||||||
|
RemovePages: (self: Window) -> (),
|
||||||
|
|
||||||
|
_: {
|
||||||
|
OnBuild: (self: Window) -> ()?,
|
||||||
|
OnOpen: (self: Window) -> ()?,
|
||||||
|
OnClose: (self: Window) -> ()?,
|
||||||
|
},
|
||||||
|
|
||||||
|
OnBuild: (self: Window, callback: (self: Window) -> ()) -> (),
|
||||||
|
OnOpen: (self: Window, callback: (self: Window) -> ()) -> (),
|
||||||
|
OnClose: (self: Window, callback: (self: Window) -> ()) -> (),
|
||||||
|
|
||||||
|
Build: (self: Window, manager: Manager) -> (),
|
||||||
|
Open: (self: Window) -> (),
|
||||||
|
Close: (self: Window) -> (),
|
||||||
|
|
||||||
|
Clean: (self: Window) -> (),
|
||||||
|
Remove: (self: Window) -> (),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Window = typeof(setmetatable({} :: WindowClass, {}))
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
export type Manager = {
|
||||||
|
--[=[
|
||||||
|
Useful packages that may be used globally by all windows, pages, and so forth.
|
||||||
|
]=]
|
||||||
|
Packages: {
|
||||||
|
Trove: typeof(trove),
|
||||||
|
},
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
```Window``` objects that are either ```Stored```, aka cached, or are in an ```Open``` state.
|
||||||
|
]=]
|
||||||
|
Windows: {
|
||||||
|
Open: { [string]: Window },
|
||||||
|
Stored: { [string]: Window },
|
||||||
|
},
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Locate a ```Window``` object stored under the provided name.
|
||||||
|
|
||||||
|
@param name string
|
||||||
|
|
||||||
|
@return ```Window```
|
||||||
|
]=]
|
||||||
|
GetWindow: (self: Manager, name: string) -> (Window),
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Build and open a ```Window``` object and log its opened state.
|
||||||
|
|
||||||
|
@param window ```Window```
|
||||||
|
]=]
|
||||||
|
OpenWindow: (self: Manager, window: Window) -> (),
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Close and remove a ```Window``` object and log its state change.
|
||||||
|
|
||||||
|
@param window ```Window```
|
||||||
|
]=]
|
||||||
|
CloseWindow: (self: Manager, window: Window) -> (),
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Intended to only be run on the first time that the client boots up. \
|
||||||
|
Receives a table of module scripts with which it will use to build the manager windows.
|
||||||
|
|
||||||
|
@param source { ModuleScript } ```Window``` objects.
|
||||||
|
]=]
|
||||||
|
Build: (self: Manager, source: { ModuleScript }) -> (),
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Will clean the manager by removing all the open ```Window``` objects.
|
||||||
|
]=]
|
||||||
|
Clean: (self: Manager) -> (),
|
||||||
|
}
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
return {}
|
99
src/Shared/Modules/Managers/UIManager/Window.lua
Normal file
99
src/Shared/Modules/Managers/UIManager/Window.lua
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
local types = require(script.Parent.Types)
|
||||||
|
|
||||||
|
local window: types.WindowClass = {} :: types.WindowClass
|
||||||
|
window["__index"] = window
|
||||||
|
|
||||||
|
local module = {}
|
||||||
|
|
||||||
|
function module.new(name: string): types.Window
|
||||||
|
local self = setmetatable({
|
||||||
|
Name= name,
|
||||||
|
Pages = {
|
||||||
|
Open = {},
|
||||||
|
Stored = {},
|
||||||
|
},
|
||||||
|
|
||||||
|
_ = {},
|
||||||
|
}, window)
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:BuildPages()
|
||||||
|
for _, page: types.Page in self.Pages.Stored do
|
||||||
|
page:Build(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:AddPage(page: types.Page)
|
||||||
|
self.Pages.Stored[page.Name] = page
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:GetPage(page: string): types.Page
|
||||||
|
return self.Pages.Stored[page]
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:RemovePages()
|
||||||
|
for _, page: types.Page in self.Pages.Open do
|
||||||
|
page:Remove()
|
||||||
|
end
|
||||||
|
|
||||||
|
self.Pages.Open = {}
|
||||||
|
self.Pages.Stored = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:OnBuild(callback: (self: types.Window) -> ())
|
||||||
|
self._.OnBuild = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:OnOpen(callback: (self: types.Window) -> ())
|
||||||
|
self._.OnOpen = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:OnClose(callback: (self: types.Window) -> ())
|
||||||
|
self._.OnClose = callback
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:Build(manager: types.Manager)
|
||||||
|
self:Clean()
|
||||||
|
|
||||||
|
self.Manager = manager
|
||||||
|
|
||||||
|
if self._.OnBuild then
|
||||||
|
self._.OnBuild(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:Open()
|
||||||
|
if self.ScreenGui then
|
||||||
|
self.ScreenGui.Enabled = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnOpen then
|
||||||
|
self._.OnOpen(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:Close()
|
||||||
|
if self.ScreenGui then
|
||||||
|
self.ScreenGui.Enabled = false
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._.OnClose then
|
||||||
|
self._.OnClose(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:Clean()
|
||||||
|
self:RemovePages()
|
||||||
|
end
|
||||||
|
|
||||||
|
function window:Remove()
|
||||||
|
self:Clean()
|
||||||
|
|
||||||
|
if self.ScreenGui then
|
||||||
|
self.ScreenGui:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return module
|
53
src/Shared/Modules/Managers/UIManager/init.lua
Normal file
53
src/Shared/Modules/Managers/UIManager/init.lua
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
local replicated = game:GetService("ReplicatedStorage")
|
||||||
|
local trove = require(replicated.Modules.Utility.Trove)
|
||||||
|
local types = require(script.Types)
|
||||||
|
|
||||||
|
local manager: types.Manager = {
|
||||||
|
Packages = {Trove = trove},
|
||||||
|
|
||||||
|
Windows = {
|
||||||
|
Open = {},
|
||||||
|
Stored = {},
|
||||||
|
},
|
||||||
|
} :: types.Manager
|
||||||
|
|
||||||
|
function manager:GetWindow(name: string)
|
||||||
|
return self.Windows.Stored[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
function manager:OpenWindow(window: types.Window)
|
||||||
|
window:Build(self)
|
||||||
|
window:Open()
|
||||||
|
|
||||||
|
self.Windows.Open[window.Name] = window
|
||||||
|
end
|
||||||
|
|
||||||
|
function manager:CloseWindow(window: types.Window)
|
||||||
|
window:Close()
|
||||||
|
window:Remove()
|
||||||
|
|
||||||
|
self.Windows.Open[window.Name] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function manager:Build(source: { ModuleScript })
|
||||||
|
self:Clean()
|
||||||
|
|
||||||
|
for _, module: ModuleScript in source do
|
||||||
|
local window: types.Window = require(module) :: types.Window
|
||||||
|
|
||||||
|
self.Windows.Stored[window.Name] = window
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function manager:Clean()
|
||||||
|
for _, window in self.Windows.Open do
|
||||||
|
window:Remove()
|
||||||
|
end
|
||||||
|
|
||||||
|
self.Windows = {
|
||||||
|
Open = {},
|
||||||
|
Stored = {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return manager
|
415
src/Shared/Modules/Utility/Trove.lua
Normal file
415
src/Shared/Modules/Utility/Trove.lua
Normal file
|
@ -0,0 +1,415 @@
|
||||||
|
-- Trove
|
||||||
|
-- Stephen Leitnick
|
||||||
|
-- October 16, 2021
|
||||||
|
|
||||||
|
local FN_MARKER = newproxy()
|
||||||
|
local THREAD_MARKER = newproxy()
|
||||||
|
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
|
||||||
|
local function GetObjectCleanupFunction(object, cleanupMethod)
|
||||||
|
local t = typeof(object)
|
||||||
|
if t == "function" then
|
||||||
|
return FN_MARKER
|
||||||
|
elseif t == "thread" then
|
||||||
|
return THREAD_MARKER
|
||||||
|
end
|
||||||
|
if cleanupMethod then
|
||||||
|
return cleanupMethod
|
||||||
|
end
|
||||||
|
if t == "Instance" then
|
||||||
|
return "Destroy"
|
||||||
|
elseif t == "RBXScriptConnection" then
|
||||||
|
return "Disconnect"
|
||||||
|
elseif t == "table" then
|
||||||
|
if typeof(object.Destroy) == "function" then
|
||||||
|
return "Destroy"
|
||||||
|
elseif typeof(object.Disconnect) == "function" then
|
||||||
|
return "Disconnect"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
error("Failed to get cleanup function for object " .. t .. ": " .. tostring(object), 3)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function AssertPromiseLike(object)
|
||||||
|
if
|
||||||
|
type(object) ~= "table"
|
||||||
|
or type(object.getStatus) ~= "function"
|
||||||
|
or type(object.finally) ~= "function"
|
||||||
|
or type(object.cancel) ~= "function"
|
||||||
|
then
|
||||||
|
error("Did not receive a Promise as an argument", 3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@class Trove
|
||||||
|
A Trove is helpful for tracking any sort of object during
|
||||||
|
runtime that needs to get cleaned up at some point.
|
||||||
|
]=]
|
||||||
|
local Trove = {}
|
||||||
|
Trove.__index = Trove
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@return Trove
|
||||||
|
Constructs a Trove object.
|
||||||
|
]=]
|
||||||
|
function Trove.new()
|
||||||
|
local self = setmetatable({}, Trove)
|
||||||
|
self._objects = {}
|
||||||
|
self._cleaning = false
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
export type Trove = typeof(Trove.new())
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@return Trove
|
||||||
|
Creates and adds another trove to itself. This is just shorthand
|
||||||
|
for `trove:Construct(Trove)`. This is useful for contexts where
|
||||||
|
the trove object is present, but the class itself isn't.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
This does _not_ clone the trove. In other words, the objects in the
|
||||||
|
trove are not given to the new constructed trove. This is simply to
|
||||||
|
construct a new Trove and add it as an object to track.
|
||||||
|
:::
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local trove = Trove.new()
|
||||||
|
local subTrove = trove:Extend()
|
||||||
|
|
||||||
|
trove:Clean() -- Cleans up the subTrove too
|
||||||
|
```
|
||||||
|
]=]
|
||||||
|
function Trove:Extend()
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:Extend() while cleaning", 2)
|
||||||
|
end
|
||||||
|
return self:Construct(Trove)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Clones the given instance and adds it to the trove. Shorthand for
|
||||||
|
`trove:Add(instance:Clone())`.
|
||||||
|
]=]
|
||||||
|
function Trove:Clone(instance: Instance): Instance
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:Clone() while cleaning", 2)
|
||||||
|
end
|
||||||
|
return self:Add(instance:Clone())
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param class table | (...any) -> any
|
||||||
|
@param ... any
|
||||||
|
@return any
|
||||||
|
Constructs a new object from either the
|
||||||
|
table or function given.
|
||||||
|
|
||||||
|
If a table is given, the table's `new`
|
||||||
|
function will be called with the given
|
||||||
|
arguments.
|
||||||
|
|
||||||
|
If a function is given, the function will
|
||||||
|
be called with the given arguments.
|
||||||
|
|
||||||
|
The result from either of the two options
|
||||||
|
will be added to the trove.
|
||||||
|
|
||||||
|
This is shorthand for `trove:Add(SomeClass.new(...))`
|
||||||
|
and `trove:Add(SomeFunction(...))`.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local Signal = require(somewhere.Signal)
|
||||||
|
|
||||||
|
-- All of these are identical:
|
||||||
|
local s = trove:Construct(Signal)
|
||||||
|
local s = trove:Construct(Signal.new)
|
||||||
|
local s = trove:Construct(function() return Signal.new() end)
|
||||||
|
local s = trove:Add(Signal.new())
|
||||||
|
|
||||||
|
-- Even Roblox instances can be created:
|
||||||
|
local part = trove:Construct(Instance, "Part")
|
||||||
|
```
|
||||||
|
]=]
|
||||||
|
function Trove:Construct(class, ...)
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:Construct() while cleaning", 2)
|
||||||
|
end
|
||||||
|
local object = nil
|
||||||
|
local t = type(class)
|
||||||
|
if t == "table" then
|
||||||
|
object = class.new(...)
|
||||||
|
elseif t == "function" then
|
||||||
|
object = class(...)
|
||||||
|
end
|
||||||
|
return self:Add(object)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param signal RBXScriptSignal
|
||||||
|
@param fn (...: any) -> ()
|
||||||
|
@return RBXScriptConnection
|
||||||
|
Connects the function to the signal, adds the connection
|
||||||
|
to the trove, and then returns the connection.
|
||||||
|
|
||||||
|
This is shorthand for `trove:Add(signal:Connect(fn))`.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
trove:Connect(workspace.ChildAdded, function(instance)
|
||||||
|
print(instance.Name .. " added to workspace")
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
]=]
|
||||||
|
function Trove:Connect(signal: RBXScriptSignal, fn)
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:Connect() while cleaning", 2)
|
||||||
|
end
|
||||||
|
return self:Add(signal:Connect(fn))
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param signal RBXScriptSignal
|
||||||
|
@param fn (...: any) -> ()
|
||||||
|
@param metatable { [any]: any? }
|
||||||
|
@param ... Passed arguements
|
||||||
|
@return RBXScriptConnection
|
||||||
|
Connects the function to the signal, adds the connection
|
||||||
|
to the trove, and then returns the connection.
|
||||||
|
|
||||||
|
This is shorthand for `trove:Add(signal:Connect(fn))`.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
trove:ConnectMethod(workspace.ChildAdded, function(instance)
|
||||||
|
print(instance.Name .. " added to workspace")
|
||||||
|
end, ...)
|
||||||
|
```
|
||||||
|
]=]
|
||||||
|
function Trove:ConnectMethod(signal: RBXScriptSignal, methodName: string, metatable, parameters: {any})
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:Connect() while cleaning", 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not metatable[methodName] then
|
||||||
|
error("Cannot call trove:Connect() because the method does not exist: " .. methodName, 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
return self:Add(signal:Connect(function()
|
||||||
|
metatable[methodName](metatable, table.unpack(parameters))
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param name string
|
||||||
|
@param priority number
|
||||||
|
@param fn (dt: number) -> ()
|
||||||
|
Calls `RunService:BindToRenderStep` and registers a function in the
|
||||||
|
trove that will call `RunService:UnbindFromRenderStep` on cleanup.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
trove:BindToRenderStep("Test", Enum.RenderPriority.Last.Value, function(dt)
|
||||||
|
-- Do something
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
]=]
|
||||||
|
function Trove:BindToRenderStep(name: string, priority: number, fn: (dt: number) -> ())
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:BindToRenderStep() while cleaning", 2)
|
||||||
|
end
|
||||||
|
RunService:BindToRenderStep(name, priority, fn)
|
||||||
|
self:Add(function()
|
||||||
|
RunService:UnbindFromRenderStep(name)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param promise Promise
|
||||||
|
@return Promise
|
||||||
|
Gives the promise to the trove, which will cancel the promise if the trove is cleaned up or if the promise
|
||||||
|
is removed. The exact promise is returned, thus allowing chaining.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
trove:AddPromise(doSomethingThatReturnsAPromise())
|
||||||
|
:andThen(function()
|
||||||
|
print("Done")
|
||||||
|
end)
|
||||||
|
-- Will cancel the above promise (assuming it didn't resolve immediately)
|
||||||
|
trove:Clean()
|
||||||
|
|
||||||
|
local p = trove:AddPromise(doSomethingThatReturnsAPromise())
|
||||||
|
-- Will also cancel the promise
|
||||||
|
trove:Remove(p)
|
||||||
|
```
|
||||||
|
|
||||||
|
:::caution Promise v4 Only
|
||||||
|
This is only compatible with the [roblox-lua-promise](https://eryn.io/roblox-lua-promise/) library, version 4.
|
||||||
|
:::
|
||||||
|
]=]
|
||||||
|
function Trove:AddPromise(promise)
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:AddPromise() while cleaning", 2)
|
||||||
|
end
|
||||||
|
AssertPromiseLike(promise)
|
||||||
|
if promise:getStatus() == "Started" then
|
||||||
|
promise:finally(function()
|
||||||
|
if self._cleaning then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:_findAndRemoveFromObjects(promise, false)
|
||||||
|
end)
|
||||||
|
self:Add(promise, "cancel")
|
||||||
|
end
|
||||||
|
return promise
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param object any -- Object to track
|
||||||
|
@param cleanupMethod string? -- Optional cleanup name override
|
||||||
|
@return object: any
|
||||||
|
Adds an object to the trove. Once the trove is cleaned or
|
||||||
|
destroyed, the object will also be cleaned up.
|
||||||
|
|
||||||
|
The following types are accepted (e.g. `typeof(object)`):
|
||||||
|
|
||||||
|
| Type | Cleanup |
|
||||||
|
| ---- | ------- |
|
||||||
|
| `Instance` | `object:Destroy()` |
|
||||||
|
| `RBXScriptConnection` | `object:Disconnect()` |
|
||||||
|
| `function` | `object()` |
|
||||||
|
| `thread` | `coroutine.close(object)` |
|
||||||
|
| `table` | `object:Destroy()` _or_ `object:Disconnect()` |
|
||||||
|
| `table` with `cleanupMethod` | `object:<cleanupMethod>()` |
|
||||||
|
|
||||||
|
Returns the object added.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Add a part to the trove, then destroy the trove,
|
||||||
|
-- which will also destroy the part:
|
||||||
|
local part = Instance.new("Part")
|
||||||
|
trove:Add(part)
|
||||||
|
trove:Destroy()
|
||||||
|
|
||||||
|
-- Add a function to the trove:
|
||||||
|
trove:Add(function()
|
||||||
|
print("Cleanup!")
|
||||||
|
end)
|
||||||
|
trove:Destroy()
|
||||||
|
|
||||||
|
-- Standard cleanup from table:
|
||||||
|
local tbl = {}
|
||||||
|
function tbl:Destroy()
|
||||||
|
print("Cleanup")
|
||||||
|
end
|
||||||
|
trove:Add(tbl)
|
||||||
|
|
||||||
|
-- Custom cleanup from table:
|
||||||
|
local tbl = {}
|
||||||
|
function tbl:DoSomething()
|
||||||
|
print("Do something on cleanup")
|
||||||
|
end
|
||||||
|
trove:Add(tbl, "DoSomething")
|
||||||
|
```
|
||||||
|
]=]
|
||||||
|
function Trove:Add(object: any, cleanupMethod: string?): any
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:Add() while cleaning", 2)
|
||||||
|
end
|
||||||
|
local cleanup = GetObjectCleanupFunction(object, cleanupMethod)
|
||||||
|
table.insert(self._objects, { object, cleanup })
|
||||||
|
return object
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param object any -- Object to remove
|
||||||
|
Removes the object from the Trove and cleans it up.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local part = Instance.new("Part")
|
||||||
|
trove:Add(part)
|
||||||
|
trove:Remove(part)
|
||||||
|
```
|
||||||
|
]=]
|
||||||
|
function Trove:Remove(object: any): boolean
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:Remove() while cleaning", 2)
|
||||||
|
end
|
||||||
|
return self:_findAndRemoveFromObjects(object, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Cleans up all objects in the trove. This is
|
||||||
|
similar to calling `Remove` on each object
|
||||||
|
within the trove. The ordering of the objects
|
||||||
|
removed is _not_ guaranteed.
|
||||||
|
]=]
|
||||||
|
function Trove:Clean()
|
||||||
|
if self._cleaning then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._cleaning = true
|
||||||
|
for _, obj in self._objects do
|
||||||
|
self:_cleanupObject(obj[1], obj[2])
|
||||||
|
end
|
||||||
|
table.clear(self._objects)
|
||||||
|
self._cleaning = false
|
||||||
|
end
|
||||||
|
|
||||||
|
function Trove:_findAndRemoveFromObjects(object: any, cleanup: boolean): boolean
|
||||||
|
local objects = self._objects
|
||||||
|
for i, obj in ipairs(objects) do
|
||||||
|
if obj[1] == object then
|
||||||
|
local n = #objects
|
||||||
|
objects[i] = objects[n]
|
||||||
|
objects[n] = nil
|
||||||
|
if cleanup then
|
||||||
|
self:_cleanupObject(obj[1], obj[2])
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function Trove:_cleanupObject(object, cleanupMethod)
|
||||||
|
if cleanupMethod == FN_MARKER then
|
||||||
|
object()
|
||||||
|
elseif cleanupMethod == THREAD_MARKER then
|
||||||
|
coroutine.close(object)
|
||||||
|
else
|
||||||
|
object[cleanupMethod](object)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
@param instance Instance
|
||||||
|
@return RBXScriptConnection
|
||||||
|
Attaches the trove to a Roblox instance. Once this
|
||||||
|
instance is removed from the game (parent or ancestor's
|
||||||
|
parent set to `nil`), the trove will automatically
|
||||||
|
clean up.
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
Will throw an error if `instance` is not a descendant
|
||||||
|
of the game hierarchy.
|
||||||
|
:::
|
||||||
|
]=]
|
||||||
|
function Trove:AttachToInstance(instance: Instance)
|
||||||
|
if self._cleaning then
|
||||||
|
error("Cannot call trove:AttachToInstance() while cleaning", 2)
|
||||||
|
elseif not instance:IsDescendantOf(game) then
|
||||||
|
error("Instance is not a descendant of the game hierarchy", 2)
|
||||||
|
end
|
||||||
|
return self:Connect(instance.Destroying, function()
|
||||||
|
self:Destroy()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[=[
|
||||||
|
Alias for `trove:Clean()`.
|
||||||
|
]=]
|
||||||
|
function Trove:Destroy()
|
||||||
|
self:Clean()
|
||||||
|
end
|
||||||
|
|
||||||
|
return Trove
|
Loading…
Reference in a new issue