@el4cteo/rbx-studio-mcp 0.3.5 → 0.3.7

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.
@@ -1,458 +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
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