@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.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/input.js +38 -5
  12. package/dist/tools/input.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +9 -8
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +10 -1
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/package.json +1 -1
  20. package/plugin/src/Config.luau +1 -1
  21. package/plugin/src/Console.luau +301 -117
  22. package/plugin/src/Mirror.luau +126 -0
  23. package/plugin/src/Serialize.luau +43 -3
  24. package/plugin/src/ThemePicker.luau +458 -0
  25. package/plugin/src/Themes/Aurora.luau +161 -0
  26. package/plugin/src/Themes/Blueprint.luau +213 -0
  27. package/plugin/src/Themes/Draw.luau +177 -0
  28. package/plugin/src/Themes/Lattice.luau +304 -0
  29. package/plugin/src/Themes/Nebula.luau +182 -0
  30. package/plugin/src/Themes/Observatory.luau +200 -0
  31. package/plugin/src/Themes/Orbit.luau +268 -0
  32. package/plugin/src/Themes/Phosphor.luau +200 -0
  33. package/plugin/src/Themes/Theme.luau +121 -0
  34. package/plugin/src/Themes/Void.luau +266 -0
  35. package/plugin/src/Themes/init.luau +116 -0
  36. package/plugin/src/Undo.luau +29 -0
  37. package/plugin/src/Visuals.luau +838 -907
  38. package/plugin/src/handlers/Device.luau +21 -1
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Input.luau +583 -493
  42. package/plugin/src/handlers/Instances.luau +35 -3
  43. 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
- return true, text == "true", nil
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 match in string.gmatch(text, "-?%d+%.?%d*") do
192
- table.insert(numbers, tonumber(match) :: number)
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