@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -26
- package/dist/index.js +1 -1
- package/dist/lib/format.js +18 -2
- package/dist/lib/format.js.map +1 -1
- package/dist/tools/api.js +20 -1
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/debug.js +18 -8
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/discover.js +5 -0
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/input.js +38 -5
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/instances.js +49 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +9 -8
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +10 -1
- package/dist/tools/scripts.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +301 -117
- package/plugin/src/Mirror.luau +126 -0
- package/plugin/src/Serialize.luau +43 -3
- package/plugin/src/ThemePicker.luau +458 -0
- package/plugin/src/Themes/Aurora.luau +161 -0
- package/plugin/src/Themes/Blueprint.luau +213 -0
- package/plugin/src/Themes/Draw.luau +177 -0
- package/plugin/src/Themes/Lattice.luau +304 -0
- package/plugin/src/Themes/Nebula.luau +182 -0
- package/plugin/src/Themes/Observatory.luau +200 -0
- package/plugin/src/Themes/Orbit.luau +268 -0
- package/plugin/src/Themes/Phosphor.luau +200 -0
- package/plugin/src/Themes/Theme.luau +121 -0
- package/plugin/src/Themes/Void.luau +266 -0
- package/plugin/src/Themes/init.luau +116 -0
- package/plugin/src/Undo.luau +29 -0
- package/plugin/src/Visuals.luau +838 -907
- package/plugin/src/handlers/Device.luau +21 -1
- package/plugin/src/handlers/Discover.luau +75 -1
- package/plugin/src/handlers/Exec.luau +17 -5
- package/plugin/src/handlers/Input.luau +583 -493
- package/plugin/src/handlers/Instances.luau +35 -3
- package/plugin/src/init.server.luau +209 -11
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Carries console activity from a playtest's SERVER view to its CLIENT view.
|
|
4
|
+
|
|
5
|
+
Pressing Play gives this plugin three lives: the editor's, the playtest
|
|
6
|
+
server's, and the playtest client's. Studio shows whichever belongs to the
|
|
7
|
+
view you are looking at, and it defaults to the client -- which is the one
|
|
8
|
+
life that can do nothing. Roblox forbids HTTP from a client session, so that
|
|
9
|
+
panel has no way to see the bridge, and it sat on a standby notice while the
|
|
10
|
+
agent worked a few feet away in a view the user was not looking at.
|
|
11
|
+
|
|
12
|
+
The two DataModels share no memory, no globals and no services. The only
|
|
13
|
+
channel between them is the game's own replication, so that is what this
|
|
14
|
+
uses: the server plugin puts a RemoteEvent in ReplicatedStorage and fires
|
|
15
|
+
each console event across it; the client plugin listens and replays them
|
|
16
|
+
into its own console. The prism reacts, the log fills, and the view the user
|
|
17
|
+
actually has open stops lying about what is happening.
|
|
18
|
+
|
|
19
|
+
The cost is honest and worth stating: this plugin writes one instance into
|
|
20
|
+
the running game. It is `Archivable = false` so it can never be saved with
|
|
21
|
+
the place, it exists only while a playtest is running, and it goes when the
|
|
22
|
+
playtest's DataModel does. Nothing reads it but the client half of this
|
|
23
|
+
plugin, and nothing is sent up from the client -- the traffic is one-way, so
|
|
24
|
+
a game's own scripts cannot use it to reach the editor.
|
|
25
|
+
]]
|
|
26
|
+
|
|
27
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
28
|
+
local RunService = game:GetService("RunService")
|
|
29
|
+
|
|
30
|
+
--[[
|
|
31
|
+
Named to be recognised in the Explorer rather than to be hidden.
|
|
32
|
+
|
|
33
|
+
Someone will see this appear in ReplicatedStorage mid-playtest and want to
|
|
34
|
+
know what put it there; a cryptic name turns that into a hunt. The prefix is
|
|
35
|
+
unlikely enough to collide with a real game's instances.
|
|
36
|
+
]]
|
|
37
|
+
local CHANNEL = "__rbxStudioMcpMirror"
|
|
38
|
+
|
|
39
|
+
local Mirror = {}
|
|
40
|
+
|
|
41
|
+
local remote: RemoteEvent? = nil
|
|
42
|
+
|
|
43
|
+
--[[
|
|
44
|
+
True only in the half of a playtest that can reach the bridge.
|
|
45
|
+
|
|
46
|
+
`IsRunning` separates a playtest from the editor, and `IsServer` separates
|
|
47
|
+
the two halves of it. The editor session must not do any of this: it has no
|
|
48
|
+
playtest to mirror and writing to its ReplicatedStorage would put the
|
|
49
|
+
instance in the user's actual place.
|
|
50
|
+
]]
|
|
51
|
+
function Mirror.isPlaytestServer(): boolean
|
|
52
|
+
return RunService:IsRunning() and RunService:IsServer()
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
function Mirror.isPlaytestClient(): boolean
|
|
56
|
+
return RunService:IsRunning() and RunService:IsClient()
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
--[[
|
|
60
|
+
Opens the channel from the playtest server.
|
|
61
|
+
|
|
62
|
+
A leftover from an earlier test in the same DataModel is replaced rather
|
|
63
|
+
than reused: reusing one whose connections are gone looks like a working
|
|
64
|
+
channel that silently delivers nothing.
|
|
65
|
+
]]
|
|
66
|
+
function Mirror.startServer()
|
|
67
|
+
if not Mirror.isPlaytestServer() then
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
local existing = ReplicatedStorage:FindFirstChild(CHANNEL)
|
|
71
|
+
if existing ~= nil then
|
|
72
|
+
existing:Destroy()
|
|
73
|
+
end
|
|
74
|
+
local event = Instance.new("RemoteEvent")
|
|
75
|
+
event.Name = CHANNEL
|
|
76
|
+
-- Never saved with the place, whatever the user does while the test runs.
|
|
77
|
+
event.Archivable = false
|
|
78
|
+
event.Parent = ReplicatedStorage
|
|
79
|
+
remote = event
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
--[[
|
|
83
|
+
Sends one console event, and never fails loudly.
|
|
84
|
+
|
|
85
|
+
This is decoration on top of the console. A replication error here must not
|
|
86
|
+
take down the call it was reporting on, so every send is guarded and a dead
|
|
87
|
+
channel simply stops mirroring.
|
|
88
|
+
]]
|
|
89
|
+
function Mirror.send(kind: string, arguments: { any })
|
|
90
|
+
local event = remote
|
|
91
|
+
if event == nil then
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
pcall(function()
|
|
95
|
+
event:FireAllClients(kind, arguments)
|
|
96
|
+
end)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
--[[
|
|
100
|
+
Listens on the playtest client.
|
|
101
|
+
|
|
102
|
+
`WaitForChild` with a timeout rather than an indefinite wait: if the server
|
|
103
|
+
half never opens the channel the client should go quiet, not hang a thread
|
|
104
|
+
for the length of the playtest.
|
|
105
|
+
]]
|
|
106
|
+
function Mirror.startClient(onMessage: (string, { any }) -> ())
|
|
107
|
+
if not Mirror.isPlaytestClient() then
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
task.spawn(function()
|
|
111
|
+
local event = ReplicatedStorage:WaitForChild(CHANNEL, 30)
|
|
112
|
+
if event == nil or not event:IsA("RemoteEvent") then
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
event.OnClientEvent:Connect(function(kind, arguments)
|
|
116
|
+
-- Shape-checked because anything on the wire can be forged by a
|
|
117
|
+
-- script in the place being tested. Nothing here is trusted enough
|
|
118
|
+
-- to index without looking.
|
|
119
|
+
if typeof(kind) == "string" and typeof(arguments) == "table" then
|
|
120
|
+
onMessage(kind, arguments :: { any })
|
|
121
|
+
end
|
|
122
|
+
end)
|
|
123
|
+
end)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
return Mirror
|
|
@@ -151,6 +151,31 @@ function Serialize.readProperty(instance: Instance, name: string): (boolean, any
|
|
|
151
151
|
return true, Serialize.value(value)
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
--[[
|
|
155
|
+
Pulls the numbers out of a composite value written as text.
|
|
156
|
+
|
|
157
|
+
This used to be a single `-?%d+%.?%d*` scan, which is not the grammar Luau
|
|
158
|
+
numbers actually have: "1e3, 0, .5" came back as 1, 3 and 0, so a Position
|
|
159
|
+
written in scientific notation was silently set to a different point in the
|
|
160
|
+
world and reported as applied. Splitting on the separators and handing each
|
|
161
|
+
token to `tonumber` means this agrees with the scalar path, which always
|
|
162
|
+
used `tonumber` -- the two disagreeing about what counts as a number was the
|
|
163
|
+
whole bug.
|
|
164
|
+
|
|
165
|
+
Non-numeric tokens are dropped, so "Vector3.new(1, 2, 3)" and "{1, 2, 3}"
|
|
166
|
+
still read as three numbers.
|
|
167
|
+
]]
|
|
168
|
+
local function scanTokens(text: string): { number }
|
|
169
|
+
local numbers: { number } = {}
|
|
170
|
+
for token in string.gmatch(text, "[^,%s%(%)%[%]{}<>]+") do
|
|
171
|
+
local value = tonumber(token)
|
|
172
|
+
if value then
|
|
173
|
+
table.insert(numbers, value)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
return numbers
|
|
177
|
+
end
|
|
178
|
+
|
|
154
179
|
--[[
|
|
155
180
|
Parses a serialized string back into a Roblox value, given the target type
|
|
156
181
|
from the API dump. Returns ok=false with a reason the agent can act on
|
|
@@ -176,7 +201,22 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
|
|
|
176
201
|
if kind == "boolean" then
|
|
177
202
|
return true, text, nil
|
|
178
203
|
end
|
|
179
|
-
|
|
204
|
+
if kind == "number" then
|
|
205
|
+
return true, text ~= 0, nil
|
|
206
|
+
end
|
|
207
|
+
-- This used to be `text == "true"`, which quietly turned "True", "TRUE"
|
|
208
|
+
-- and "1" into false and reported the write as applied. A bool that is
|
|
209
|
+
-- silently the opposite of what was asked is the worst failure this
|
|
210
|
+
-- module can produce, so near misses are accepted and anything else is
|
|
211
|
+
-- refused rather than guessed at.
|
|
212
|
+
local lowered = string.lower(tostring(text))
|
|
213
|
+
if lowered == "true" or lowered == "1" or lowered == "yes" then
|
|
214
|
+
return true, true, nil
|
|
215
|
+
end
|
|
216
|
+
if lowered == "false" or lowered == "0" or lowered == "no" then
|
|
217
|
+
return true, false, nil
|
|
218
|
+
end
|
|
219
|
+
return false, nil, string.format('expected true or false, got "%s"', tostring(text))
|
|
180
220
|
end
|
|
181
221
|
if NUMERIC_TYPES[valueType] then
|
|
182
222
|
local parsed = tonumber(text)
|
|
@@ -188,8 +228,8 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
|
|
|
188
228
|
|
|
189
229
|
local numbers: { number } = {}
|
|
190
230
|
if kind == "string" then
|
|
191
|
-
for
|
|
192
|
-
table.insert(numbers,
|
|
231
|
+
for _, token in scanTokens(text) do
|
|
232
|
+
table.insert(numbers, token)
|
|
193
233
|
end
|
|
194
234
|
elseif kind == "table" then
|
|
195
235
|
for _, item in text :: { any } do
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The preset drawer: a tab on the panel's right edge that opens on hover.
|
|
4
|
+
|
|
5
|
+
Kept out of the header on purpose. The header is where connection facts live
|
|
6
|
+
and it is already carrying a status light, an address, a client badge and
|
|
7
|
+
three buttons; a ninth control there would push something off the edge on a
|
|
8
|
+
narrow dock. A theme is also not something anyone changes twice in a
|
|
9
|
+
session, so it has no claim on permanent width -- an edge tab costs fourteen
|
|
10
|
+
pixels and is invisible until you go looking for it.
|
|
11
|
+
|
|
12
|
+
Hover rather than click to open, because the drawer is a preview as much as
|
|
13
|
+
a menu: each row wears its own preset's background, text colour and cell
|
|
14
|
+
miniature, so running the pointer down the list shows you the eight themes
|
|
15
|
+
rather than eight names. Clicking picks; leaving closes.
|
|
16
|
+
|
|
17
|
+
The grace period on close is the part that makes it usable. Without it, the
|
|
18
|
+
pointer crossing the one-pixel gap between the tab and the drawer counts as
|
|
19
|
+
leaving, and the menu shuts under the hand reaching for it.
|
|
20
|
+
]]
|
|
21
|
+
|
|
22
|
+
local TweenService = game:GetService("TweenService")
|
|
23
|
+
|
|
24
|
+
local Themes = require(script.Parent.Themes)
|
|
25
|
+
|
|
26
|
+
local ThemePicker = {}
|
|
27
|
+
|
|
28
|
+
local TAB_WIDTH = 14
|
|
29
|
+
local TAB_HEIGHT = 48
|
|
30
|
+
local DRAWER_WIDTH = 196
|
|
31
|
+
local ROW_HEIGHT = 34
|
|
32
|
+
local PADDING = 6
|
|
33
|
+
|
|
34
|
+
-- Long enough to cross the gap between tab and drawer without thinking about
|
|
35
|
+
-- it, short enough that a drawer left open behind the pointer still closes.
|
|
36
|
+
local GRACE = 0.4
|
|
37
|
+
|
|
38
|
+
--[[
|
|
39
|
+
Explicit depths for every layer of the drawer.
|
|
40
|
+
|
|
41
|
+
A plugin widget draws with ZIndexBehavior.Global, where a child does NOT
|
|
42
|
+
inherit its parent's depth -- so the rows, built with the default ZIndex of
|
|
43
|
+
1, rendered UNDER the drawer's own background at 30 and the menu opened
|
|
44
|
+
empty. Nothing about that is visible in the code unless you already know the
|
|
45
|
+
rule, so the depths are named rather than sprinkled as numbers.
|
|
46
|
+
]]
|
|
47
|
+
local Z_DRAWER = 30
|
|
48
|
+
local Z_LIST = 31
|
|
49
|
+
local Z_ROW = 32
|
|
50
|
+
local Z_ROW_CONTENT = 33
|
|
51
|
+
local Z_TAB = 36
|
|
52
|
+
local Z_VEIL = 50
|
|
53
|
+
|
|
54
|
+
--[[
|
|
55
|
+
Stamps a depth on an Instance and everything inside it.
|
|
56
|
+
|
|
57
|
+
Needed because a preset draws its own swatch and has no reason to know it is
|
|
58
|
+
being drawn inside a menu. Rather than adding a ZIndex argument to the theme
|
|
59
|
+
contract -- which would put a menu detail into eight renderers -- the swatch
|
|
60
|
+
is drawn first and pushed to the right depth afterwards.
|
|
61
|
+
]]
|
|
62
|
+
local function stack(instance: Instance, depth: number)
|
|
63
|
+
if instance:IsA("GuiObject") then
|
|
64
|
+
instance.ZIndex = depth
|
|
65
|
+
end
|
|
66
|
+
for _, child in instance:GetDescendants() do
|
|
67
|
+
if child:IsA("GuiObject") then
|
|
68
|
+
child.ZIndex = depth
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
local OPEN = TweenInfo.new(0.22, Enum.EasingStyle.Quint, Enum.EasingDirection.Out)
|
|
74
|
+
local SHUT = TweenInfo.new(0.16, Enum.EasingStyle.Quint, Enum.EasingDirection.In)
|
|
75
|
+
|
|
76
|
+
type Runtime = {
|
|
77
|
+
drawer: Frame?,
|
|
78
|
+
tab: TextButton?,
|
|
79
|
+
chevron: TextLabel?,
|
|
80
|
+
rows: { { frame: Frame, id: string, marker: TextLabel } },
|
|
81
|
+
open: boolean,
|
|
82
|
+
-- Bumped on every hover, so a grace timer from an earlier one cannot close
|
|
83
|
+
-- a drawer the pointer has since come back to.
|
|
84
|
+
generation: number,
|
|
85
|
+
root: Frame?,
|
|
86
|
+
onPicked: ((string) -> ())?,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
local runtime: Runtime = {
|
|
90
|
+
drawer = nil,
|
|
91
|
+
tab = nil,
|
|
92
|
+
chevron = nil,
|
|
93
|
+
rows = {},
|
|
94
|
+
open = false,
|
|
95
|
+
generation = 0,
|
|
96
|
+
root = nil,
|
|
97
|
+
onPicked = nil,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
local function drawerHeight(): number
|
|
101
|
+
return #Themes.list() * ROW_HEIGHT + PADDING * 2
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
local function setOpen(open: boolean)
|
|
105
|
+
local drawer = runtime.drawer
|
|
106
|
+
local chevron = runtime.chevron
|
|
107
|
+
if drawer == nil then
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
if runtime.open == open then
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
runtime.open = open
|
|
114
|
+
|
|
115
|
+
--[[
|
|
116
|
+
Size and position tween together, with the drawer anchored to its own
|
|
117
|
+
right edge at the tab's height. That is what makes it appear to grow out
|
|
118
|
+
of the tab rather than slide in from off-screen: at width zero it is
|
|
119
|
+
exactly where the pointer is.
|
|
120
|
+
]]
|
|
121
|
+
local height = drawerHeight()
|
|
122
|
+
TweenService:Create(drawer, if open then OPEN else SHUT, {
|
|
123
|
+
Size = UDim2.fromOffset(if open then DRAWER_WIDTH else 0, height),
|
|
124
|
+
}):Play()
|
|
125
|
+
|
|
126
|
+
if chevron then
|
|
127
|
+
-- Points the way it will move: into the panel when it will open, back
|
|
128
|
+
-- out at the edge when it will close.
|
|
129
|
+
TweenService:Create(chevron, if open then OPEN else SHUT, {
|
|
130
|
+
Rotation = if open then 180 else 0,
|
|
131
|
+
}):Play()
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
--[[
|
|
136
|
+
Closes after the grace period, unless the pointer came back.
|
|
137
|
+
]]
|
|
138
|
+
local function scheduleClose()
|
|
139
|
+
runtime.generation += 1
|
|
140
|
+
local generation = runtime.generation
|
|
141
|
+
task.delay(GRACE, function()
|
|
142
|
+
if runtime.generation == generation then
|
|
143
|
+
setOpen(false)
|
|
144
|
+
end
|
|
145
|
+
end)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
local function hold()
|
|
149
|
+
runtime.generation += 1
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[[
|
|
153
|
+
Fades the panel through its own background while the palette changes.
|
|
154
|
+
|
|
155
|
+
A theme switch repaints thirty Instances and re-renders three hundred log
|
|
156
|
+
rows, and doing that in one frame reads as a flicker -- the eye catches the
|
|
157
|
+
half-repainted state even though there is not one. A third of a second of
|
|
158
|
+
the outgoing background over the top hides the seam and, more usefully, says
|
|
159
|
+
that something deliberate just happened.
|
|
160
|
+
]]
|
|
161
|
+
local function crossfade(root: Frame, from: Color3, apply: () -> ())
|
|
162
|
+
local veil = Instance.new("Frame")
|
|
163
|
+
veil.Name = "ThemeFade"
|
|
164
|
+
veil.Size = UDim2.fromScale(1, 1)
|
|
165
|
+
veil.BackgroundColor3 = from
|
|
166
|
+
veil.BackgroundTransparency = 0.15
|
|
167
|
+
veil.BorderSizePixel = 0
|
|
168
|
+
veil.ZIndex = Z_VEIL
|
|
169
|
+
veil.Parent = root
|
|
170
|
+
|
|
171
|
+
apply()
|
|
172
|
+
|
|
173
|
+
local fade = TweenService:Create(
|
|
174
|
+
veil,
|
|
175
|
+
TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
|
|
176
|
+
{ BackgroundTransparency = 1 }
|
|
177
|
+
)
|
|
178
|
+
fade.Completed:Connect(function()
|
|
179
|
+
veil:Destroy()
|
|
180
|
+
end)
|
|
181
|
+
fade:Play()
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
local function markActive()
|
|
185
|
+
local activeId = Themes.activeId()
|
|
186
|
+
for _, row in runtime.rows do
|
|
187
|
+
row.marker.Text = if row.id == activeId then "\u{25C6}" else ""
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
--[[
|
|
192
|
+
One row: the preset's own background, its cell miniature, its name.
|
|
193
|
+
|
|
194
|
+
Drawn in the preset's palette rather than the console's, which is the whole
|
|
195
|
+
reason the drawer is worth opening. A list of eight words in the current
|
|
196
|
+
theme's colours would tell you nothing you could not have guessed.
|
|
197
|
+
]]
|
|
198
|
+
local function buildRow(parent: Instance, theme: Themes.Theme, order: number)
|
|
199
|
+
local palette = theme.palette
|
|
200
|
+
|
|
201
|
+
local row = Instance.new("TextButton")
|
|
202
|
+
row.Name = theme.id
|
|
203
|
+
row.Text = ""
|
|
204
|
+
row.AutoButtonColor = false
|
|
205
|
+
row.BackgroundColor3 = palette.background
|
|
206
|
+
row.BorderSizePixel = 0
|
|
207
|
+
row.Size = UDim2.new(1, 0, 0, ROW_HEIGHT - 2)
|
|
208
|
+
row.LayoutOrder = order
|
|
209
|
+
row.ZIndex = Z_ROW
|
|
210
|
+
row.Parent = parent
|
|
211
|
+
|
|
212
|
+
local corner = Instance.new("UICorner")
|
|
213
|
+
corner.CornerRadius = UDim.new(0, 3)
|
|
214
|
+
corner.Parent = row
|
|
215
|
+
|
|
216
|
+
local stroke = Instance.new("UIStroke")
|
|
217
|
+
stroke.Color = palette.rule
|
|
218
|
+
stroke.Transparency = 0.75
|
|
219
|
+
stroke.Parent = row
|
|
220
|
+
|
|
221
|
+
--[[
|
|
222
|
+
The miniature. Clipped, because a preset draws to the edges of whatever
|
|
223
|
+
it is given and a swatch that spills over the row would land on its
|
|
224
|
+
neighbour.
|
|
225
|
+
]]
|
|
226
|
+
local swatch = Instance.new("Frame")
|
|
227
|
+
swatch.Name = "Swatch"
|
|
228
|
+
swatch.AnchorPoint = Vector2.new(0, 0.5)
|
|
229
|
+
swatch.Position = UDim2.new(0, 6, 0.5, 0)
|
|
230
|
+
swatch.Size = UDim2.fromOffset(40, 24)
|
|
231
|
+
swatch.BackgroundColor3 = palette.surface
|
|
232
|
+
swatch.BorderSizePixel = 0
|
|
233
|
+
swatch.ClipsDescendants = true
|
|
234
|
+
swatch.ZIndex = Z_ROW_CONTENT
|
|
235
|
+
swatch.Parent = row
|
|
236
|
+
|
|
237
|
+
local swatchCorner = Instance.new("UICorner")
|
|
238
|
+
swatchCorner.CornerRadius = UDim.new(0, 2)
|
|
239
|
+
swatchCorner.Parent = swatch
|
|
240
|
+
|
|
241
|
+
-- A preset that cannot draw its own thumbnail must not take the menu with
|
|
242
|
+
-- it; an empty swatch is a bad row, a broken drawer is a broken plugin.
|
|
243
|
+
local ok, err = pcall(theme.swatch, swatch, palette)
|
|
244
|
+
if not ok then
|
|
245
|
+
warn(string.format("[rbx-studio] theme %q failed to draw a swatch: %s", theme.id, tostring(err)))
|
|
246
|
+
end
|
|
247
|
+
-- Whatever the preset just built is at depth 1 and would be invisible.
|
|
248
|
+
stack(swatch, Z_ROW_CONTENT)
|
|
249
|
+
|
|
250
|
+
local label = Instance.new("TextLabel")
|
|
251
|
+
label.Name = "Label"
|
|
252
|
+
label.BackgroundTransparency = 1
|
|
253
|
+
label.Position = UDim2.new(0, 52, 0, 4)
|
|
254
|
+
label.Size = UDim2.new(1, -70, 0, 13)
|
|
255
|
+
label.Font = Enum.Font.Code
|
|
256
|
+
label.TextSize = 12
|
|
257
|
+
label.TextColor3 = palette.text
|
|
258
|
+
label.TextXAlignment = Enum.TextXAlignment.Left
|
|
259
|
+
label.Text = theme.label
|
|
260
|
+
label.ZIndex = Z_ROW_CONTENT
|
|
261
|
+
label.Parent = row
|
|
262
|
+
|
|
263
|
+
local blurb = Instance.new("TextLabel")
|
|
264
|
+
blurb.Name = "Blurb"
|
|
265
|
+
blurb.BackgroundTransparency = 1
|
|
266
|
+
blurb.Position = UDim2.new(0, 52, 0, 16)
|
|
267
|
+
blurb.Size = UDim2.new(1, -70, 0, 12)
|
|
268
|
+
blurb.Font = Enum.Font.Code
|
|
269
|
+
blurb.TextSize = 10
|
|
270
|
+
blurb.TextColor3 = palette.dim
|
|
271
|
+
blurb.TextXAlignment = Enum.TextXAlignment.Left
|
|
272
|
+
blurb.TextTruncate = Enum.TextTruncate.AtEnd
|
|
273
|
+
blurb.Text = theme.blurb
|
|
274
|
+
blurb.ZIndex = Z_ROW_CONTENT
|
|
275
|
+
blurb.Parent = row
|
|
276
|
+
|
|
277
|
+
local marker = Instance.new("TextLabel")
|
|
278
|
+
marker.Name = "Marker"
|
|
279
|
+
marker.AnchorPoint = Vector2.new(1, 0.5)
|
|
280
|
+
marker.Position = UDim2.new(1, -6, 0.5, 0)
|
|
281
|
+
marker.Size = UDim2.fromOffset(12, 12)
|
|
282
|
+
marker.BackgroundTransparency = 1
|
|
283
|
+
marker.Font = Enum.Font.Code
|
|
284
|
+
marker.TextSize = 11
|
|
285
|
+
marker.TextColor3 = palette.rule
|
|
286
|
+
marker.Text = ""
|
|
287
|
+
marker.ZIndex = Z_ROW_CONTENT
|
|
288
|
+
marker.Parent = row
|
|
289
|
+
|
|
290
|
+
row.MouseEnter:Connect(function()
|
|
291
|
+
hold()
|
|
292
|
+
stroke.Transparency = 0.2
|
|
293
|
+
end)
|
|
294
|
+
row.MouseLeave:Connect(function()
|
|
295
|
+
stroke.Transparency = 0.75
|
|
296
|
+
end)
|
|
297
|
+
|
|
298
|
+
row.MouseButton1Click:Connect(function()
|
|
299
|
+
local root = runtime.root
|
|
300
|
+
if root == nil then
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
-- Re-picking the current theme is a no-op rather than a free repaint of
|
|
304
|
+
-- three hundred log rows.
|
|
305
|
+
if not Themes.use(theme.id) then
|
|
306
|
+
setOpen(false)
|
|
307
|
+
return
|
|
308
|
+
end
|
|
309
|
+
local previous = root.BackgroundColor3
|
|
310
|
+
setOpen(false)
|
|
311
|
+
crossfade(root, previous, function()
|
|
312
|
+
local handler = runtime.onPicked
|
|
313
|
+
if handler then
|
|
314
|
+
handler(theme.id)
|
|
315
|
+
end
|
|
316
|
+
end)
|
|
317
|
+
markActive()
|
|
318
|
+
end)
|
|
319
|
+
|
|
320
|
+
table.insert(runtime.rows, { frame = row, id = theme.id, marker = marker })
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
--[[
|
|
324
|
+
Builds the tab and the drawer.
|
|
325
|
+
|
|
326
|
+
`onPicked` is called with the new id AFTER `Themes.use` has switched, so the
|
|
327
|
+
caller repaints and persists rather than this module knowing how either is
|
|
328
|
+
done -- `plugin:SetSetting` is not reachable from a ModuleScript anyway.
|
|
329
|
+
]]
|
|
330
|
+
function ThemePicker.mount(root: Frame, onPicked: (string) -> ())
|
|
331
|
+
runtime.root = root
|
|
332
|
+
runtime.onPicked = onPicked
|
|
333
|
+
runtime.rows = {}
|
|
334
|
+
|
|
335
|
+
local palette = Themes.palette()
|
|
336
|
+
|
|
337
|
+
local drawer = Instance.new("Frame")
|
|
338
|
+
drawer.Name = "ThemeDrawer"
|
|
339
|
+
-- Anchored to its own right edge at the tab's height: at width zero it is a
|
|
340
|
+
-- line where the tab is, which is what it grows out of.
|
|
341
|
+
drawer.AnchorPoint = Vector2.new(1, 0.5)
|
|
342
|
+
drawer.Position = UDim2.new(1, -TAB_WIDTH, 0.5, 0)
|
|
343
|
+
drawer.Size = UDim2.fromOffset(0, drawerHeight())
|
|
344
|
+
drawer.BackgroundColor3 = palette.surface
|
|
345
|
+
drawer.BorderSizePixel = 0
|
|
346
|
+
drawer.ClipsDescendants = true
|
|
347
|
+
drawer.ZIndex = Z_DRAWER
|
|
348
|
+
drawer.Parent = root
|
|
349
|
+
runtime.drawer = drawer
|
|
350
|
+
|
|
351
|
+
local drawerCorner = Instance.new("UICorner")
|
|
352
|
+
drawerCorner.CornerRadius = UDim.new(0, 4)
|
|
353
|
+
drawerCorner.Parent = drawer
|
|
354
|
+
|
|
355
|
+
local drawerStroke = Instance.new("UIStroke")
|
|
356
|
+
drawerStroke.Color = palette.rule
|
|
357
|
+
drawerStroke.Transparency = 0.6
|
|
358
|
+
drawerStroke.Parent = drawer
|
|
359
|
+
|
|
360
|
+
local list = Instance.new("Frame")
|
|
361
|
+
list.Name = "Rows"
|
|
362
|
+
list.Position = UDim2.fromOffset(PADDING, PADDING)
|
|
363
|
+
-- Sized to the open width rather than to the parent, so the rows do not
|
|
364
|
+
-- squash and reflow through every frame of the tween.
|
|
365
|
+
list.Size = UDim2.fromOffset(DRAWER_WIDTH - PADDING * 2, drawerHeight() - PADDING * 2)
|
|
366
|
+
list.BackgroundTransparency = 1
|
|
367
|
+
list.ZIndex = Z_LIST
|
|
368
|
+
list.Parent = drawer
|
|
369
|
+
|
|
370
|
+
local layout = Instance.new("UIListLayout")
|
|
371
|
+
layout.FillDirection = Enum.FillDirection.Vertical
|
|
372
|
+
layout.Padding = UDim.new(0, 2)
|
|
373
|
+
layout.SortOrder = Enum.SortOrder.LayoutOrder
|
|
374
|
+
layout.Parent = list
|
|
375
|
+
|
|
376
|
+
for order, theme in Themes.list() do
|
|
377
|
+
buildRow(list, theme, order)
|
|
378
|
+
end
|
|
379
|
+
markActive()
|
|
380
|
+
|
|
381
|
+
local tab = Instance.new("TextButton")
|
|
382
|
+
tab.Name = "ThemeTab"
|
|
383
|
+
tab.Text = ""
|
|
384
|
+
tab.AutoButtonColor = false
|
|
385
|
+
tab.AnchorPoint = Vector2.new(1, 0.5)
|
|
386
|
+
tab.Position = UDim2.new(1, 0, 0.5, 0)
|
|
387
|
+
tab.Size = UDim2.fromOffset(TAB_WIDTH, TAB_HEIGHT)
|
|
388
|
+
tab.BackgroundColor3 = palette.surface
|
|
389
|
+
tab.BorderSizePixel = 0
|
|
390
|
+
tab.ZIndex = Z_TAB
|
|
391
|
+
tab.Parent = root
|
|
392
|
+
runtime.tab = tab
|
|
393
|
+
|
|
394
|
+
local tabCorner = Instance.new("UICorner")
|
|
395
|
+
tabCorner.CornerRadius = UDim.new(0, 3)
|
|
396
|
+
tabCorner.Parent = tab
|
|
397
|
+
|
|
398
|
+
local chevron = Instance.new("TextLabel")
|
|
399
|
+
chevron.Name = "Chevron"
|
|
400
|
+
chevron.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
401
|
+
chevron.Position = UDim2.fromScale(0.5, 0.5)
|
|
402
|
+
chevron.Size = UDim2.fromOffset(TAB_WIDTH, TAB_WIDTH)
|
|
403
|
+
chevron.BackgroundTransparency = 1
|
|
404
|
+
chevron.Font = Enum.Font.Code
|
|
405
|
+
chevron.TextSize = 12
|
|
406
|
+
chevron.TextColor3 = palette.rule
|
|
407
|
+
chevron.Text = "\u{25C0}"
|
|
408
|
+
chevron.ZIndex = Z_TAB + 1
|
|
409
|
+
chevron.Parent = tab
|
|
410
|
+
runtime.chevron = chevron
|
|
411
|
+
|
|
412
|
+
tab.MouseEnter:Connect(function()
|
|
413
|
+
hold()
|
|
414
|
+
setOpen(true)
|
|
415
|
+
end)
|
|
416
|
+
tab.MouseLeave:Connect(scheduleClose)
|
|
417
|
+
drawer.MouseEnter:Connect(hold)
|
|
418
|
+
drawer.MouseLeave:Connect(scheduleClose)
|
|
419
|
+
|
|
420
|
+
-- Clicking works too. Hover is the affordance, but a tab that only responds
|
|
421
|
+
-- to hover is unreachable to anyone driving Studio from the keyboard or a
|
|
422
|
+
-- tablet, and this costs one line.
|
|
423
|
+
tab.MouseButton1Click:Connect(function()
|
|
424
|
+
hold()
|
|
425
|
+
setOpen(not runtime.open)
|
|
426
|
+
end)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
--[[
|
|
430
|
+
Recolours the tab and drawer chrome after a switch.
|
|
431
|
+
|
|
432
|
+
The ROWS are deliberately left alone: each one wears its own preset's
|
|
433
|
+
palette and always did, which is what makes the drawer a preview rather
|
|
434
|
+
than a list.
|
|
435
|
+
]]
|
|
436
|
+
function ThemePicker.applyTheme()
|
|
437
|
+
local palette = Themes.palette()
|
|
438
|
+
local drawer = runtime.drawer
|
|
439
|
+
local tab = runtime.tab
|
|
440
|
+
local chevron = runtime.chevron
|
|
441
|
+
|
|
442
|
+
if drawer then
|
|
443
|
+
drawer.BackgroundColor3 = palette.surface
|
|
444
|
+
local stroke = drawer:FindFirstChildOfClass("UIStroke")
|
|
445
|
+
if stroke then
|
|
446
|
+
stroke.Color = palette.rule
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
if tab then
|
|
450
|
+
tab.BackgroundColor3 = palette.surface
|
|
451
|
+
end
|
|
452
|
+
if chevron then
|
|
453
|
+
chevron.TextColor3 = palette.rule
|
|
454
|
+
end
|
|
455
|
+
markActive()
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
return ThemePicker
|