├── 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 | 
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 | 
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 | 
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 | 
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 | 
--------------------------------------------------------------------------------
/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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------