├── selene.toml ├── .gitignore ├── .gitattributes ├── default.project.json ├── tests.server.lua ├── .editorconfig ├── aftman.toml ├── wally.toml ├── wally.lock ├── src ├── createConnect.lua ├── widgets │ ├── blur.lua │ ├── space.lua │ ├── label.lua │ ├── portal.lua │ ├── heading.lua │ ├── spinner.lua │ ├── highlight.lua │ ├── row.lua │ ├── error.lua │ ├── button.lua │ ├── slider.lua │ ├── arrow.lua │ ├── checkbox.lua │ ├── table.lua │ └── window.lua ├── hydrateAutomaticSize.lua ├── Style.lua ├── init.lua ├── create.lua ├── automaticSize.lua └── Runtime.lua ├── README.md ├── stories ├── arrow.story.lua ├── button.story.lua ├── spinner.story.lua ├── window.story.lua └── checkbox.story.lua ├── example └── ui.client.lua ├── test.project.json ├── moonwave.toml ├── docs ├── styles.md ├── getting-started.md ├── details.md ├── intro.md └── creating-widgets.md ├── testez.toml ├── LICENSE ├── tests └── plasma.spec.lua └── CHANGELOG.md /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox+testez" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | roblox.toml 3 | build 4 | plasma.rbxm -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plasma", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests.server.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | require(ReplicatedStorage.TestEZ).TestBootstrap:run({ 4 | ReplicatedStorage.Plasma, 5 | ReplicatedStorage.Tests, 6 | }) 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.json] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.lua] 13 | indent_style = tab 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /aftman.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Aftman, a cross-platform toolchain manager. 2 | # For more information, see https://github.com/LPGhatguy/aftman 3 | 4 | # To add a new tool, add an entry to this table. 5 | [tools] 6 | rojo = "rojo-rbx/rojo@7.1.1" 7 | wally = "upliftgames/wally@0.3.1" 8 | # rojo = "rojo-rbx/rojo@6.2.0" -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "evaera/plasma" 3 | version = "0.4.2" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | exclude = ["example", "docs", "Packages", ".moonwave", "build", "pages", "tests", "stories", "plasma.rbxm", "roblox.toml"] 7 | 8 | [dependencies] 9 | TestEZ = "roblox/testez@0.4.1" -------------------------------------------------------------------------------- /wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "evaera/plasma" 7 | version = "0.4.2" 8 | dependencies = [["TestEZ", "roblox/testez@0.4.1"]] 9 | 10 | [[package]] 11 | name = "roblox/testez" 12 | version = "0.4.1" 13 | dependencies = [] 14 | -------------------------------------------------------------------------------- /src/createConnect.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Runtime) 2 | 3 | local function createConnect() 4 | local eventCallback = Runtime.useEventCallback() 5 | 6 | return function(instance, eventName, handler) 7 | if eventCallback then 8 | return eventCallback(instance, eventName, handler) 9 | else 10 | return instance[eventName]:Connect(handler) 11 | end 12 | end 13 | end 14 | 15 | return createConnect 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Plasma

3 |

A declarative, immediate mode UI widget library for Roblox.

4 |

This repository is no longer maintained. A community fork contintues development:

5 | Take me to matter-ecs/plasma → 6 |





7 | 8 | Originally made by [@lpghatguy](https://github.com/lpghatguy) and [@evaera](https://github.com/evaera) ❤️ 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/widgets/blur.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Plasma 3 | @function blur 4 | @tag widgets 5 | @param size number -- The size of the blur 6 | 7 | A blur effect in the world. Created in Lighting. 8 | ]=] 9 | 10 | local Lighting = game:GetService("Lighting") 11 | local Runtime = require(script.Parent.Parent.Runtime) 12 | local portal = require(script.Parent.portal) 13 | 14 | return function(size) 15 | portal(Lighting, function() 16 | Runtime.useInstance(function() 17 | local blur = Instance.new("BlurEffect") 18 | blur.Size = size 19 | return blur 20 | end) 21 | end) 22 | end 23 | -------------------------------------------------------------------------------- /src/widgets/space.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Parent.Runtime) 2 | local create = require(script.Parent.Parent.create) 3 | 4 | --[=[ 5 | @within Plasma 6 | @function space 7 | @param size number 8 | 9 | Blank space of a certain size. 10 | 11 | ]=] 12 | return Runtime.widget(function(size) 13 | local refs = Runtime.useInstance(function(ref) 14 | return create("Frame", { 15 | [ref] = "space", 16 | BackgroundTransparency = 1, 17 | }) 18 | end) 19 | 20 | Runtime.useEffect(function() 21 | refs.space.Size = UDim2.new(0, size, 0, size) 22 | end, size) 23 | end) 24 | -------------------------------------------------------------------------------- /stories/arrow.story.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local Plasma = require(ReplicatedStorage.Plasma) 4 | 5 | return function(target) 6 | local root = Plasma.new(target) 7 | 8 | local connection = RunService.Heartbeat:Connect(function() 9 | Plasma.start(root, function() 10 | Plasma.portal(workspace, function() 11 | Plasma.arrow(Vector3.new(0, 0, 0)) 12 | Plasma.arrow(Vector3.new(5, 5, 5), Vector3.new(10, 10, 10)) 13 | end) 14 | end) 15 | end) 16 | 17 | return function() 18 | connection:Disconnect() 19 | Plasma.start(root, function() end) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /stories/button.story.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local Plasma = require(ReplicatedStorage.Plasma) 4 | 5 | return function(target) 6 | local root = Plasma.new(target) 7 | 8 | local checked = false 9 | 10 | local connection = RunService.Heartbeat:Connect(function() 11 | Plasma.start(root, function() 12 | Plasma.window("Button", function() 13 | if Plasma.button("button text"):clicked() then 14 | print("clicked!") 15 | end 16 | end) 17 | end) 18 | end) 19 | 20 | return function() 21 | connection:Disconnect() 22 | Plasma.start(root, function() end) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /stories/spinner.story.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local Plasma = require(ReplicatedStorage.Plasma) 4 | 5 | return function(target) 6 | local root = Plasma.new(target) 7 | 8 | local connection = RunService.Heartbeat:Connect(function() 9 | Plasma.start(root, function() 10 | Plasma.window("Spinner", function() 11 | Plasma.row({ 12 | alignment = Enum.HorizontalAlignment.Center, 13 | }, function() 14 | Plasma.spinner() 15 | end) 16 | end) 17 | end) 18 | end) 19 | 20 | return function() 21 | connection:Disconnect() 22 | Plasma.start(root, function() end) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /example/ui.client.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Players = game:GetService("Players") 3 | local PlayerGui = Players.LocalPlayer:WaitForChild("PlayerGui") 4 | local RunService = game:GetService("RunService") 5 | local Plasma = require(ReplicatedStorage.Plasma) 6 | 7 | local screenGui = Instance.new("ScreenGui") 8 | screenGui.Name = "Plasma" 9 | screenGui.Parent = PlayerGui 10 | 11 | local root = Plasma.new(screenGui) 12 | 13 | RunService.Heartbeat:Connect(function() 14 | Plasma.start(root, function() 15 | Plasma.window("Hello plasma!", function() 16 | if Plasma.button("Say hello"):clicked() then 17 | print("Hello world!") 18 | end 19 | end) 20 | end) 21 | end) 22 | -------------------------------------------------------------------------------- /src/widgets/label.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Parent.Runtime) 2 | local create = require(script.Parent.Parent.create) 3 | local Style = require(script.Parent.Parent.Style) 4 | local automaticSize = require(script.Parent.Parent.automaticSize) 5 | 6 | --[=[ 7 | @within Plasma 8 | @function label 9 | @param text string 10 | @tag widgets 11 | 12 | Text. 13 | ]=] 14 | return Runtime.widget(function(text) 15 | local refs = Runtime.useInstance(function(ref) 16 | local style = Style.get() 17 | 18 | create("TextLabel", { 19 | [ref] = "label", 20 | BackgroundTransparency = 1, 21 | Font = Enum.Font.SourceSans, 22 | TextColor3 = style.textColor, 23 | TextSize = 20, 24 | RichText = true, 25 | }) 26 | 27 | automaticSize(ref.label) 28 | 29 | return ref.label 30 | end) 31 | 32 | refs.label.Text = text 33 | end) 34 | -------------------------------------------------------------------------------- /src/widgets/portal.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Plasma 3 | @function portal 4 | @tag widgets 5 | @param targetInstance Instance -- Where the portal goes to 6 | @param children () -> () -- Children 7 | 8 | The portal widget creates its children inside the specified `targetInstance`. For example, you could use this 9 | to create lighting effects in Lighting as a widget: 10 | 11 | 12 | ```lua 13 | return function(size) 14 | portal(Lighting, function() 15 | useInstance(function() 16 | local blur = Instance.new("BlurEffect") 17 | blur.Size = size 18 | return blur 19 | end) 20 | end) 21 | end 22 | ``` 23 | ]=] 24 | 25 | local Runtime = require(script.Parent.Parent.Runtime) 26 | 27 | return Runtime.widget(function(targetInstance, fn) 28 | Runtime.useInstance(function() 29 | return nil, targetInstance 30 | end) 31 | 32 | Runtime.scope(fn) 33 | end) 34 | -------------------------------------------------------------------------------- /src/hydrateAutomaticSize.lua: -------------------------------------------------------------------------------- 1 | local CollectionService = game:GetService("CollectionService") 2 | local automaticSize = require(script.Parent.automaticSize) 3 | 4 | --[=[ 5 | Applies automatic sizing to any current or future instances in the DataModel that are tagged with 6 | `"PlasmaAutomaticSize"`. Attributes `axis` (string) and `maxSize` (UDim2 or Vector2) are allowed. 7 | 8 | @within Plasma 9 | @tag utilities 10 | @client 11 | @return RBXScriptConnection 12 | ]=] 13 | local function hydrateAutomaticSize() 14 | for _, instance in CollectionService:GetTagged("PlasmaAutomaticSize") do 15 | automaticSize(instance) 16 | end 17 | 18 | return CollectionService:GetInstanceAddedSignal("PlasmaAutomaticSize"):Connect(function(instance) 19 | task.defer(automaticSize, instance) -- instance added signal fires before children are added 20 | end) 21 | end 22 | 23 | return hydrateAutomaticSize 24 | -------------------------------------------------------------------------------- /stories/window.story.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local Plasma = require(ReplicatedStorage.Plasma) 4 | 5 | return function(target) 6 | local root = Plasma.new(target) 7 | 8 | local movable = true 9 | local closable = true 10 | 11 | local connection = RunService.Heartbeat:Connect(function() 12 | Plasma.start(root, function() 13 | Plasma.window({ 14 | title = "Hello there this is a really long title", 15 | closable = closable, 16 | movable = movable, 17 | minSize = Vector2.new(300, 300), 18 | }, function() 19 | movable = Plasma.checkbox("movable"):checked() 20 | closable = Plasma.checkbox("closable"):checked() 21 | end) 22 | end) 23 | end) 24 | 25 | return function() 26 | connection:Disconnect() 27 | Plasma.start(root, function() end) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plasma", 3 | "servePort": 34873, 4 | 5 | "tree": { 6 | "$className": "DataModel", 7 | "StarterPlayer": { 8 | "$className": "StarterPlayer", 9 | "StarterPlayerScripts": { 10 | "$className": "StarterPlayerScripts", 11 | "$path": "example" 12 | } 13 | }, 14 | "ReplicatedStorage": { 15 | "$className": "ReplicatedStorage", 16 | "$path": "Packages", 17 | "Plasma": { 18 | "$path": "src" 19 | }, 20 | "Stories": { 21 | "$path": "stories" 22 | }, 23 | "Tests": { 24 | "$path": "tests" 25 | } 26 | }, 27 | "TestService": { 28 | "$properties": { 29 | "ExecuteWithStudioRun": true 30 | }, 31 | "$className": "TestService", 32 | "run": { 33 | "$path": "tests.server.lua" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/widgets/heading.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Parent.Runtime) 2 | local create = require(script.Parent.Parent.create) 3 | local Style = require(script.Parent.Parent.Style) 4 | 5 | --[=[ 6 | @within Plasma 7 | @function heading 8 | @param text string 9 | @param options? {font: Font} 10 | @tag widgets 11 | 12 | Text, but bigger! 13 | ]=] 14 | return Runtime.widget(function(text, options) 15 | options = options or {} 16 | local refs = Runtime.useInstance(function(ref) 17 | local style = Style.get() 18 | 19 | return create("TextLabel", { 20 | [ref] = "heading", 21 | BackgroundTransparency = 1, 22 | Font = Enum.Font.GothamBold, 23 | AutomaticSize = Enum.AutomaticSize.XY, 24 | TextColor3 = style.mutedTextColor, 25 | TextSize = 20, 26 | RichText = true, 27 | }) 28 | end) 29 | 30 | local instance = refs.heading 31 | instance.Text = text 32 | instance.Font = options.font or Enum.Font.GothamBold 33 | end) 34 | -------------------------------------------------------------------------------- /stories/checkbox.story.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local Plasma = require(ReplicatedStorage.Plasma) 4 | local window = Plasma.window 5 | local checkbox = Plasma.checkbox 6 | 7 | return function(target) 8 | local root = Plasma.new(target) 9 | 10 | local checked = false 11 | 12 | local connection = RunService.Heartbeat:Connect(function() 13 | Plasma.start(root, function() 14 | window("Checkboxes", function() 15 | if checkbox("Controlled checkbox", { 16 | checked = checked, 17 | }):clicked() then 18 | checked = not checked 19 | end 20 | 21 | checkbox("Disabled checkbox", { 22 | checked = checked, 23 | disabled = true, 24 | }) 25 | 26 | checkbox("Uncontrolled checkbox") 27 | end) 28 | end) 29 | end) 30 | 31 | return function() 32 | connection:Disconnect() 33 | Plasma.start(root, function() end) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /moonwave.toml: -------------------------------------------------------------------------------- 1 | apiCategories = ["hooks", "utilities", "style", "widgets"] 2 | gitSourceBranch = "main" 3 | 4 | [docusaurus] 5 | url = "https://eryn.io" 6 | tagline = "Immediate-mode widget library for Roblox" 7 | 8 | [[navbar.items]] 9 | href = "https://discord.gg/aQwDAYhqtJ" 10 | label = "Roblox Open Source Discord" 11 | position = "right" 12 | 13 | [home] 14 | enabled = true 15 | includeReadme = false 16 | 17 | [[home.features]] 18 | title = "Immediate-mode UI" 19 | description = "Your UI code runs every frame, which makes making UI a breeze" 20 | image = "https://i.eryn.io/2150/carbon3.png" 21 | 22 | [[home.features]] 23 | title = "Built-in widgets" 24 | description = "Built-in widgets make it trivial to create a debug panel for any system in your game" 25 | image = "https://i.eryn.io/2150/checkboxes3.png" 26 | 27 | [[home.features]] 28 | title = "Gizmos" 29 | description = "Easily create debug UI and gizmos in the 3D world for debugging" 30 | image = "https://i.eryn.io/2150/arrows3.png" -------------------------------------------------------------------------------- /docs/styles.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Styles 6 | 7 | Styles are accessed with the `useStyle` and `setStyle` functions. Styles are similar to CSS in that they cascade downwards in the tree. 8 | 9 | Styles are not anything special, they are just configuration. It's up to the widgets to read from the specified styles and use the values. 10 | 11 | ## useStyle 12 | 13 | Returns the current style information, with styles that are set more recently in the tree overriding styles that were set further up. In this way, styles cascade downwards, similar to CSS. 14 | 15 | ## setStyle 16 | 17 | Defines style for any subsequent calls in this scope. Merges with any existing styles. 18 | 19 | ## Default styles 20 | 21 | By default, these styles are used. You can override them anywhere: 22 | 23 | ```lua 24 | { 25 | bg1 = Color3.fromRGB(31, 31, 31), 26 | bg2 = Color3.fromRGB(42, 42, 42), 27 | bg3 = Color3.fromRGB(54, 54, 54), 28 | mutedTextColor = Color3.fromRGB(147, 147, 147), 29 | textColor = Color3.fromRGB(255, 255, 255), 30 | } 31 | ``` -------------------------------------------------------------------------------- /testez.toml: -------------------------------------------------------------------------------- 1 | # TestEZ 2 | [[afterAll.args]] 3 | type = "function" 4 | 5 | [[afterEach.args]] 6 | type = "function" 7 | 8 | [[beforeAll.args]] 9 | type = "function" 10 | 11 | [[beforeEach.args]] 12 | type = "function" 13 | 14 | [[describe.args]] 15 | type = "string" 16 | 17 | [[describe.args]] 18 | type = "function" 19 | 20 | [[describeFOCUS.args]] 21 | type = "string" 22 | 23 | [[describeFOCUS.args]] 24 | type = "function" 25 | 26 | [[describeSKIP.args]] 27 | type = "string" 28 | 29 | [[describeSKIP.args]] 30 | type = "function" 31 | 32 | [[expect.args]] 33 | type = "any" 34 | 35 | [[FIXME.args]] 36 | type = "string" 37 | required = false 38 | 39 | [FOCUS] 40 | args = [] 41 | 42 | [[it.args]] 43 | type = "string" 44 | 45 | [[it.args]] 46 | type = "function" 47 | 48 | [[itFIXME.args]] 49 | type = "string" 50 | 51 | [[itFIXME.args]] 52 | type = "function" 53 | 54 | [[itFOCUS.args]] 55 | type = "string" 56 | 57 | [[itFOCUS.args]] 58 | type = "function" 59 | 60 | [[itSKIP.args]] 61 | type = "string" 62 | 63 | [[itSKIP.args]] 64 | type = "function" 65 | 66 | [SKIP] 67 | args = [] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Eryn L. K. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/plasma.spec.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Plasma = require(ReplicatedStorage.Plasma) 3 | 4 | return function() 5 | describe("plasma", function() 6 | it("should create and destroy things", function() 7 | local folder = Instance.new("Folder") 8 | 9 | local root = Plasma.new(folder) 10 | 11 | Plasma.start(root, function() 12 | Plasma.button("hello") 13 | end) 14 | 15 | expect(folder:FindFirstChildWhichIsA("TextButton")).to.be.ok() 16 | 17 | Plasma.start(root, function() end) 18 | 19 | expect(folder:FindFirstChildWhichIsA("TextButton")).to.never.be.ok() 20 | end) 21 | 22 | it("should create and destroy from a single start point", function() 23 | local folder = Instance.new("Folder") 24 | 25 | local root = Plasma.new(folder) 26 | 27 | local function start(visible) 28 | Plasma.start(root, function() 29 | if visible then 30 | Plasma.button("hello") 31 | end 32 | end) 33 | end 34 | 35 | start(true) 36 | expect(folder:FindFirstChildWhichIsA("TextButton")).to.be.ok() 37 | 38 | start(false) 39 | 40 | expect(folder:FindFirstChildWhichIsA("TextButton")).to.never.be.ok() 41 | end) 42 | end) 43 | end 44 | -------------------------------------------------------------------------------- /src/widgets/spinner.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Plasma 3 | @function spinner 4 | @tag widgets 5 | 6 | A spinner widget, indicating loading. 7 | 8 | ![A spinner](https://i.eryn.io/2150/RobloxStudioBeta-sEyci8qy.png) 9 | ]=] 10 | 11 | local RunService = game:GetService("RunService") 12 | local Runtime = require(script.Parent.Parent.Runtime) 13 | 14 | return Runtime.widget(function() 15 | local refs = Runtime.useInstance(function(ref) 16 | local Frame = Instance.new("Frame") 17 | Frame.BackgroundTransparency = 1 18 | Frame.Size = UDim2.new(0, 100, 0, 100) 19 | 20 | local ImageLabel = Instance.new("ImageLabel") 21 | ImageLabel.AnchorPoint = Vector2.new(0.5, 0.5) 22 | ImageLabel.BackgroundTransparency = 1 23 | ImageLabel.Image = "rbxassetid://2689141406" 24 | ImageLabel.Position = UDim2.new(0.5, 0, 0.5, 0) 25 | ImageLabel.Size = UDim2.new(0, 100, 0, 100) 26 | ImageLabel.Parent = Frame 27 | 28 | ref.frame = Frame 29 | 30 | return Frame 31 | end) 32 | 33 | Runtime.useEffect(function() 34 | local connection = RunService.RenderStepped:Connect(function() 35 | refs.frame.ImageLabel.Rotation = os.clock() * 100 % 360 36 | end) 37 | 38 | return function() 39 | connection:Disconnect() 40 | end 41 | end) 42 | end) 43 | -------------------------------------------------------------------------------- /src/widgets/highlight.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Parent.Runtime) 2 | local create = require(script.Parent.Parent.create) 3 | 4 | --[=[ 5 | @interface HighlightOptions 6 | @within Plasma 7 | 8 | .outlineColor?: Color3 9 | .fillColor?: Color3 10 | .fillTransparency?: number 11 | .outlineTransparency?: number 12 | .fillMode?: HighlightFillMode 13 | ]=] 14 | 15 | --[=[ 16 | @within Plasma 17 | @function highlight 18 | @param adornee Instance 19 | @param options? HighlightOptions 20 | 21 | 22 | Creates a highlight over an instance with the specified options, using the Roblox [Highlight] instance 23 | ]=] 24 | return Runtime.widget(function(adornee, options) 25 | options = options or {} 26 | 27 | local refs = Runtime.useInstance(function(ref) 28 | return create("Highlight", { 29 | [ref] = "highlight", 30 | }) 31 | end) 32 | 33 | refs.highlight.Adornee = adornee 34 | 35 | Runtime.useEffect(function() 36 | refs.highlight.OutlineColor = options.outlineColor or Color3.new(1, 1, 1) 37 | refs.highlight.FillColor = options.fillColor or Color3.new(1, 0, 0) 38 | end, options.fillColor, options.outlineColor) 39 | 40 | refs.highlight.FillTransparency = options.fillTransparency or 0.5 41 | refs.highlight.OutlineTransparency = options.outlineTransparency or 0 42 | refs.highlight.DepthMode = options.depthMode or Enum.HighlightDepthMode.AlwaysOnTop 43 | end) 44 | -------------------------------------------------------------------------------- /src/widgets/row.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Plasma 3 | @function row 4 | @tag widgets 5 | @param options {padding: Vector2} 6 | @param children () -> () -- Children 7 | 8 | Lays out children horizontally 9 | ]=] 10 | 11 | local Runtime = require(script.Parent.Parent.Runtime) 12 | local automaticSize = require(script.Parent.Parent.automaticSize) 13 | 14 | return Runtime.widget(function(options, fn) 15 | if type(options) == "function" and fn == nil then 16 | fn = options 17 | options = {} 18 | end 19 | 20 | if options.padding then 21 | if type(options.padding) == "number" then 22 | options.padding = UDim.new(0, options.padding) 23 | end 24 | else 25 | options.padding = UDim.new(0, 10) 26 | end 27 | 28 | local refs = Runtime.useInstance(function(ref) 29 | local Frame = Instance.new("Frame") 30 | Frame.BackgroundTransparency = 1 31 | 32 | local UIListLayout = Instance.new("UIListLayout") 33 | UIListLayout.SortOrder = Enum.SortOrder.LayoutOrder 34 | UIListLayout.FillDirection = Enum.FillDirection.Horizontal 35 | UIListLayout.Padding = options.padding 36 | UIListLayout.Parent = Frame 37 | 38 | ref.frame = Frame 39 | 40 | automaticSize(Frame) 41 | 42 | return Frame 43 | end) 44 | 45 | local frame = refs.frame 46 | 47 | frame.UIListLayout.HorizontalAlignment = options.alignment or Enum.HorizontalAlignment.Left 48 | 49 | Runtime.scope(fn) 50 | end) 51 | -------------------------------------------------------------------------------- /src/Style.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Runtime) 2 | 3 | local ContextKey = Runtime.createContext("Style") 4 | 5 | local defaultStyle = { 6 | bg1 = Color3.fromRGB(31, 31, 31), 7 | bg2 = Color3.fromRGB(42, 42, 42), 8 | bg3 = Color3.fromRGB(54, 54, 54), 9 | mutedTextColor = Color3.fromRGB(147, 147, 147), 10 | textColor = Color3.fromRGB(255, 255, 255), 11 | } 12 | 13 | local Style = {} 14 | 15 | --[=[ 16 | @within Plasma 17 | @function useStyle 18 | @tag style 19 | 20 | Returns the current style information, with styles that are set more recently in the tree overriding styles that 21 | were set further up. In this way, styles cascade downwards, similar to CSS. 22 | ]=] 23 | function Style.get() 24 | return Runtime.useContext(ContextKey) or defaultStyle 25 | end 26 | 27 | --[=[ 28 | @within Plasma 29 | @function setStyle 30 | @tag style 31 | @param styleFragment {[string]: any} -- A dictionary of style information 32 | 33 | Defines style for any subsequent calls in this scope. Merges with any existing styles. 34 | ]=] 35 | function Style.set(styleFragment) 36 | local existing = Runtime.useContext(ContextKey) or defaultStyle 37 | local newStyle = {} 38 | 39 | for key, value in pairs(existing) do 40 | newStyle[key] = value 41 | end 42 | 43 | for key, value in pairs(styleFragment) do 44 | newStyle[key] = value 45 | end 46 | 47 | Runtime.provideContext(ContextKey, newStyle) 48 | end 49 | 50 | return Style 51 | -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | --- @class Plasma 2 | 3 | local Runtime = require(script.Runtime) 4 | local Style = require(script.Style) 5 | 6 | return { 7 | new = Runtime.new, 8 | start = Runtime.start, 9 | continueFrame = Runtime.continueFrame, 10 | beginFrame = Runtime.beginFrame, 11 | finishFrame = Runtime.finishFrame, 12 | scope = Runtime.scope, 13 | widget = Runtime.widget, 14 | useState = Runtime.useState, 15 | useInstance = Runtime.useInstance, 16 | useEffect = Runtime.useEffect, 17 | useKey = Runtime.useKey, 18 | setEventCallback = Runtime.setEventCallback, 19 | createContext = Runtime.createContext, 20 | useContext = Runtime.useContext, 21 | provideContext = Runtime.provideContext, 22 | 23 | useStyle = Style.get, 24 | setStyle = Style.set, 25 | 26 | automaticSize = require(script.automaticSize), 27 | hydrateAutomaticSize = require(script.hydrateAutomaticSize), 28 | create = require(script.create), 29 | 30 | window = require(script.widgets.window), 31 | button = require(script.widgets.button), 32 | portal = require(script.widgets.portal), 33 | blur = require(script.widgets.blur), 34 | row = require(script.widgets.row), 35 | spinner = require(script.widgets.spinner), 36 | checkbox = require(script.widgets.checkbox), 37 | arrow = require(script.widgets.arrow), 38 | heading = require(script.widgets.heading), 39 | label = require(script.widgets.label), 40 | slider = require(script.widgets.slider), 41 | space = require(script.widgets.space), 42 | table = require(script.widgets.table), 43 | highlight = require(script.widgets.highlight), 44 | } 45 | -------------------------------------------------------------------------------- /src/widgets/error.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Parent.Runtime) 2 | local create = require(script.Parent.Parent.create) 3 | 4 | return Runtime.widget(function(text) 5 | local refs = Runtime.useInstance(function(ref) 6 | return create("Frame", { 7 | [ref] = "error", 8 | BackgroundTransparency = 0, 9 | BackgroundColor3 = Color3.fromRGB(231, 76, 60), 10 | Name = "Error", 11 | Size = UDim2.new(0, 100, 0, 75), 12 | AutomaticSize = Enum.AutomaticSize.XY, 13 | 14 | create("UIPadding", { 15 | PaddingBottom = UDim.new(0, 20), 16 | PaddingLeft = UDim.new(0, 20), 17 | PaddingRight = UDim.new(0, 20), 18 | PaddingTop = UDim.new(0, 20), 19 | }), 20 | 21 | create("UIListLayout", {}), 22 | 23 | create("TextLabel", { 24 | Font = Enum.Font.GothamBold, 25 | BackgroundTransparency = 1, 26 | TextColor3 = Color3.fromRGB(255, 255, 255), 27 | TextSize = 25, 28 | AutomaticSize = Enum.AutomaticSize.X, 29 | Text = "⚠️ An Error Occurred", 30 | Size = UDim2.fromOffset(0, 75), 31 | }), 32 | 33 | create("TextLabel", { 34 | Name = "error", 35 | Font = Enum.Font.GothamMedium, 36 | BackgroundTransparency = 1, 37 | TextColor3 = Color3.fromRGB(255, 255, 255), 38 | TextSize = 20, 39 | LineHeight = 1.2, 40 | AutomaticSize = Enum.AutomaticSize.XY, 41 | Size = UDim2.fromOffset(100, 75), 42 | TextXAlignment = Enum.TextXAlignment.Left, 43 | TextYAlignment = Enum.TextYAlignment.Top, 44 | }), 45 | }) 46 | end) 47 | 48 | local instance = refs.error 49 | 50 | instance.error.Text = text 51 | end) 52 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Getting Started 6 | 7 | Your UI code is intended to run on every frame. To get started with Plasma, the first step is to set up an event loop: 8 | 9 | ```lua 10 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 11 | local Players = game:GetService("Players") 12 | local PlayerGui = Players.LocalPlayer:WaitForChild("PlayerGui") 13 | local RunService = game:GetService("RunService") 14 | local Plasma = require(ReplicatedStorage.Plasma) 15 | 16 | local screenGui = Instance.new("ScreenGui") 17 | screenGui.Name = "Plasma" 18 | screenGui.Parent = PlayerGui 19 | 20 | local root = Plasma.new(screenGui) 21 | 22 | RunService.Heartbeat:Connect(function() 23 | Plasma.start(root, function() 24 | Plasma.window("Hello plasma!", function() 25 | if Plasma.button("Say hello"):clicked() then 26 | print("Hello world!") 27 | end 28 | end) 29 | end) 30 | 31 | end) 32 | ``` 33 | 34 | ![Example](https://i.eryn.io/2150/RobloxStudioBeta-hHIjzTo6.png) 35 | 36 | In the above code sample, we call `Plasma.new`, passing in the root instance where we want our UI to end up, in this case it's a ScreenGui. 37 | 38 | `Plasma.new` returns an object which holds state about our UI. You don't need to interact with this object, just keep it around so we can pass it into Plasma later. 39 | 40 | We create an event connected to Heartbeat, and every heartbeat event, we call `Plasma.start` with our `root` and a function that creates the UI. 41 | 42 | The function we pass to `Plasma.start` cannot yield (doing so will error). Inside, we can create our UI using Plasma's [widgets](/api/Plasma#arrow). 43 | 44 | From here, you should look at the [API reference](/api/Plasma) to check out all the other available widgets! -------------------------------------------------------------------------------- /src/widgets/button.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Plasma 3 | @function button 4 | @tag widgets 5 | @param label string -- The label for the checkbox 6 | @return ButtonWidgetHandle 7 | 8 | A text button. 9 | 10 | Returns a widget handle, which has the field: 11 | 12 | - `clicked`, a function you can call to check if the checkbox was clicked this frame 13 | 14 | ![A button](https://i.eryn.io/2150/RobloxStudioBeta-iwRM0RMx.png) 15 | 16 | ```lua 17 | Plasma.window("Button", function() 18 | if Plasma.button("button text"):clicked() then 19 | print("clicked!") 20 | end 21 | end) 22 | ``` 23 | ]=] 24 | 25 | local Runtime = require(script.Parent.Parent.Runtime) 26 | local Style = require(script.Parent.Parent.Style) 27 | local create = require(script.Parent.Parent.create) 28 | 29 | return Runtime.widget(function(text) 30 | local clicked, setClicked = Runtime.useState(false) 31 | local refs = Runtime.useInstance(function(ref) 32 | local style = Style.get() 33 | 34 | return create("TextButton", { 35 | [ref] = "button", 36 | BackgroundColor3 = style.bg3, 37 | BorderSizePixel = 0, 38 | Font = Enum.Font.SourceSans, 39 | Size = UDim2.new(0, 100, 0, 40), 40 | TextColor3 = style.textColor, 41 | AutomaticSize = Enum.AutomaticSize.X, 42 | TextSize = 21, 43 | 44 | create("UIPadding", { 45 | PaddingLeft = UDim.new(0, 10), 46 | PaddingRight = UDim.new(0, 10), 47 | }), 48 | 49 | create("UICorner"), 50 | 51 | Activated = function() 52 | setClicked(true) 53 | end, 54 | }) 55 | end) 56 | 57 | local instance = refs.button 58 | 59 | instance.Text = text 60 | 61 | local handle = { 62 | clicked = function() 63 | if clicked then 64 | setClicked(false) 65 | return true 66 | end 67 | 68 | return false 69 | end, 70 | } 71 | 72 | return handle 73 | end) 74 | -------------------------------------------------------------------------------- /docs/details.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Usage details 6 | 7 | ## Hooks 8 | 9 | Hooks are topologically-aware functions. This means that when you use a hook like `useState` or `useEffect`, they are aware of the call stack and hold their own state outside of arguments and return values. 10 | 11 | Their state is actually keyed by your script name and the line number you call them on! This state is kept in the enclosing *scope*. Scopes are created every time you use a new widget or create children inside of a widget. You can also create a new scope manually with the `Plasma.scope` function. 12 | 13 | Even if code runs multiple times in a single frame, state will always be separated by scope. This means you can use the same widget multiple times in a single frame, and their state will be separate because they are all inside of separate scopes. 14 | 15 | If called in a loop, hooks hold state by number of times that line was called consecutively. 16 | 17 | Check out the [API reference](/api#useContext) to learn more. 18 | 19 | ## Automatic layout 20 | 21 | Widgets are automatically laid out with Roblox's Layout objects. This means you don't need to worry about where UI elements go, they just go after whatever came before them. 22 | 23 | By default, widgets are laid out vertically. You can lay widgets out horizontally by using `Plasma.row(function() end)` and creating widgets inside the children function to lay widgets out to the side. 24 | 25 | In the future, there will be more widgets that allow users to customize layout more easily. 26 | 27 | Plasma automatically sets the `LayoutOrder` property of children widgets to the correct value. 28 | 29 | ## Error reporting 30 | 31 | By default, errors that occur during layout are reported visually in the UI, bounded at the scope level. 32 | 33 | ![Example of error](https://i.eryn.io/2150/n1AsMbhS.png) 34 | 35 | Since your code runs every frame, errors that happen every frame can fill up the output quickly. To mitigate this, Plasma will only allow repeated errors to be reported in the output once every 10 seconds: 36 | 37 | ![Example of output](https://i.eryn.io/2150/xmBJqQDQ.png) -------------------------------------------------------------------------------- /src/create.lua: -------------------------------------------------------------------------------- 1 | local Runtime = require(script.Parent.Runtime) 2 | 3 | --[=[ 4 | @within Plasma 5 | @function create 6 | @param className string -- The class name of the Instance to create 7 | @param props CreateProps 8 | @return Instance -- The created instance 9 | @tag utilities 10 | 11 | A function that creates an Instance tree. 12 | 13 | CreateProps is a table: 14 | - String keys are interpreted as properties to set 15 | - Numerical keys are interpreted as children 16 | - Function values are interpreted as event handlers 17 | - Table keys can be used to get references to instances deep in the tree, the value becomes the key in the table 18 | 19 | This function doesn't do anything special. It just creates an instance. 20 | 21 | ```lua 22 | create("Frame", { 23 | BackgroundTransparency = 1, 24 | Name = "Checkbox", 25 | 26 | create("TextButton", { 27 | BackgroundColor3 = Color3.fromRGB(54, 54, 54), 28 | Size = UDim2.new(0, 30, 0, 30), 29 | 30 | create("UICorner", { 31 | CornerRadius = UDim.new(0, 8), 32 | }), 33 | 34 | Activated = function() 35 | setClicked(true) 36 | end, 37 | }), 38 | }) 39 | ``` 40 | 41 | Getting references to instances deep in a tree: 42 | 43 | ```lua 44 | local ref = {} 45 | 46 | create("Frame", { 47 | create("TextButton", { 48 | [ref] = "button", 49 | Text = "hi" 50 | }) 51 | }) 52 | 53 | print(ref.button.Text) --> hi 54 | ``` 55 | ]=] 56 | local function create(className, props) 57 | props = props or {} 58 | 59 | local eventCallback = Runtime.useEventCallback() 60 | 61 | local instance = Instance.new(className) 62 | 63 | for key, value in pairs(props) do 64 | if type(value) == "function" then 65 | if eventCallback then 66 | eventCallback(instance, key, value) 67 | else 68 | instance[key]:Connect(value) 69 | end 70 | elseif type(key) == "number" then 71 | value.Parent = instance 72 | elseif type(key) == "table" then 73 | key[value] = instance 74 | 75 | if props.Name == nil then 76 | instance.Name = value 77 | end 78 | else 79 | instance[key] = value 80 | end 81 | end 82 | 83 | return instance 84 | end 85 | 86 | return create 87 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## UNRELEASED 4 | ### Added 5 | - The context functions are now exposed in `init.lua` 6 | 7 | ### Fixed 8 | - automaticSize helper should now set minSize attribute correctly 9 | 10 | ## [0.4.2] - 2022-07-10 11 | ### Fixed 12 | - Fixed Slider not firing event callback for drag connection (Fixes using slider on the server in Matter debugger) 13 | 14 | ## [0.4.1] - 2022-07-08 15 | ### Added 16 | - Added highlight widget 17 | - table now supports a `hovered` method in its handle 18 | 19 | ## [0.4.0] - 2022-07-05 20 | ### Added 21 | - Table widget 22 | - Added font option to heading 23 | 24 | ### Changed 25 | - `useInstance` no longer returns the root instance. Instead, it returns a `ref` table, which is also created and passed to the `useInstance` function itself as a first parameter. You can create keys in the table to access instances in the widget body. 26 | - The `create` function now has special behavior if a key is passed in the props table. It will set a value in the table to the instance that's being created at the key specified by the value in the props table. E.g., `[ref] = "button"` will set the key "button" in the `ref` table to the button that was created. 27 | - `automaticSize` now covers more cases, including text objects and objects with no UIGridStyleLayout within them. 28 | - The window widget is now draggable, resizable, and closable. 29 | - Slider now supports min and initial value settings 30 | - label, checkbox, heading, table, window now support RichText 31 | - Buttons are appropriately sized to their text contents 32 | 33 | ## [0.3.0] - 2022-07-01 34 | ### Added 35 | - Added `hydrateAutomaticSize` function 36 | ### Changed 37 | - `automaticSize` no longer applies automatic sizing on the server. Instead, it configures the instance to be compatible with `hydrateAutomaticSize` from the client. 38 | - `automaticSize` now accepts a UDim2 as a `maxSize` for use with Scale 39 | ### Fixed 40 | - Fixed `automaticSize` with scrolling frames sometimes causing an infinite content reflow loop. 41 | 42 | ## [0.2.0] - 2022-06-30 43 | ### Added 44 | - Added `useKey` 45 | - Added heading, label, slider, and space widgets 46 | - Add multi-phase frame API with `beginFrame`, `continueFrame`, and `finishFrame` 47 | - Add event callback injection 48 | ### Fixed 49 | - Widget state now resets if the widget in the slot changed from last frame 50 | 51 | ## [0.1.0] - 2021-12-13 52 | - Initial release 53 | -------------------------------------------------------------------------------- /src/widgets/slider.lua: -------------------------------------------------------------------------------- 1 | local UserInputService = game:GetService("UserInputService") 2 | 3 | local Runtime = require(script.Parent.Parent.Runtime) 4 | local Style = require(script.Parent.Parent.Style) 5 | local create = require(script.Parent.Parent.create) 6 | local createConnect = require(script.Parent.Parent.createConnect) 7 | 8 | return Runtime.widget(function(options) 9 | if type(options) == "number" then 10 | options = { 11 | max = options, 12 | } 13 | end 14 | 15 | local min = options.min or 0 16 | local max = options.max or 1 17 | local value, setValue = Runtime.useState(options.initial or 0) 18 | 19 | local refs = Runtime.useInstance(function(ref) 20 | local connect = createConnect() 21 | 22 | local style = Style.get() 23 | 24 | local connection 25 | 26 | local frame = create("Frame", { 27 | [ref] = "frame", 28 | BackgroundTransparency = 1, 29 | Size = UDim2.new(0, 200, 0, 30), 30 | 31 | create("Frame", { 32 | Name = "line", 33 | Size = UDim2.new(1, 0, 0, 2), 34 | BackgroundColor3 = style.mutedTextColor, 35 | BorderSizePixel = 0, 36 | Position = UDim2.new(0, 0, 0.5, 0), 37 | }), 38 | 39 | create("TextButton", { 40 | Name = "dot", 41 | [ref] = "dot", 42 | Size = UDim2.new(0, 15, 0, 15), 43 | AnchorPoint = Vector2.new(0.5, 0.5), 44 | BackgroundColor3 = style.textColor, 45 | Position = UDim2.new(0, 0, 0.5, 0), 46 | Text = "", 47 | 48 | create("UICorner", { 49 | CornerRadius = UDim.new(1, 0), 50 | }), 51 | 52 | InputBegan = function(input) 53 | if input.UserInputType ~= Enum.UserInputType.MouseButton1 then 54 | return 55 | end 56 | 57 | if connection then 58 | connection:Disconnect() 59 | end 60 | 61 | connection = connect(UserInputService, "InputChanged", function(moveInput) 62 | if moveInput.UserInputType ~= Enum.UserInputType.MouseMovement then 63 | return 64 | end 65 | 66 | local x = moveInput.Position.X 67 | 68 | local maxPos = ref.frame.AbsoluteSize.X - ref.dot.AbsoluteSize.X 69 | x -= ref.frame.AbsolutePosition.X + ref.dot.AbsoluteSize.X / 2 70 | x = math.clamp(x, 0, maxPos) 71 | 72 | local percent = x / maxPos 73 | 74 | setValue(percent * (max - min) + min) 75 | end) 76 | end, 77 | 78 | InputEnded = function(input) 79 | if input.UserInputType ~= Enum.UserInputType.MouseButton1 then 80 | return 81 | end 82 | 83 | if connection then 84 | connection:Disconnect() 85 | connection = nil 86 | end 87 | end, 88 | }), 89 | }) 90 | 91 | return frame 92 | end) 93 | 94 | local maxPos = refs.frame.AbsoluteSize.X - refs.frame.dot.AbsoluteSize.X 95 | local percent = (value - min) / (max - min) 96 | refs.frame.dot.Position = UDim2.new(0, percent * maxPos + refs.frame.dot.AbsoluteSize.X / 2, 0.5, 0) 97 | 98 | return value 99 | end) 100 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Plasma 6 | 7 | Plasma is a declarative, immediate mode UI widget library for Roblox. 8 | 9 | :::danger Still baking! 10 | Plasma isn't quite ready for production yet. There might be bugs and missing features! 11 | ::: 12 | 13 | Plasma is intended to be used for debug UI, similar to [egui](https://github.com/emilk/egui) and [Dear ImGui](https://github.com/ocornut/imgui). While there's nothing stopping you from using it for your game's main UI, something like [Roact](https://github.com/Roblox/roact) might be a better fit. 14 | 15 | ## What is immediate mode? 16 | 17 | Plasma is an *immediate mode* UI library, as opposed to *retained mode*. 18 | 19 | In a retained mode model, you might make a button and connect a clicked event, with code that is invoked when the event happens. The button is *retained* in the DataModel, and to change the text on it you need to store a reference to it. 20 | 21 | But under an immediate mode model, you show the button and check if it's been clicked immediately, and you do that every single frame (60 times per second). There's no need for a clicked event or to store a reference to the button. 22 | 23 | As another example, let's say you had a window that you only wanted to be shown when it was visible. In retained mode, you would create the window, and store a reference to the window. When the button to toggle visibility is toggled, you use the reference to the window to make it visible or not. 24 | 25 | In immediate mode, it's much simpler: you just check if the window should be rendered with an if statement, and render the window inside the if statement. That's it: if the window wasn't supposed to be rendered, you just never call the code to render the window. 26 | 27 | ## Advantages 28 | 29 | The main advantage of immediate mode is that code becomes vastly simpler: 30 | 31 | - You never need to have any on-click handlers and callbacks that disrupts your code flow. 32 | - You don't have to worry about a lingering callback calling something that is gone. 33 | - Your GUI code can easily live in a simple function (no need for an object just for the UI). 34 | - You don't have to worry about world and GUI state being out-of-sync (i.e. the GUI showing something outdated), because the GUI isn't storing any state - it is showing the latest state immediately. 35 | 36 | In other words, a whole lot of code, complexity and bugs are gone, and you can focus your time on something more interesting than writing GUI code. 37 | 38 | ## Performance 39 | 40 | Your UI code runs every frame, but we only make changes to the DataModel as needed. If you created a window, button, and checkbox in the exact same place last frame, we just do nothing this frame. We only make changes to the DataModel when something ends up being different than the last frame. 41 | 42 | This means that if your UI is not any different than it was last time, the only overhead you have is actually calling the functions to create the UI. It's not *free*, and using immediate-mode UI can end up using more CPU time than a retained mode UI, but it's also shouldn't be a significant enough of an overhead to cause problems. Computers are pretty fast. 43 | -------------------------------------------------------------------------------- /src/widgets/arrow.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Plasma 3 | @function arrow 4 | @tag widgets 5 | @param from Vector3 | CFrame | BasePart 6 | @param to Vector3 | BasePart | nil 7 | @param color Color3? -- Optional color. Random if not specified. 8 | 9 | - `arrow(from: Vector3, to: Vector3)` -> Creates an arrow between `from` and `to` 10 | - `arrow(point: Vector3)` -> Creates an arrow pointing at `point` 11 | - `arrow(cframe: CFrame)` -> Creates an arrow with its point at the CFrame position facing the CFrame LookVector 12 | - `arrow(part: BasePart)` -> Arrow represents the Part's CFrame 13 | - `arrow(fromPart: BasePart, toPart: BasePart)` -> Arrow between the two parts 14 | 15 | ![Arrows](https://i.eryn.io/2150/arrows.png) 16 | 17 | ```lua 18 | Plasma.arrow(Vector3.new(0, 0, 0)) 19 | Plasma.arrow(Vector3.new(5, 5, 5), Vector3.new(10, 10, 10)) 20 | ``` 21 | ]=] 22 | 23 | local function arrow(name, container, scale, color, zindex) 24 | local body = Instance.new("CylinderHandleAdornment") 25 | 26 | body.Name = name .. "Body" 27 | body.Color3 = color 28 | body.Radius = 0.15 29 | body.Adornee = workspace.Terrain 30 | body.Transparency = 0 31 | body.Radius = 0.15 * scale 32 | body.Transparency = 0 33 | body.AlwaysOnTop = true 34 | body.ZIndex = zindex 35 | 36 | body.Parent = container 37 | 38 | local point = Instance.new("ConeHandleAdornment") 39 | 40 | scale = scale == 1 and 1 or 1.4 41 | 42 | point.Name = name .. "Point" 43 | point.Color3 = color 44 | point.Radius = 0.5 * scale 45 | point.Transparency = 0 46 | point.Adornee = workspace.Terrain 47 | point.Height = 2 * scale 48 | point.AlwaysOnTop = true 49 | point.ZIndex = zindex 50 | 51 | point.Parent = container 52 | end 53 | 54 | local function update(body, point, from, to, scale) 55 | body.Height = (from - to).magnitude - 2 56 | body.CFrame = CFrame.lookAt(((from + to) / 2) - ((to - from).unit * 1), to) 57 | point.CFrame = CFrame.lookAt((CFrame.lookAt(to, from) * CFrame.new(0, 0, -2 - ((scale - 1) / 2))).p, to) 58 | end 59 | 60 | local Runtime = require(script.Parent.Parent.Runtime) 61 | 62 | return Runtime.widget(function(from, to, color) 63 | local fallbackColor = Runtime.useState(BrickColor.random().Color) 64 | color = color or fallbackColor 65 | 66 | if typeof(from) == "Instance" then 67 | if from:IsA("BasePart") then 68 | from = from.CFrame 69 | elseif from:IsA("Attachment") then 70 | from = from.WorldCFrame 71 | end 72 | 73 | if to ~= nil then 74 | from = from.p 75 | end 76 | end 77 | 78 | if typeof(to) == "Instance" then 79 | if to:IsA("BasePart") then 80 | to = to.Position 81 | elseif to:IsA("Attachment") then 82 | to = to.WorldPosition 83 | end 84 | end 85 | 86 | if typeof(from) == "CFrame" and to == nil then 87 | local look = from.lookVector 88 | to = from.p 89 | from = to + (look * -10) 90 | end 91 | 92 | if to == nil then 93 | to = from 94 | from = to + Vector3.new(0, 10, 0) 95 | end 96 | 97 | assert(typeof(from) == "Vector3" and typeof(to) == "Vector3", "Passed parameters are of invalid types") 98 | 99 | local refs = Runtime.useInstance(function(ref) 100 | local container = Instance.new("Folder") 101 | container.Name = "Arrow" 102 | 103 | ref.folder = container 104 | 105 | arrow("front", container, 1, color, 1) 106 | arrow("back", container, 2, Color3.new(0, 0, 0), -1) 107 | 108 | return container 109 | end) 110 | 111 | local folder = refs.folder 112 | 113 | update(folder.frontBody, folder.frontPoint, from, to, 1) 114 | update(folder.backBody, folder.backPoint, from, to, 1.4) 115 | 116 | folder.frontBody.Color3 = color 117 | folder.frontPoint.Color3 = color 118 | end) 119 | -------------------------------------------------------------------------------- /src/widgets/checkbox.lua: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Plasma 3 | @function checkbox 4 | @tag widgets 5 | @param label string -- The label for the checkbox 6 | @param options {disabled: boolean, checked: boolean} 7 | @return CheckboxWidgetHandle 8 | 9 | A checkbox. A checkbox may either be controlled or uncontrolled. 10 | 11 | By passing the `checked` field in `options`, you make the checkbox controlled. Controlling the checkbox means that 12 | the checked state is controlled by your code. Otherwise, the controlled state is controlled by the widget itself. 13 | 14 | Returns a widget handle, which has the fields: 15 | 16 | - `checked`, a function you can call to check if the checkbox is checked 17 | - `clicked`, a function you can call to check if the checkbox was clicked this frame 18 | 19 | ![Checkboxes](https://i.eryn.io/2150/9Yg31gc8.png) 20 | 21 | ```lua 22 | Plasma.window("Checkboxes", function() 23 | if Plasma.checkbox("Controlled checkbox", { 24 | checked = checked, 25 | }):clicked() then 26 | checked = not checked 27 | end 28 | 29 | Plasma.checkbox("Disabled checkbox", { 30 | checked = checked, 31 | disabled = true, 32 | }) 33 | 34 | Plasma.checkbox("Uncontrolled checkbox") 35 | end) 36 | ``` 37 | ]=] 38 | 39 | local Runtime = require(script.Parent.Parent.Runtime) 40 | local create = require(script.Parent.Parent.create) 41 | 42 | return Runtime.widget(function(text, options) 43 | options = options or {} 44 | 45 | local checked, setChecked = Runtime.useState(false) 46 | local clicked, setClicked = Runtime.useState(false) 47 | 48 | local refs = Runtime.useInstance(function(ref) 49 | local Checkbox = create("Frame", { 50 | [ref] = "checkbox", 51 | BackgroundTransparency = 1, 52 | Name = "Checkbox", 53 | Size = UDim2.new(0, 30, 0, 30), 54 | AutomaticSize = Enum.AutomaticSize.X, 55 | 56 | create("TextButton", { 57 | BackgroundColor3 = Color3.fromRGB(54, 54, 54), 58 | BorderSizePixel = 0, 59 | Font = Enum.Font.SourceSansBold, 60 | Size = UDim2.new(0, 30, 0, 30), 61 | TextColor3 = Color3.fromRGB(153, 153, 153), 62 | TextSize = 24, 63 | 64 | create("UICorner", { 65 | CornerRadius = UDim.new(0, 8), 66 | }), 67 | 68 | Activated = function() 69 | setClicked(true) 70 | setChecked(function(currentlyChecked) 71 | return not currentlyChecked 72 | end) 73 | end, 74 | }), 75 | 76 | create("TextLabel", { 77 | BackgroundColor3 = Color3.fromRGB(255, 255, 255), 78 | Font = Enum.Font.GothamMedium, 79 | TextColor3 = Color3.fromRGB(203, 203, 203), 80 | TextSize = 18, 81 | AutomaticSize = Enum.AutomaticSize.X, 82 | RichText = true, 83 | }), 84 | 85 | create("UIListLayout", { 86 | FillDirection = Enum.FillDirection.Horizontal, 87 | Padding = UDim.new(0, 10), 88 | SortOrder = Enum.SortOrder.LayoutOrder, 89 | VerticalAlignment = Enum.VerticalAlignment.Center, 90 | }), 91 | }) 92 | 93 | return Checkbox 94 | end) 95 | 96 | local instance = refs.checkbox 97 | 98 | instance.TextLabel.Text = text 99 | instance.TextButton.AutoButtonColor = not options.disabled 100 | 101 | Runtime.useEffect(function() 102 | local isChecked 103 | if options.checked ~= nil then 104 | isChecked = options.checked 105 | else 106 | isChecked = checked 107 | end 108 | 109 | instance.TextButton.Text = isChecked and "✓" or "" 110 | end, options.checked, checked) 111 | 112 | Runtime.useEffect(function() 113 | instance.TextButton.BackgroundColor3 = options.disabled and Color3.fromRGB(112, 112, 112) 114 | or Color3.fromRGB(54, 54, 54) 115 | end, options.disabled) 116 | 117 | local handle = { 118 | checked = function() 119 | if options.checked or checked then 120 | return true 121 | end 122 | 123 | return false 124 | end, 125 | clicked = function() 126 | if clicked then 127 | setClicked(false) 128 | return true 129 | end 130 | 131 | return false 132 | end, 133 | } 134 | 135 | return handle 136 | end) 137 | -------------------------------------------------------------------------------- /src/widgets/table.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local Runtime = require(script.Parent.Parent.Runtime) 3 | local Style = require(script.Parent.Parent.Style) 4 | local create = require(script.Parent.Parent.create) 5 | local automaticSize = require(script.Parent.Parent.automaticSize) 6 | 7 | local cell = Runtime.widget(function(text, font) 8 | local refs = Runtime.useInstance(function(ref) 9 | local style = Style.get() 10 | 11 | return create("TextLabel", { 12 | [ref] = "label", 13 | BackgroundTransparency = 1, 14 | Font = Enum.Font.SourceSans, 15 | AutomaticSize = Enum.AutomaticSize.XY, 16 | TextColor3 = style.textColor, 17 | TextSize = 20, 18 | TextXAlignment = Enum.TextXAlignment.Left, 19 | RichText = true, 20 | 21 | create("UIPadding", { 22 | PaddingBottom = UDim.new(0, 8), 23 | PaddingLeft = UDim.new(0, 8), 24 | PaddingRight = UDim.new(0, 8), 25 | PaddingTop = UDim.new(0, 8), 26 | }), 27 | }) 28 | end) 29 | 30 | refs.label.Font = font or Enum.Font.SourceSans 31 | refs.label.Text = text 32 | end) 33 | 34 | local row = Runtime.widget(function(columns, darken, selectable, font) 35 | local clicked, setClicked = Runtime.useState(false) 36 | local hovering, setHovering = Runtime.useState(false) 37 | 38 | local selected = columns.selected 39 | 40 | local refs = Runtime.useInstance(function(ref) 41 | return create("TextButton", { 42 | [ref] = "row", 43 | BackgroundTransparency = if darken then 0.7 else 1, 44 | BackgroundColor3 = Color3.fromRGB(0, 0, 0), 45 | AutoButtonColor = false, 46 | Text = "", 47 | Active = false, 48 | 49 | MouseEnter = function() 50 | setHovering(true) 51 | end, 52 | 53 | MouseLeave = function() 54 | setHovering(false) 55 | end, 56 | 57 | Activated = function() 58 | setClicked(true) 59 | end, 60 | }) 61 | end) 62 | 63 | refs.row.Active = selectable and not selected or false 64 | 65 | local transparency = 1 66 | 67 | if selected then 68 | transparency = 0 69 | elseif hovering and selectable then 70 | transparency = 0.4 71 | elseif darken then 72 | transparency = 0.7 73 | end 74 | 75 | refs.row.BackgroundTransparency = transparency 76 | refs.row.BackgroundColor3 = selected and Color3.fromHex("bd515c") or Color3.fromRGB(0, 0, 0) 77 | 78 | for _, column in ipairs(columns) do 79 | if type(column) == "function" then 80 | Runtime.scope(column) 81 | else 82 | cell(column, font) 83 | end 84 | end 85 | 86 | return { 87 | clicked = function() 88 | if clicked then 89 | setClicked(false) 90 | return true 91 | end 92 | return false 93 | end, 94 | hovered = function() 95 | return hovering 96 | end, 97 | } 98 | end) 99 | 100 | --[=[ 101 | @within Plasma 102 | @function table 103 | @param items {{string}} 104 | @param options {marginTop?: number, selectable?: boolean, font?: Font, headings?: boolean} 105 | @tag widgets 106 | 107 | A table widget. Items is a list of rows, with each row being a list of cells. 108 | 109 | ```lua 110 | local items = { 111 | {"cell one", "cell two"}, 112 | {"cell three", "cell four"} 113 | } 114 | ``` 115 | 116 | ![Table](https://i.eryn.io/2227/NEc4Dmnv.png) 117 | ]=] 118 | return Runtime.widget(function(items, options) 119 | options = options or {} 120 | 121 | Runtime.useInstance(function(ref) 122 | create("Frame", { 123 | [ref] = "table", 124 | BackgroundTransparency = 1, 125 | Position = UDim2.new(0, 0, 0, options.marginTop or 0), 126 | 127 | create("UITableLayout", { 128 | [ref] = "layout", 129 | SortOrder = Enum.SortOrder.LayoutOrder, 130 | }), 131 | }) 132 | 133 | local connection 134 | 135 | connection = ref.table:GetPropertyChangedSignal("Parent"):Connect(function() 136 | connection:Disconnect() 137 | connection = nil 138 | 139 | RunService.Heartbeat:Wait() 140 | RunService.Heartbeat:Wait() 141 | 142 | -- Wtf roblox 143 | 144 | for _, child in ref.table:GetChildren() do 145 | if child:IsA("GuiObject") then 146 | child.Visible = false 147 | end 148 | end 149 | 150 | local _ = ref.layout.AbsoluteContentSize 151 | 152 | for _, child in ref.table:GetChildren() do 153 | if child:IsA("GuiObject") then 154 | child.Visible = true 155 | end 156 | end 157 | end) 158 | 159 | automaticSize(ref.table) 160 | 161 | return ref.table 162 | end) 163 | 164 | local selected, setSelected = Runtime.useState() 165 | local hovered 166 | 167 | for i, columns in items do 168 | local selectable = options.selectable 169 | local font = options.font 170 | 171 | if options.headings and i == 1 then 172 | selectable = false 173 | font = Enum.Font.GothamBold 174 | end 175 | 176 | local currentRow = row(columns, i % 2 == 1, selectable, font) 177 | 178 | if currentRow:clicked() then 179 | setSelected(columns) 180 | end 181 | 182 | if currentRow:hovered() then 183 | hovered = columns 184 | end 185 | end 186 | 187 | return { 188 | selected = function() 189 | if selected then 190 | setSelected(nil) 191 | return selected 192 | end 193 | end, 194 | hovered = function() 195 | return hovered 196 | end, 197 | } 198 | end) 199 | -------------------------------------------------------------------------------- /docs/creating-widgets.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Creating Custom Widgets 6 | 7 | Creating custom widgets is easy. Widgets in Plasma are just functions! 8 | 9 | To create a widget, pass a function to the `widget` function. 10 | 11 | ```lua title="myButton.lua" 12 | local Plasma = require(ReplicatedStorage.Plasma) 13 | 14 | return Plasma.widget(function(text, color) 15 | local refs = Plasma.useInstance(function(ref) 16 | -- Code here only runs one time to create the widget. 17 | -- Only set properties here that DO NOT depend on arguments. 18 | 19 | return Plasma.create("TextButton", { 20 | [ref] = "button", 21 | Font = Enum.Font.GothamBold, 22 | TextColor3 = Color3.fromRGB(147, 147, 147), 23 | BackgroundColor3 = Color3.fromRGB(54, 54, 54), 24 | TextSize = 20, 25 | Size = UDim2.new(0, 0, 0, 30), 26 | AutomaticSize = Enum.AutomaticSize.XY, 27 | 28 | -- We can create children here as well 29 | Plasma.create("UIPadding", { 30 | PaddingBottom = UDim.new(0, 10), 31 | PaddingLeft = UDim.new(0, 20), 32 | PaddingRight = UDim.new(0, 20), 33 | PaddingTop = UDim.new(0, 10), 34 | }), 35 | 36 | Plasma.create("UICorner", { 37 | CornerRadius = UDim.new(0, 8), 38 | }), 39 | }) 40 | end) 41 | 42 | -- In the main body of the function, we set the properties that do depend on arguments. 43 | 44 | refs.button.Text = text 45 | refs.button.TextColor3 = color 46 | end) 47 | ``` 48 | 49 | In the above code snippet, we use the [`useInstance`](/api/Plasma#useInstance) hook, which takes a callback that is used to create the initial UI for the widget. The callback is only ever invoked on the first time this widget runs and never again. 50 | 51 | The function you pass in to `useInstance` is passed a blank table. We called it `ref` in the example above. When you use it as a table key to the `Plasma.create` function, a reference to the instance you're creating is created in the table, at the key you specify. 52 | 53 | In the example above, the code `[ref] = "button"` means that the key "button" in the `ref` table will be set to the TextButton. 54 | 55 | ## Only updating properties when necessary with useEffect 56 | Typically, setting properties every frame is not that expensive of an operation, but if you only wanted to set `Text` and `TextColor3` when their arguments actually changed, we can use the [`useEffect`](/api/Plasma#useEffect) hook: 57 | 58 | ```lua 59 | Plasma.useEffect(function() 60 | label.Text = text 61 | label.TextColor3 = color 62 | end, text, color) 63 | ``` 64 | 65 | Now, this code will only ever be invoked if `text` or `color` actually changes from the last run. 66 | 67 | ## Persistent state with the useState hook 68 | 69 | Let's make a counter button! The button's text should increase by 1 every time we click it. 70 | 71 | ```lua title="myButton.lua" 72 | local Plasma = require(ReplicatedStorage.Plasma) 73 | 74 | return Plasma.widget(function(text, color) 75 | local times, setTimes = Plasma.useState(0) -- new! 76 | 77 | local refs = Plasma.useInstance(function(ref) 78 | return Plasma.create("TextButton", { 79 | -- snip -- 80 | 81 | Activated = function() -- new! 82 | setTimes(function(last) 83 | return last + 1 84 | end) 85 | end, 86 | }) 87 | end) 88 | 89 | 90 | refs.button.Text = text .. " " .. times -- new! 91 | end) 92 | ``` 93 | (Extraneous lines have been removed from the above example) 94 | 95 | Now, every time the user clicks this button, it'll concatenate the text they passed in with the number of times the button's been pressed. 96 | 97 | ![Button presses](https://i.eryn.io/2150/RobloxStudioBeta-sNsoBtKL.png) 98 | 99 | :::info A note on useState with useInstance and useEffect 100 | Notice that we pass a function to `setTimes`. What would have happened if we just wrote `setTimes(times + 1)` instead? 101 | 102 | **It would only go to 1**! This is because (as we mentioned above), the code inside `useInstance` only ever runs once, when the widget is created. This means that the `times` variable the `useInstance` closure captured is always going to be `0`. 103 | 104 | In the main scope of the widget function, `times` is what you expect, because it does run every frame. But inside `useInstance` or `useEffect` functions, `times` is always going to be what it was when those functions ran. 105 | 106 | That's why the set callback (`setTimes`) can be given a function, which is invoked immediately with the *current* value of `times`. Problem solved! 107 | ::: 108 | 109 | ## Getting information out of widgets 110 | 111 | Let's say you want your users to be able to see how many times your button was clicked in their code when they use your button widget. 112 | 113 | You already know how to do this: just return it! 114 | 115 | At the bottom of your widget, just: 116 | 117 | ```lua 118 | return { 119 | times = times 120 | } 121 | ``` 122 | 123 | :::tip 124 | Returning a table with named values instead of returning a value directly is recommended, because it allows you to add more return values in the future without breaking your API interface. 125 | ::: 126 | 127 | Then, when you use your button widget, you can just check it! 128 | 129 | ```lua title="Using your widget" 130 | Plasma.start(root, function() 131 | Plasma.window("Button", function() 132 | local timesClicked = myButton("hi", Color3.fromRGB(255, 153, 0)).times 133 | 134 | if timesClicked > 50 then 135 | Plasma.label("You clicked them all!") 136 | end 137 | end) 138 | end) 139 | ``` 140 | 141 | ## Nested widgets 142 | 143 | You can use widgets inside of other widgets. 144 | 145 | For example, you could blur the world only if the number of times clicked is even: 146 | 147 | ```lua 148 | if times % 2 == 0 then 149 | Plasma.blur(20) 150 | end 151 | ``` 152 | 153 | ## Automatic size 154 | 155 | Roblox has an automatic size property of GuiObjects, but it doesn't always work correctly, especially with padding. 156 | 157 | Plasma comes with an alternative automatic sizing function: [`automaticSize`](/api/Plasma#automaticSize). 158 | 159 | To use it, just call `automaticSize` inside your `useInstance` function, passing in your root frame. -------------------------------------------------------------------------------- /src/automaticSize.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local CollectionService = game:GetService("CollectionService") 3 | local function applyLayout(container, layout) 4 | local axisName = container:GetAttribute("axis") or "XY" 5 | local axis = Enum.AutomaticSize[axisName] 6 | 7 | local maxSize = container:GetAttribute("maxSize") or Vector2.new(math.huge, math.huge) 8 | local minSize = container:GetAttribute("minSize") or Vector2.new(0, 0) 9 | 10 | if typeof(maxSize) == "UDim2" then 11 | if container.Parent == nil then 12 | maxSize = Vector2.new(0, 0) 13 | else 14 | local parentSize = container.Parent.AbsoluteSize 15 | 16 | maxSize = Vector2.new( 17 | (parentSize.X / maxSize.X.Scale) + maxSize.X.Offset, 18 | (parentSize.Y / maxSize.Y.Scale) + maxSize.Y.Offset 19 | ) 20 | end 21 | end 22 | 23 | local padX = 0 24 | local padY = 0 25 | local padding = container:FindFirstChildOfClass("UIPadding") 26 | if padding ~= nil then 27 | padX = padding.PaddingLeft.Offset + padding.PaddingRight.Offset 28 | padY = padding.PaddingTop.Offset + padding.PaddingBottom.Offset 29 | end 30 | 31 | local contentSize 32 | 33 | if layout then 34 | contentSize = layout.AbsoluteContentSize 35 | elseif container:IsA("TextButton") or container:IsA("TextLabel") then 36 | contentSize = container.TextBounds 37 | else 38 | contentSize = Vector2.new(0, 0) 39 | 40 | for _, child in container:GetChildren() do 41 | if child:IsA("GuiObject") then 42 | local farX = child.Position.X.Offset + child.Size.X.Offset 43 | local farY = child.Position.Y.Offset + child.Size.Y.Offset 44 | 45 | contentSize = Vector2.new(math.max(contentSize.X, farX), math.max(contentSize.Y, farY)) 46 | end 47 | end 48 | end 49 | 50 | local baseX = math.max(contentSize.X + padX, minSize.X) 51 | local baseY = math.max(contentSize.Y + padY, minSize.Y) 52 | 53 | local x, y 54 | local xClamped, yClamped 55 | 56 | if axis == Enum.AutomaticSize.XY then 57 | x = UDim.new(0, baseX) 58 | y = UDim.new(0, baseY) 59 | xClamped = UDim.new(0, math.min(baseX, maxSize.X)) 60 | yClamped = UDim.new(0, math.min(baseY, maxSize.Y)) 61 | elseif axis == Enum.AutomaticSize.X then 62 | x = UDim.new(0, baseX) 63 | y = container.Size.Y 64 | xClamped = UDim.new(0, math.min(baseX, maxSize.X)) 65 | yClamped = container.Size.Y 66 | else 67 | x = container.Size.X 68 | y = UDim.new(0, baseY) 69 | xClamped = container.Size.X 70 | yClamped = UDim.new(0, math.min(baseY, maxSize.Y)) 71 | end 72 | 73 | if container:IsA("ScrollingFrame") then 74 | local canvasX = x 75 | local canvasY = y 76 | 77 | if x.Offset > xClamped.Offset then 78 | canvasY -= UDim.new(0, container.ScrollBarThickness) 79 | end 80 | if y.Offset > yClamped.Offset then 81 | canvasX -= UDim.new(0, container.ScrollBarThickness) 82 | end 83 | 84 | container.CanvasSize = UDim2.new(canvasX, canvasY) 85 | end 86 | 87 | container.Size = UDim2.new(xClamped, yClamped) 88 | end 89 | 90 | local function trackParentSize(instance, callback) 91 | local parent = nil 92 | local connection = nil 93 | 94 | local function parentChanged(newParent) 95 | if parent == newParent then 96 | return 97 | end 98 | 99 | if connection ~= nil then 100 | connection:Disconnect() 101 | connection = nil 102 | end 103 | 104 | if newParent == nil then 105 | return 106 | end 107 | 108 | connection = newParent:GetPropertyChangedSignal("AbsoluteSize"):Connect(callback) 109 | parent = newParent 110 | end 111 | 112 | parentChanged(instance.Parent) 113 | 114 | instance:GetPropertyChangedSignal("Parent"):Connect(function() 115 | parentChanged(instance.Parent) 116 | end) 117 | end 118 | 119 | local defaultOptions = {} 120 | 121 | --[=[ 122 | @within Plasma 123 | @function automaticSize 124 | @param container GuiObject -- The instance to apply automatic sizing to. 125 | @param options { axis: Enum.AutomaticSize, maxSize: Vector2 | UDim2, minSize: Vector2 } | nil 126 | @tag utilities 127 | 128 | Applies padding-aware automatic size to the given GUI instance. This function sets up events to listen to further changes, so 129 | should only be called once per object. 130 | 131 | Also supports ScrollingFrames by correctly clamping actual and canvas sizes. 132 | 133 | :::note 134 | If this function is called from the server, if `maxSize` is a UDim2, because of differing screen sizes, it instead 135 | configures the instance to be compatible with the [Plasma.hydrateAutomaticSize] function, adding the 136 | CollectionService tag and other attributes. 137 | 138 | You must also call `hydrateAutomaticSize` once on the client for this to work. 139 | ::: 140 | 141 | ::warning 142 | There is currently no way to undo this other than destroying the instance. Once automatic sizing has been applied, 143 | it is always applied to that instance. 144 | ::: 145 | ]=] 146 | local function automaticSize(container, options) 147 | options = options or defaultOptions 148 | 149 | if options.maxSize then 150 | container:SetAttribute("maxSize", options.maxSize) 151 | end 152 | 153 | if options.minSize then 154 | container:SetAttribute("minSize", options.minSize) 155 | end 156 | 157 | if options.axis then 158 | container:SetAttribute("axis", options.axis.Name) 159 | end 160 | 161 | if not RunService:IsClient() and typeof(container:GetAttribute("maxSize") or nil) == "UDim2" then 162 | CollectionService:AddTag(container, "PlasmaAutomaticSize") 163 | 164 | return 165 | end 166 | 167 | local layout = container:FindFirstChildWhichIsA("UIGridStyleLayout") 168 | 169 | applyLayout(container, layout) 170 | 171 | if typeof(container:GetAttribute("maxSize") or nil) == "UDim2" then 172 | trackParentSize(container, function() 173 | applyLayout(container, layout) 174 | end) 175 | end 176 | 177 | if layout then 178 | layout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(function() 179 | applyLayout(container, layout) 180 | end) 181 | elseif container:IsA("TextLabel") or container:IsA("TextButton") then 182 | container:GetPropertyChangedSignal("TextBounds"):Connect(function() 183 | applyLayout(container) 184 | end) 185 | else 186 | local function connect(child) 187 | if child:IsA("GuiObject") then 188 | child:GetPropertyChangedSignal("Size"):Connect(function() 189 | applyLayout(container) 190 | end) 191 | end 192 | end 193 | 194 | for _, child in container:GetChildren() do 195 | connect(child) 196 | end 197 | 198 | container.ChildAdded:Connect(function(child) 199 | applyLayout(container) 200 | 201 | connect(child) 202 | end) 203 | 204 | container.ChildRemoved:Connect(function() 205 | applyLayout(container) 206 | end) 207 | end 208 | 209 | container:GetAttributeChangedSignal("maxSize"):Connect(function() 210 | applyLayout(container, layout) 211 | end) 212 | 213 | container:GetAttributeChangedSignal("minSize"):Connect(function() 214 | applyLayout(container, layout) 215 | end) 216 | end 217 | 218 | return automaticSize 219 | -------------------------------------------------------------------------------- /src/widgets/window.lua: -------------------------------------------------------------------------------- 1 | local GuiService = game:GetService("GuiService") 2 | local UserInputService = game:GetService("UserInputService") 3 | 4 | --[=[ 5 | @interface WindowOptions 6 | @within Plasma 7 | 8 | .title? string 9 | .closable? boolean 10 | .movable? boolean 11 | .resizable? boolean 12 | ]=] 13 | 14 | --[=[ 15 | @within Plasma 16 | @function window 17 | @param options string | WindowOptions -- The title of the window, or options 18 | @param children () -> () -- Children 19 | @tag widgets 20 | @return WindowWidgetHandle 21 | 22 | A window widget. Contains children. 23 | 24 | - Closable 25 | - Draggable 26 | - Resizable 27 | 28 | Returns a widget handle, which has the field: 29 | 30 | - `closed`, a function you can call to check if the close button was clicked. 31 | 32 | ![Window with checkboxes](https://i.eryn.io/2150/TVkkOnxj.png) 33 | ]=] 34 | 35 | local Runtime = require(script.Parent.Parent.Runtime) 36 | local createConnect = require(script.Parent.Parent.createConnect) 37 | local Style = require(script.Parent.Parent.Style) 38 | local automaticSize = require(script.Parent.Parent.automaticSize) 39 | local c = require(script.Parent.Parent.create) 40 | 41 | local MIN_SIZE = Vector2.new(50, 50) 42 | local MAX_SIZE = Vector2.new(1500, 500) 43 | 44 | return Runtime.widget(function(options, fn) 45 | local closed, setClosed = Runtime.useState(false) 46 | 47 | local refs = Runtime.useInstance(function(ref) 48 | local style = Style.get() 49 | 50 | local dragConnection 51 | 52 | local connect = createConnect() 53 | 54 | c("Frame", { 55 | [ref] = "frame", 56 | BackgroundColor3 = style.bg2, 57 | Position = UDim2.new(0, 0, 0, 0), 58 | Size = UDim2.new(0, 50, 0, 40), 59 | 60 | c("UICorner", {}), 61 | 62 | c("UIPadding", { 63 | PaddingBottom = UDim.new(0, 20), 64 | PaddingLeft = UDim.new(0, 20), 65 | PaddingRight = UDim.new(0, 20), 66 | PaddingTop = UDim.new(0, 20), 67 | }), 68 | 69 | c("UIStroke", {}), 70 | 71 | c("TextButton", { 72 | [ref] = "titleBar", 73 | Size = UDim2.new(1, 0, 0, 40), 74 | BackgroundTransparency = 1, 75 | Text = "", 76 | 77 | InputBegan = function(clickInput) 78 | if not ref.titleBar.Active then 79 | return 80 | end 81 | if clickInput.UserInputType ~= Enum.UserInputType.MouseButton1 then 82 | return 83 | end 84 | 85 | local lastMousePosition = clickInput.Position 86 | 87 | if 88 | ref.frame.Parent:FindFirstChildWhichIsA("UIGridStyleLayout") 89 | and not ref.frame.Parent:IsA("ScreenGui") 90 | then 91 | local beforePosition = ref.frame.AbsolutePosition 92 | 93 | local screenGui = ref.frame:FindFirstAncestorOfClass("ScreenGui") 94 | 95 | if screenGui.IgnoreGuiInset then 96 | beforePosition += GuiService:GetGuiInset() 97 | end 98 | 99 | ref.frame.Parent = screenGui 100 | ref.frame.Position = UDim2.new(0, beforePosition.X, 0, beforePosition.Y) 101 | end 102 | 103 | dragConnection = connect(UserInputService, "InputChanged", function(moveInput) 104 | local delta = lastMousePosition - moveInput.Position 105 | 106 | lastMousePosition = moveInput.Position 107 | 108 | ref.frame.Position = ref.frame.Position - UDim2.new(0, delta.X, 0, delta.Y) 109 | end) 110 | end, 111 | 112 | InputEnded = function(input) 113 | if dragConnection and input.UserInputType == Enum.UserInputType.MouseButton1 then 114 | dragConnection:Disconnect() 115 | dragConnection = nil 116 | end 117 | end, 118 | 119 | c("Frame", { 120 | [ref] = "handle", 121 | Position = UDim2.new(0, -5, 0, 0), 122 | 123 | c("TextLabel", { 124 | Text = "..", 125 | Position = UDim2.new(0, 0, 0, 0), 126 | BackgroundTransparency = 1, 127 | TextSize = 20, 128 | TextColor3 = style.mutedTextColor, 129 | }), 130 | 131 | c("TextLabel", { 132 | Text = "..", 133 | Position = UDim2.new(0, 0, 0, 7), 134 | BackgroundTransparency = 1, 135 | TextSize = 20, 136 | TextColor3 = style.mutedTextColor, 137 | }), 138 | 139 | c("TextLabel", { 140 | Text = "..", 141 | Position = UDim2.new(0, 0, 0, -7), 142 | BackgroundTransparency = 1, 143 | TextSize = 20, 144 | TextColor3 = style.mutedTextColor, 145 | }), 146 | }), 147 | 148 | c("TextLabel", { 149 | [ref] = "title", 150 | BackgroundTransparency = 1, 151 | Font = Enum.Font.GothamBold, 152 | Size = UDim2.new(1, 0, 1, 0), 153 | TextColor3 = style.mutedTextColor, 154 | TextSize = 20, 155 | TextXAlignment = Enum.TextXAlignment.Left, 156 | TextYAlignment = Enum.TextYAlignment.Top, 157 | TextTruncate = Enum.TextTruncate.AtEnd, 158 | }), 159 | 160 | c("TextButton", { 161 | [ref] = "close", 162 | BackgroundColor3 = Color3.fromHex("e74c3c"), 163 | Size = UDim2.new(0, 20, 0, 20), 164 | Text = "", 165 | AnchorPoint = Vector2.new(0.5, 0), 166 | Position = UDim2.new(1, -10, 0, 0), 167 | TextColor3 = Color3.fromHex("#71190f"), 168 | TextSize = 20, 169 | Font = Enum.Font.Gotham, 170 | 171 | MouseEnter = function() 172 | ref.close.Text = "x" 173 | end, 174 | 175 | MouseLeave = function() 176 | ref.close.Text = "" 177 | end, 178 | 179 | Activated = function() 180 | setClosed(true) 181 | end, 182 | 183 | c("UICorner", { 184 | CornerRadius = UDim.new(1, 0), 185 | }), 186 | }), 187 | }), 188 | 189 | c("ScrollingFrame", { 190 | [ref] = "container", 191 | BackgroundTransparency = 1, 192 | VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar, 193 | HorizontalScrollBarInset = Enum.ScrollBarInset.ScrollBar, 194 | BorderSizePixel = 0, 195 | ScrollBarThickness = 6, 196 | Position = UDim2.new(0, 0, 0, 40), 197 | 198 | c("UIListLayout", { 199 | SortOrder = Enum.SortOrder.LayoutOrder, 200 | Padding = UDim.new(0, 10), 201 | }), 202 | }), 203 | 204 | c("TextButton", { 205 | [ref] = "resizeHandle", 206 | Size = UDim2.new(0, 20, 0, 20), 207 | Text = "≡", 208 | Font = Enum.Font.SourceSans, 209 | TextSize = 20, 210 | Rotation = -45, 211 | BackgroundTransparency = 1, 212 | TextColor3 = style.mutedTextColor, 213 | Position = UDim2.new(1, 0, 1, 0), 214 | 215 | InputBegan = function(clickInput) 216 | if clickInput.UserInputType ~= Enum.UserInputType.MouseButton1 then 217 | return 218 | end 219 | 220 | local initialMousePosition = clickInput.Position 221 | local initialSize = ref.container.AbsoluteSize 222 | 223 | dragConnection = connect(UserInputService, "InputChanged", function(moveInput) 224 | if moveInput.UserInputType ~= Enum.UserInputType.MouseMovement then 225 | return 226 | end 227 | 228 | local delta = Vector2.new( 229 | (moveInput.Position.X - initialMousePosition.X), 230 | (moveInput.Position.Y - initialMousePosition.Y) 231 | ) 232 | 233 | local size = initialSize + delta 234 | 235 | ref.container:SetAttribute( 236 | "maxSize", 237 | Vector2.new(math.max(MIN_SIZE.X, size.X), math.max(MIN_SIZE.Y, size.Y)) 238 | ) 239 | end) 240 | end, 241 | 242 | InputEnded = function(input) 243 | if dragConnection and input.UserInputType == Enum.UserInputType.MouseButton1 then 244 | dragConnection:Disconnect() 245 | dragConnection = nil 246 | end 247 | end, 248 | }), 249 | }) 250 | 251 | automaticSize(ref.container) 252 | automaticSize(ref.frame) 253 | 254 | return ref.frame, ref.container 255 | end) 256 | 257 | if type(options) == "string" then 258 | options = { 259 | title = options, 260 | } 261 | end 262 | 263 | local movable = if options.movable ~= nil then options.movable else true 264 | local resizable = if options.resizable ~= nil then options.movable else true 265 | 266 | refs.close.Visible = options.closable or false 267 | refs.handle.Visible = movable 268 | refs.titleBar.Active = movable 269 | refs.resizeHandle.Visible = resizable 270 | 271 | refs.title.Size = UDim2.new(1, if options.closable then -30 else 0, 1, 0) 272 | 273 | local spaces = if movable then " " else "" 274 | refs.title.Text = options.title and spaces .. string.upper(options.title) or "" 275 | 276 | Runtime.useEffect(function() 277 | refs.container:SetAttribute("maxSize", options.maxSize or MAX_SIZE) 278 | end, options.maxSize) 279 | 280 | Runtime.useEffect(function() 281 | refs.container:SetAttribute("minSize", options.minSize) 282 | end, options.minSize) 283 | 284 | Runtime.scope(fn) 285 | 286 | local handle = { 287 | closed = function() 288 | if closed then 289 | setClosed(false) 290 | return true 291 | end 292 | 293 | return false 294 | end, 295 | } 296 | 297 | return handle 298 | end) 299 | -------------------------------------------------------------------------------- /src/Runtime.lua: -------------------------------------------------------------------------------- 1 | type EventCallback = (Instance, string, (...any) -> ()) -> () 2 | 3 | type Node = { 4 | instance: Instance?, 5 | refs: { [any]: Instance }?, 6 | containerInstance: Instance?, 7 | effects: { 8 | [TopoKey]: { 9 | lastDependencies: { any }?, 10 | lastDependenciesLength: number, 11 | destructor: (() -> ())?, 12 | }, 13 | }, 14 | states: { [TopoKey]: any }, 15 | children: { [TopoKey]: Node }, 16 | generation: number, 17 | eventCallback: EventCallback?, 18 | } 19 | 20 | type TopoKey = string 21 | 22 | type StackFrame = { 23 | node: Node, 24 | contextValues: { 25 | [any]: any, 26 | }, 27 | childrenCount: number, 28 | effectCounts: { [TopoKey]: number }, 29 | stateCounts: { [TopoKey]: number }, 30 | childCounts: { [TopoKey]: number }, 31 | discriminator: string | number, 32 | } 33 | 34 | local stack: { StackFrame } = {} 35 | 36 | local recentErrors = {} 37 | local recentErrorLastTime = 0 38 | 39 | local function newNode(state: {}): Node 40 | if state == nil then 41 | state = {} 42 | end 43 | 44 | return { 45 | instance = nil, 46 | containerInstance = nil, 47 | effects = {}, 48 | states = {}, 49 | children = {}, 50 | generation = 0, 51 | } 52 | end 53 | 54 | local function destroyNode(node: Node) 55 | if node.instance ~= nil then 56 | node.instance:Destroy() 57 | end 58 | 59 | for _, effect in pairs(node.effects) do 60 | if effect.destructor ~= nil then 61 | effect.destructor() 62 | end 63 | end 64 | 65 | for _, child in pairs(node.children) do 66 | destroyNode(child) 67 | end 68 | end 69 | 70 | local function newStackFrame(node: Node): StackFrame 71 | return { 72 | node = node, 73 | contextValues = {}, 74 | childrenCount = 0, 75 | effectCounts = {}, 76 | stateCounts = {}, 77 | childCounts = {}, 78 | } 79 | end 80 | 81 | local Runtime = {} 82 | 83 | --[=[ 84 | @within Plasma 85 | @param rootInstance Instance -- The root instance of which to mount all children. Likely a ScreenGui. 86 | @return Node -- An opaque object which holds persistent state about your UI. 87 | ]=] 88 | function Runtime.new(rootInstance: Instance): Node 89 | local node = newNode() 90 | node.instance = rootInstance 91 | return node 92 | end 93 | 94 | --[=[ 95 | @within Plasma 96 | @param name string -- The human-readable name of the context. This is only for debug purposes. 97 | @return Context -- An opqaue Context object which holds persistent state. 98 | 99 | Creates a [Context] object which is used to pass state downwards through the tree without needing to thread it 100 | through every child as props. 101 | ]=] 102 | function Runtime.createContext(name: string) 103 | local fullName = string.format("PlasmaContext(%s)", name) 104 | return setmetatable({}, { 105 | __tostring = function() 106 | return fullName 107 | end, 108 | }) 109 | end 110 | 111 | --[=[ 112 | @within Plasma 113 | @param context Context -- A context object previously created with `createContext` 114 | @return T 115 | @tag hooks 116 | 117 | Returns the value of this context provided by the most recent ancestor that used `provideContext` with this context. 118 | ]=] 119 | function Runtime.useContext(context) 120 | for i = #stack - 1, 1, -1 do 121 | local frame = stack[i] 122 | 123 | if frame.contextValues[context] ~= nil then 124 | return frame.contextValues[context] 125 | end 126 | end 127 | 128 | return nil 129 | end 130 | 131 | --[=[ 132 | @within Plasma 133 | @param context Context -- A context object previously created with `createContext` 134 | @param value T -- Any value you want to provide for this context 135 | 136 | Provides a value for this context for any subsequent uses of `useContext` in this scope. 137 | ]=] 138 | function Runtime.provideContext(context, value) 139 | local frame = stack[#stack] 140 | frame.contextValues[context] = value 141 | end 142 | 143 | --[=[ 144 | @within Plasma 145 | @param callback () -> () | () -> () -> () -- A callback function that optionally returns a cleanup function 146 | @param ... any -- Dependencies 147 | @tag hooks 148 | 149 | `useEffect` takes a callback as a parameter which is then only invoked if passed dependencies are different from the 150 | last time this function was called. The callback is always invoked the first time this code path is reached. 151 | 152 | If no dependencies are passed, the callback only runs once. 153 | 154 | This function can be used to skip expensive work if none of the dependencies have changed since the last run. 155 | For example, you might use this to set a bunch of properties in a widget if any of the inputs change. 156 | ]=] 157 | function Runtime.useEffect(callback: () -> () | () -> () -> (), ...) 158 | local frame = stack[#stack] 159 | local effects = frame.node.effects 160 | 161 | local file = debug.info(2, "s") 162 | local line = debug.info(2, "l") 163 | local baseKey = string.format("%s:%s:%d", tostring(frame.discriminator) or "", file, line) 164 | 165 | frame.effectCounts[baseKey] = (frame.effectCounts[baseKey] or 0) + 1 166 | local key = string.format("%s:%d", baseKey, frame.effectCounts[baseKey]) 167 | 168 | local existing = effects[key] 169 | local gottaRunIt = existing == nil -- We ain't never run this before! 170 | or select("#", ...) ~= existing.lastDependenciesLength -- I have altered the dependencies. Pray that I do not alter them further. 171 | 172 | if not gottaRunIt then 173 | for i = 1, select("#", ...) do 174 | if select(i, ...) ~= existing.lastDependencies[i] then 175 | gottaRunIt = true 176 | break 177 | end 178 | end 179 | end 180 | 181 | if gottaRunIt then 182 | if existing ~= nil and existing.destructor ~= nil then 183 | existing.destructor() 184 | end 185 | 186 | effects[key] = { 187 | destructor = callback(), 188 | lastDependencies = { ... }, 189 | lastDependenciesLength = select("#", ...), 190 | } 191 | end 192 | end 193 | 194 | --[=[ 195 | @within Plasma 196 | @param initialValue T -- The value this hook returns if the set callback has never been called 197 | @return T -- The previously set value, or the initial value if none has been set 198 | @return (newValue: T) -> () -- A function which when called stores the value in this hook for the next run 199 | @tag hooks 200 | 201 | ```lua 202 | local checked, setChecked = useState(false) 203 | 204 | useInstance(function() 205 | local TextButton = Instance.new("TextButton") 206 | 207 | TextButton.Activated:Connect(function() 208 | setChecked(not checked) 209 | end) 210 | 211 | return TextButton 212 | end) 213 | 214 | TextButton.Text = if checked then "X" else "" 215 | ``` 216 | ]=] 217 | function Runtime.useState(initialValue: T): T 218 | local frame = stack[#stack] 219 | local states = frame.node.states 220 | 221 | local file = debug.info(2, "s") 222 | local line = debug.info(2, "l") 223 | local baseKey = string.format("%s:%s:%d", tostring(frame.discriminator) or "", file, line) 224 | frame.stateCounts[baseKey] = (frame.stateCounts[baseKey] or 0) + 1 225 | local key = string.format("%s:%d", baseKey, frame.stateCounts[baseKey]) 226 | 227 | local existing = states[key] 228 | if existing == nil then 229 | states[key] = initialValue 230 | end 231 | 232 | local function setter(newValue) 233 | if type(newValue) == "function" then 234 | newValue = newValue(states[key]) 235 | end 236 | 237 | states[key] = newValue 238 | end 239 | 240 | return states[key], setter 241 | end 242 | 243 | --[=[ 244 | @within Plasma 245 | @param key 246 | 247 | Specify a key by which to store all future state in this scope. This is similar to React's `key` prop. 248 | 249 | This is important to use to prevent state from one source being still being applied when it should actually reset. 250 | ]=] 251 | function Runtime.useKey(key: string | number) 252 | local frame = stack[#stack] 253 | 254 | frame.discriminator = key 255 | end 256 | 257 | --[=[ 258 | @within Plasma 259 | @param creator (ref: {}) -> (Instance, Instance?) -- A callback which creates the widget and returns it 260 | @return Instance -- Returns the instance returned by `creator` 261 | @tag hooks 262 | 263 | `useInstance` takes a callback which should be used to create the initial UI for the widget. 264 | The callback is only ever invoked on the first time this widget runs and never again. 265 | The callback should return the instance it created. 266 | The callback can optionally return a second value, which is the instance where children of this widget should be 267 | placed. Otherwise, children are placed in the first instance returned. 268 | 269 | `useInstance` returns the `ref` table that is passed to it. You can use this to create references to objects 270 | you want to update in the widget body. 271 | ]=] 272 | function Runtime.useInstance(creator: () -> Instance): Instance 273 | local node = stack[#stack].node 274 | local parentFrame = Runtime.nearestStackFrameWithInstance() 275 | 276 | if node.instance == nil then 277 | local parent = parentFrame.node.containerInstance or parentFrame.node.instance 278 | 279 | node.refs = {} 280 | local instance, container = creator(node.refs) 281 | 282 | if instance ~= nil then 283 | instance.Parent = parent 284 | node.instance = instance 285 | end 286 | 287 | if container ~= nil then 288 | node.containerInstance = container 289 | end 290 | end 291 | 292 | if node.instance ~= nil and node.instance:IsA("GuiObject") then 293 | parentFrame.childrenCount += 1 294 | node.instance.LayoutOrder = parentFrame.childrenCount 295 | end 296 | 297 | return node.refs 298 | end 299 | 300 | function Runtime.nearestStackFrameWithInstance(): StackFrame? 301 | for i = #stack - 1, 1, -1 do 302 | local frame = stack[i] 303 | 304 | if frame.node.containerInstance ~= nil or frame.node.instance ~= nil then 305 | return frame 306 | end 307 | end 308 | 309 | return nil 310 | end 311 | 312 | local function scope(level, scopeKey, fn, ...) 313 | local parentFrame = stack[#stack] 314 | local parentNode = parentFrame.node 315 | 316 | local file = debug.info(1 + level, "s") 317 | local line = debug.info(1 + level, "l") 318 | local baseKey = string.format("%s:%s:%s:%d", scopeKey, tostring(parentFrame.discriminator) or "", file, line) 319 | 320 | parentFrame.childCounts[baseKey] = (parentFrame.childCounts[baseKey] or 0) + 1 321 | local key = string.format("%s:%d", baseKey, parentFrame.childCounts[baseKey]) 322 | 323 | local currentNode = parentNode.children[key] 324 | 325 | if currentNode == nil then 326 | currentNode = newNode() 327 | parentNode.children[key] = currentNode 328 | end 329 | 330 | currentNode.generation = parentNode.generation 331 | 332 | table.insert(stack, newStackFrame(currentNode)) 333 | local thread = coroutine.create(fn) 334 | 335 | local success, widgetHandle = coroutine.resume(thread, ...) 336 | 337 | if coroutine.status(thread) ~= "dead" then 338 | success = false 339 | widgetHandle = 340 | "Plasma: Handler passed to Plasma.start yielded! Yielding is not allowed and the handler thread has been closed." 341 | 342 | coroutine.close(thread) 343 | end 344 | 345 | if not success then 346 | if os.clock() - recentErrorLastTime > 10 then 347 | recentErrorLastTime = os.clock() 348 | recentErrors = {} 349 | end 350 | 351 | local errorValue = debug.traceback(thread, tostring(widgetHandle)) 352 | 353 | if not recentErrors[errorValue] then 354 | task.spawn(error, tostring(errorValue)) 355 | warn("Plasma: The above error will be suppressed for the next 10 seconds") 356 | recentErrors[errorValue] = true 357 | end 358 | 359 | local errorWidget = require(script.Parent.widgets.error) 360 | 361 | errorWidget(tostring(errorValue)) 362 | end 363 | 364 | table.remove(stack) 365 | 366 | for childKey, childNode in pairs(currentNode.children) do 367 | if childNode.generation ~= currentNode.generation then 368 | destroyNode(childNode) 369 | currentNode.children[childKey] = nil 370 | end 371 | end 372 | 373 | return widgetHandle 374 | end 375 | 376 | --[=[ 377 | @within Plasma 378 | @param rootNode Node -- A node created by `Plasma.new`. 379 | @param fn (...: T) -> () 380 | @param ... T -- Additional parameters to `callback` 381 | 382 | Begins a new frame for this Plasma instance. The `callback` is invoked immediately. 383 | Code run in the `callback` function that uses plasma APIs will be associated with this Plasma node. 384 | The `callback` function is **not allowed to yield**. 385 | 386 | If this function is used, `Plasma.beginFrame`, `Plasma.continueFrame`, and `Plasma.finishFrame` should not be used. 387 | ]=] 388 | function Runtime.start(rootNode: Node, fn, ...) 389 | Runtime.beginFrame(rootNode, fn, ...) 390 | 391 | Runtime.finishFrame(rootNode) 392 | end 393 | 394 | --[=[ 395 | @within Plasma 396 | @param rootNode Node -- A node created by `Plasma.new`. 397 | @param fn (...: T) -> () 398 | @param ... T -- Additional parameters to `callback` 399 | @return ContinueHandle -- A handle to pass to `continueFrame` 400 | 401 | Begins a *continuable* Plasma frame. Same semantics as [Plasma.start]. 402 | 403 | For a frame: 404 | - Call `beginFrame` once. 405 | - Call `continueFrame` any number of times. 406 | - Call `finishFrame` when the frame is complete. 407 | 408 | If this function is used, `Plasma.start` should not be used. 409 | ]=] 410 | function Runtime.beginFrame(rootNode: Node, fn, ...) 411 | if #stack > 0 then 412 | error("Runtime.start cannot be called while Runtime.start is already running", 2) 413 | end 414 | 415 | debug.profilebegin("Plasma") 416 | 417 | if rootNode.generation == 0 then 418 | rootNode.generation = 1 419 | else 420 | rootNode.generation = 0 421 | end 422 | 423 | stack[1] = newStackFrame(rootNode) 424 | scope(2, "root", fn, ...) 425 | local continueHandle = table.remove(stack) 426 | 427 | debug.profileend() 428 | 429 | return continueHandle 430 | end 431 | 432 | --[=[ 433 | Finishes a continuable Plasma frame, cleaning up any objects that have been removed since the last frame. 434 | @within Plasma 435 | @param rootNode Node -- A node created by `Plasma.new`. 436 | ]=] 437 | function Runtime.finishFrame(rootNode: Node) 438 | for childKey, childNode in pairs(rootNode.children) do 439 | if childNode.generation ~= rootNode.generation then 440 | destroyNode(childNode) 441 | rootNode.children[childKey] = nil 442 | end 443 | end 444 | end 445 | 446 | --[=[ 447 | Continue the Plasma frame with a new handler function. Calling this will not trigger any cleanup that typically 448 | happens every frame. 449 | 450 | This is intended to be used to continue creating UI within the same frame that you started on. You should call 451 | [Plasma.beginFrame] once per frame, then `Plasma.continueFrame` any number of times after that, finally calling 452 | [Plasma.finishFrame]. 453 | 454 | @within Plasma 455 | @param continueHandle ContinueHandle -- An object returned by Plasma.start 456 | @param fn (...: T) -> () 457 | @param ... T -- Additional parameters to `callback` 458 | ]=] 459 | function Runtime.continueFrame(continueHandle, fn, ...) 460 | if #stack > 0 then 461 | error("Runtime.continue cannot be called while Runtime.start is already running", 2) 462 | end 463 | 464 | stack[1] = continueHandle 465 | 466 | scope(2, "root", fn, ...) 467 | 468 | table.remove(stack) 469 | end 470 | 471 | --[=[ 472 | @within Plasma 473 | @param fn (...: T) -> () 474 | @param ... T -- Additional parameters to `callback` 475 | 476 | Begins a new scope. This function may only be called within a `Plasma.start` callback. 477 | The `callback` is invoked immediately. 478 | 479 | Beginning a new scope associates all further calls to Plasma APIs with a nested scope inside this one. 480 | ]=] 481 | function Runtime.scope(fn, ...) 482 | return scope(2, "", fn, ...) 483 | end 484 | 485 | --[=[ 486 | @within Plasma 487 | @param fn (...: T) -> () -- The widget function 488 | @return (...: T) -> () -- A function which can be called to create the widget 489 | 490 | This function takes a widget function and returns a function that automatically starts a new scope when the function 491 | is called. 492 | ]=] 493 | function Runtime.widget(fn) 494 | local file, line = debug.info(2, "sl") 495 | local scopeKey = string.format("%s+%d", file, line) 496 | 497 | return function(...) 498 | return scope(2, scopeKey, fn, ...) 499 | end 500 | end 501 | 502 | function Runtime.setEventCallback(callback: EventCallback) 503 | stack[1].node.eventCallback = callback 504 | end 505 | 506 | function Runtime.useEventCallback(): EventCallback? 507 | local frame = stack[1] 508 | 509 | if not frame then 510 | return nil 511 | end 512 | 513 | return frame.node.eventCallback 514 | end 515 | 516 | return Runtime 517 | --------------------------------------------------------------------------------