@el4cteo/rbx-studio-mcp 0.3.0 → 0.3.5

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/lib/pluginbuild.js +23 -13
  6. package/dist/lib/pluginbuild.js.map +1 -1
  7. package/dist/tools/api.js +20 -1
  8. package/dist/tools/api.js.map +1 -1
  9. package/dist/tools/debug.js +18 -8
  10. package/dist/tools/debug.js.map +1 -1
  11. package/dist/tools/discover.js +5 -0
  12. package/dist/tools/discover.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 +11 -1
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +27 -3
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/dist/tools/session.js +16 -0
  20. package/dist/tools/session.js.map +1 -1
  21. package/package.json +1 -1
  22. package/plugin/src/Config.luau +1 -1
  23. package/plugin/src/Console.luau +257 -116
  24. package/plugin/src/Paths.luau +138 -69
  25. package/plugin/src/Serialize.luau +43 -3
  26. package/plugin/src/ThemePicker.luau +458 -0
  27. package/plugin/src/Themes/Aurora.luau +147 -0
  28. package/plugin/src/Themes/Blueprint.luau +213 -0
  29. package/plugin/src/Themes/Draw.luau +177 -0
  30. package/plugin/src/Themes/Lattice.luau +304 -0
  31. package/plugin/src/Themes/Nebula.luau +182 -0
  32. package/plugin/src/Themes/Observatory.luau +200 -0
  33. package/plugin/src/Themes/Orbit.luau +268 -0
  34. package/plugin/src/Themes/Phosphor.luau +200 -0
  35. package/plugin/src/Themes/Theme.luau +121 -0
  36. package/plugin/src/Themes/Void.luau +230 -0
  37. package/plugin/src/Themes/init.luau +116 -0
  38. package/plugin/src/Visuals.luau +788 -907
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Instances.luau +35 -3
  42. package/plugin/src/handlers/Scripts.luau +58 -2
  43. package/plugin/src/init.server.luau +386 -354
@@ -18,39 +18,50 @@
18
18
  Instances, and colour spans do the same job in a fraction of the code.
19
19
  ]]
20
20
 
21
+ local ThemePicker = require(script.Parent.ThemePicker)
22
+ local Themes = require(script.Parent.Themes)
21
23
  local Visuals = require(script.Parent.Visuals)
22
24
 
23
25
  local Console = {}
24
26
 
25
- -- Ring buffer bound. Long enough to cover a working session, short enough that
26
- -- re-rendering the whole string on every append stays cheap.
27
- local MAX_LINES = 300
28
-
29
- -- Violet-tinted near-black. The cast is slight but it stops the panel reading
30
- -- as the same grey as every other Studio dock.
31
- local PALETTE = {
32
- background = Color3.fromRGB(11, 12, 20),
33
- surface = Color3.fromRGB(18, 20, 31),
34
- rule = Color3.fromRGB(167, 139, 250),
35
- text = Color3.fromRGB(206, 212, 226),
36
- dim = Color3.fromRGB(90, 97, 120),
37
- green = Color3.fromRGB(110, 231, 168),
38
- red = Color3.fromRGB(251, 113, 133),
39
- amber = Color3.fromRGB(251, 191, 36),
40
- cyan = Color3.fromRGB(103, 232, 249),
41
- violet = Color3.fromRGB(167, 139, 250),
42
- }
27
+ --[[
28
+ Ring buffer bound, counted in logged rows rather than in rendered lines.
29
+
30
+ It used to bound the rendered lines, which meant a burst of failures -- each
31
+ writing a message plus two wrapped lines of explanation -- evicted three
32
+ times as much history as a burst of successes. Bounding the records makes
33
+ "the last three hundred things that happened" mean what it says.
34
+ ]]
35
+ local MAX_RECORDS = 300
36
+
37
+ --[[
38
+ The active preset's colours, rebound rather than re-read.
39
+
40
+ Every use site in this file is a plain `PALETTE.dim`, and there are about
41
+ forty of them. Turning each into a function call to satisfy theming would
42
+ have been forty edits to working code for no gain: the palette changes only
43
+ when the user picks a different preset, so it is simply reassigned there and
44
+ the reads stay as they were.
45
+
46
+ The catch is that anything which COPIES a colour out of here keeps the old
47
+ one. Instances built in `mount` do exactly that, which is why `applyTheme`
48
+ has to walk them by hand.
49
+ ]]
50
+ local PALETTE: Themes.Palette = Themes.palette()
43
51
 
44
52
  -- Sigil plus colour per level. The sigil is what makes the log scannable at a
45
53
  -- glance; colour alone fails for anyone who cannot separate red from green.
46
- local LEVELS: { [string]: { sigil: string, color: Color3 } } = {
47
- ok = { sigil = "\u{25C6}", color = PALETTE.green },
48
- error = { sigil = "\u{2715}", color = PALETTE.red },
49
- warn = { sigil = "\u{25B2}", color = PALETTE.amber },
50
- info = { sigil = "\u{25C6}", color = PALETTE.violet },
51
- dim = { sigil = "\u{00B7}", color = PALETTE.dim },
52
- call = { sigil = "\u{25B8}", color = PALETTE.cyan },
53
- reply = { sigil = "\u{25C2}", color = PALETTE.violet },
54
+ -- Named by palette key rather than by colour, because this table is built once
55
+ -- at load and a colour captured then belongs to whichever preset happened to
56
+ -- be active at the time.
57
+ local LEVELS: { [string]: { sigil: string, key: string } } = {
58
+ ok = { sigil = "\u{25C6}", key = "green" },
59
+ error = { sigil = "\u{2715}", key = "red" },
60
+ warn = { sigil = "\u{25B2}", key = "amber" },
61
+ info = { sigil = "\u{25C6}", key = "violet" },
62
+ dim = { sigil = "\u{00B7}", key = "dim" },
63
+ call = { sigil = "\u{25B8}", key = "cyan" },
64
+ reply = { sigil = "\u{25C2}", key = "violet" },
54
65
  }
55
66
 
56
67
  export type Level = "ok" | "error" | "warn" | "info" | "dim" | "call" | "reply"
@@ -105,8 +116,28 @@ local function wrapDetail(detail: string): { string }
105
116
  return lines
106
117
  end
107
118
 
119
+ --[[
120
+ One logged row, before it is coloured.
121
+
122
+ Holding the parts rather than the finished string is what lets a theme
123
+ switch repaint history: see `renderRecord`.
124
+ ]]
125
+ type Record = {
126
+ level: Level,
127
+ message: string,
128
+ detail: string?,
129
+ stamp: string,
130
+ }
131
+
108
132
  type State = {
109
- lines: { string },
133
+ records: { Record },
134
+ -- The last status reported, replayed after a theme switch. Without it the
135
+ -- header has no way to know what colour it should be wearing.
136
+ status: string,
137
+ statusMeta: string,
138
+ -- The accent rule's gradient. A ColorSequence rather than a Color3, so it
139
+ -- cannot ride the generic role list.
140
+ ruleFade: UIGradient?,
110
141
  --[[
111
142
  Rows written, as opposed to lines rendered.
112
143
 
@@ -144,7 +175,10 @@ type State = {
144
175
  }
145
176
 
146
177
  local state: State = {
147
- lines = {},
178
+ records = {},
179
+ ruleFade = nil,
180
+ status = "disconnected",
181
+ statusMeta = "",
148
182
  entries = 0,
149
183
  label = nil,
150
184
  scroller = nil,
@@ -189,13 +223,89 @@ local function span(color: Color3, value: string): string
189
223
  return string.format('<font color="%s">%s</font>', hex(color), escape(value))
190
224
  end
191
225
 
226
+ --[[
227
+ Turns one record into the lines it occupies.
228
+
229
+ Rendering is deferred to here rather than done when the row is logged, which
230
+ is the change that made theming possible at all: a line that has already had
231
+ `#A78BFA` baked into it cannot be recoloured, so switching preset used to
232
+ leave the whole session's history in the previous theme's palette while new
233
+ rows arrived in the new one. Now nothing is coloured until it is painted.
234
+ ]]
235
+ local function renderRecord(record: Record): { string }
236
+ local spec = LEVELS[record.level] or LEVELS.info
237
+ local detail = record.detail
238
+
239
+ --[[
240
+ Measured in characters, not bytes.
241
+
242
+ `#body` counts bytes, and every sigil in this console is a 3-byte UTF-8
243
+ glyph, so it over-counted each line by two and pushed the latency column
244
+ two places left on exactly the lines that had a latency. The column was
245
+ never straight, and the cause was invisible until two different sigils
246
+ sat next to each other.
247
+ ]]
248
+ --[[
249
+ Only a detail that will actually sit in the column costs the message any
250
+ of its width. Cutting the message to make room for a detail that then
251
+ goes on its own line below would shorten it for nothing.
252
+ ]]
253
+ local detailWidth = if detail then (utf8.len(detail) or #detail) else 0
254
+ local inlineDetail = detail ~= nil
255
+ and (utf8.len(record.message) or #record.message) + detailWidth + 3
256
+ <= DETAIL_COLUMN + INLINE_DETAIL
257
+
258
+ local trimmed = record.message
259
+ local budget = DETAIL_COLUMN - 3
260
+ if inlineDetail and (utf8.len(trimmed) or #trimmed) > budget then
261
+ -- Cut rather than wrap. A wrapped line destroys the alignment and
262
+ -- carries the detail off the end of the visible width as well.
263
+ local offset = utf8.offset(trimmed, budget) or budget
264
+ trimmed = string.sub(trimmed, 1, offset - 1) .. utf8.char(0x2026)
265
+ end
266
+
267
+ local body = string.format("%s %s", spec.sigil, trimmed)
268
+ local bodyWidth = utf8.len(body) or #body
269
+ local lines = { span(PALETTE.dim, record.stamp) .. " " .. span(PALETTE[spec.key], body) }
270
+
271
+ --[[
272
+ Short details ride the right-hand column; long ones get their own lines.
273
+
274
+ The column exists for latencies -- "12ms" stacking into something
275
+ readable -- and it was applied to every detail regardless of length. A
276
+ sentence of prose therefore started at column 52, ran off the widget, and
277
+ wrapped back to column 0, so the explanation of a standby session came
278
+ out as a ragged block that began in the middle of the screen and ended at
279
+ the left edge. Anything that will not fit beside the message is better
280
+ off beneath it.
281
+ ]]
282
+ if detail then
283
+ if inlineDetail then
284
+ local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
285
+ lines[1] ..= span(PALETTE.dim, string.rep(" ", padding) .. detail)
286
+ else
287
+ for _, wrapped in wrapDetail(detail) do
288
+ table.insert(lines, span(PALETTE.dim, DETAIL_INDENT .. wrapped))
289
+ end
290
+ end
291
+ end
292
+ return lines
293
+ end
294
+
192
295
  local function paint()
193
296
  state.dirty = false
194
297
  local label = state.label
195
298
  if not label then
196
299
  return
197
300
  end
198
- label.Text = table.concat(state.lines, "\n")
301
+
302
+ local lines: { string } = {}
303
+ for _, record in state.records do
304
+ for _, line in renderRecord(record) do
305
+ table.insert(lines, line)
306
+ end
307
+ end
308
+ label.Text = table.concat(lines, "\n")
199
309
 
200
310
  -- Only follow the tail when the user has not scrolled up to read history.
201
311
  local scroller = state.scroller
@@ -261,64 +371,18 @@ end
261
371
  ragged offset.
262
372
  ]]
263
373
  function Console.log(level: Level, message: string, detail: string?)
264
- local spec = LEVELS[level] or LEVELS.info
265
-
266
- --[[
267
- Measured in characters, not bytes.
268
-
269
- `#body` counts bytes, and every sigil in this console is a 3-byte UTF-8
270
- glyph, so it over-counted each line by two and pushed the latency column
271
- two places left on exactly the lines that had a latency. The column was
272
- never straight, and the cause was invisible until two different sigils
273
- sat next to each other.
274
- ]]
275
- --[[
276
- Only a detail that will actually sit in the column costs the message any
277
- of its width. Cutting the message to make room for a detail that then
278
- goes on its own line below would shorten it for nothing.
279
- ]]
280
- local detailWidth = if detail then (utf8.len(detail) or #detail) else 0
281
- local inlineDetail = detail ~= nil
282
- and (utf8.len(message) or #message) + detailWidth + 3 <= DETAIL_COLUMN + INLINE_DETAIL
283
-
284
- local trimmed = message
285
- local budget = DETAIL_COLUMN - 3
286
- if inlineDetail and (utf8.len(trimmed) or #trimmed) > budget then
287
- -- Cut rather than wrap. A wrapped line destroys the alignment and
288
- -- carries the detail off the end of the visible width as well.
289
- local offset = utf8.offset(trimmed, budget) or budget
290
- trimmed = string.sub(trimmed, 1, offset - 1) .. utf8.char(0x2026)
291
- end
292
-
293
- local body = string.format("%s %s", spec.sigil, trimmed)
294
- local bodyWidth = utf8.len(body) or #body
295
- local line = span(PALETTE.dim, os.date("%H:%M:%S") :: string) .. " " .. span(spec.color, body)
296
-
297
- --[[
298
- Short details ride the right-hand column; long ones get their own lines.
299
-
300
- The column exists for latencies -- "12ms" stacking into something
301
- readable -- and it was applied to every detail regardless of length. A
302
- sentence of prose therefore started at column 52, ran off the widget, and
303
- wrapped back to column 0, so the explanation of a standby session came
304
- out as a ragged block that began in the middle of the screen and ended at
305
- the left edge. Anything that will not fit beside the message is better
306
- off beneath it.
307
- ]]
308
- table.insert(state.lines, line)
374
+ table.insert(state.records, {
375
+ level = level,
376
+ message = message,
377
+ detail = detail,
378
+ -- Stamped when the row happened, not when it is painted. A repaint after
379
+ -- a theme switch re-renders every line, and re-reading the clock there
380
+ -- would restamp the whole session to the moment the user changed colour.
381
+ stamp = os.date("%H:%M:%S") :: string,
382
+ })
309
383
  state.entries += 1
310
- if detail then
311
- if inlineDetail then
312
- local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
313
- state.lines[#state.lines] ..= span(PALETTE.dim, string.rep(" ", padding) .. detail)
314
- else
315
- for _, wrapped in wrapDetail(detail) do
316
- table.insert(state.lines, span(PALETTE.dim, DETAIL_INDENT .. wrapped))
317
- end
318
- end
319
- end
320
- while #state.lines > MAX_LINES do
321
- table.remove(state.lines, 1)
384
+ while #state.records > MAX_RECORDS do
385
+ table.remove(state.records, 1)
322
386
  end
323
387
  redraw()
324
388
  end
@@ -344,11 +408,11 @@ end
344
408
  and heavier still. Urgency drives both the spin rate and how strongly the
345
409
  colour takes over, so the two never disagree.
346
410
  ]]
347
- local KIND_LOOK: { [string]: { color: Color3, urgency: number } } = {
348
- read = { color = PALETTE.cyan, urgency = 0.3 },
349
- write = { color = PALETTE.violet, urgency = 0.7 },
350
- run = { color = PALETTE.green, urgency = 0.9 },
351
- debug = { color = PALETTE.amber, urgency = 0.5 },
411
+ local KIND_LOOK: { [string]: { key: string, urgency: number } } = {
412
+ read = { key = "cyan", urgency = 0.3 },
413
+ write = { key = "violet", urgency = 0.7 },
414
+ run = { key = "green", urgency = 0.9 },
415
+ debug = { key = "amber", urgency = 0.5 },
352
416
  }
353
417
 
354
418
  function Console.beginCall(title: string, kind: string)
@@ -356,7 +420,7 @@ function Console.beginCall(title: string, kind: string)
356
420
  state.generation += 1
357
421
  local look = KIND_LOOK[kind] or KIND_LOOK.read
358
422
  Visuals.setCaption(title)
359
- Visuals.setKind(look.color, look.urgency)
423
+ Visuals.setKind(PALETTE[look.key], look.urgency)
360
424
  refreshCounters()
361
425
  end
362
426
 
@@ -499,7 +563,7 @@ function Console.clear()
499
563
  the one that went.
500
564
  ]]
501
565
  local cleared = state.entries
502
- table.clear(state.lines)
566
+ table.clear(state.records)
503
567
  state.entries = 0
504
568
  state.calls = 0
505
569
  state.errors = 0
@@ -524,6 +588,9 @@ end
524
588
  visible without scrolling, however long the session has run.
525
589
  ]]
526
590
  function Console.setStatus(status: string, meta: string)
591
+ state.status = status
592
+ state.statusMeta = meta
593
+
527
594
  local dot = state.statusDot
528
595
  local text = state.statusText
529
596
  local metaLabel = state.metaText
@@ -550,14 +617,37 @@ function Console.setStatus(status: string, meta: string)
550
617
  Visuals.setTint(color)
551
618
  end
552
619
 
620
+ --[[
621
+ Records which palette colour an Instance is wearing, and puts it on.
622
+
623
+ The console builds about thirty Instances and copies a colour into each. A
624
+ copy does not follow the palette when the user picks a different preset, so
625
+ the panel would keep the old theme's header, rule, scrollbar and buttons
626
+ while the log underneath repainted -- which looks less like a theme than
627
+ like a half-finished render.
628
+
629
+ Rather than keep thirty named references and re-set thirty properties by
630
+ hand, each one declares the property and palette key it is wearing when it
631
+ is built, and `Console.applyTheme` walks the list. Adding a widget therefore
632
+ cannot forget to theme it: the same call that colours it registers it.
633
+ ]]
634
+ type Role = { instance: Instance, property: string, key: string }
635
+ local roles: { Role } = {}
636
+
637
+ local function themed<T>(instance: T & Instance, property: string, key: string): T
638
+ table.insert(roles, { instance = instance, property = property, key = key })
639
+ ;(instance :: any)[property] = PALETTE[key]
640
+ return instance
641
+ end
642
+
553
643
  local function makeButton(parent: Instance, text: string, order: number): TextButton
554
644
  local button = Instance.new("TextButton")
555
645
  button.Name = text
556
646
  button.Text = text
557
647
  button.Font = Enum.Font.Code
558
648
  button.TextSize = 11
559
- button.TextColor3 = PALETTE.dim
560
- button.BackgroundColor3 = PALETTE.background
649
+ themed(button, "TextColor3", "dim")
650
+ themed(button, "BackgroundColor3", "background")
561
651
  button.AutoButtonColor = false
562
652
  button.BorderSizePixel = 0
563
653
  button.Size = UDim2.new(0, 76, 0, 20)
@@ -569,7 +659,7 @@ local function makeButton(parent: Instance, text: string, order: number): TextBu
569
659
  corner.Parent = button
570
660
 
571
661
  local stroke = Instance.new("UIStroke")
572
- stroke.Color = PALETTE.dim
662
+ themed(stroke, "Color", "dim")
573
663
  stroke.Transparency = 0.6
574
664
  stroke.Parent = button
575
665
 
@@ -590,6 +680,10 @@ end
590
680
  export type Handlers = {
591
681
  onReconnect: () -> (),
592
682
  onClear: () -> (),
683
+ -- Called after a preset switch has already been applied, so the caller's
684
+ -- only job is to remember it. Persistence lives there because
685
+ -- `plugin:SetSetting` is not reachable from a ModuleScript.
686
+ onTheme: (string) -> (),
593
687
  }
594
688
 
595
689
  --[[
@@ -599,14 +693,14 @@ export type Handlers = {
599
693
  function Console.mount(parent: Instance, handlers: Handlers)
600
694
  local root = Instance.new("Frame")
601
695
  root.Size = UDim2.fromScale(1, 1)
602
- root.BackgroundColor3 = PALETTE.background
696
+ themed(root, "BackgroundColor3", "background")
603
697
  root.BorderSizePixel = 0
604
698
  root.Parent = parent
605
699
 
606
700
  -- Header ---------------------------------------------------------------
607
701
  local header = Instance.new("Frame")
608
702
  header.Size = UDim2.new(1, 0, 0, 32)
609
- header.BackgroundColor3 = PALETTE.surface
703
+ themed(header, "BackgroundColor3", "surface")
610
704
  header.BorderSizePixel = 0
611
705
  header.Parent = root
612
706
 
@@ -619,6 +713,10 @@ function Console.mount(parent: Instance, handlers: Handlers)
619
713
  dot.Text = "\u{25CF}"
620
714
  dot.Font = Enum.Font.Code
621
715
  dot.TextSize = 13
716
+ -- Deliberately NOT registered with `themed`. Red here is the value it
717
+ -- STARTS at, not the colour it wears: what it should be is whatever
718
+ -- `setStatus` last reported. Registering it meant every theme switch
719
+ -- repainted a connected session red.
622
720
  dot.TextColor3 = PALETTE.red
623
721
  dot.BackgroundTransparency = 1
624
722
  dot.Size = UDim2.new(0, 12, 1, 0)
@@ -641,7 +739,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
641
739
  meta.Text = ""
642
740
  meta.Font = Enum.Font.Code
643
741
  meta.TextSize = 11
644
- meta.TextColor3 = PALETTE.dim
742
+ themed(meta, "TextColor3", "dim")
645
743
  meta.TextXAlignment = Enum.TextXAlignment.Left
646
744
  meta.TextTruncate = Enum.TextTruncate.AtEnd
647
745
  meta.BackgroundTransparency = 1
@@ -663,11 +761,11 @@ function Console.mount(parent: Instance, handlers: Handlers)
663
761
  clientsChip.AnchorPoint = Vector2.new(1, 0.5)
664
762
  clientsChip.Position = UDim2.new(1, -252, 0.5, 0)
665
763
  clientsChip.Size = UDim2.new(0, 84, 0, 18)
666
- clientsChip.BackgroundColor3 = PALETTE.background
764
+ themed(clientsChip, "BackgroundColor3", "background")
667
765
  clientsChip.BorderSizePixel = 0
668
766
  clientsChip.Font = Enum.Font.Code
669
767
  clientsChip.TextSize = 11
670
- clientsChip.TextColor3 = PALETTE.cyan
768
+ themed(clientsChip, "TextColor3", "cyan")
671
769
  clientsChip.Text = ""
672
770
  clientsChip.Visible = false
673
771
  clientsChip.Parent = header
@@ -678,7 +776,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
678
776
  chipCorner.Parent = clientsChip
679
777
 
680
778
  local chipStroke = Instance.new("UIStroke")
681
- chipStroke.Color = PALETTE.cyan
779
+ themed(chipStroke, "Color", "cyan")
682
780
  chipStroke.Transparency = 0.6
683
781
  chipStroke.Parent = clientsChip
684
782
 
@@ -706,12 +804,13 @@ function Console.mount(parent: Instance, handlers: Handlers)
706
804
  local rule = Instance.new("Frame")
707
805
  rule.Position = UDim2.new(0, 0, 0, 32)
708
806
  rule.Size = UDim2.new(1, 0, 0, 1)
709
- rule.BackgroundColor3 = PALETTE.rule
807
+ themed(rule, "BackgroundColor3", "rule")
710
808
  rule.BorderSizePixel = 0
711
809
  rule.Parent = root
712
810
 
713
811
  local ruleFade = Instance.new("UIGradient")
714
812
  ruleFade.Color = ColorSequence.new(PALETTE.rule)
813
+ state.ruleFade = ruleFade
715
814
  ruleFade.Transparency = NumberSequence.new({
716
815
  NumberSequenceKeypoint.new(0, 0.15),
717
816
  NumberSequenceKeypoint.new(1, 0.85),
@@ -725,7 +824,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
725
824
  scroller.BackgroundTransparency = 1
726
825
  scroller.BorderSizePixel = 0
727
826
  scroller.ScrollBarThickness = 5
728
- scroller.ScrollBarImageColor3 = PALETTE.violet
827
+ themed(scroller, "ScrollBarImageColor3", "violet")
729
828
  scroller.ScrollBarImageTransparency = 0.5
730
829
  scroller.CanvasSize = UDim2.new()
731
830
  scroller.AutomaticCanvasSize = Enum.AutomaticSize.Y
@@ -747,7 +846,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
747
846
  label.Font = Enum.Font.Code
748
847
  label.TextSize = 12
749
848
  label.LineHeight = 1.25
750
- label.TextColor3 = PALETTE.text
849
+ themed(label, "TextColor3", "text")
751
850
  label.RichText = true
752
851
  label.TextWrapped = true
753
852
  label.TextXAlignment = Enum.TextXAlignment.Left
@@ -761,7 +860,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
761
860
  footer.AnchorPoint = Vector2.new(0, 1)
762
861
  footer.Position = UDim2.fromScale(0, 1)
763
862
  footer.Size = UDim2.new(1, 0, 0, 22)
764
- footer.BackgroundColor3 = PALETTE.surface
863
+ themed(footer, "BackgroundColor3", "surface")
765
864
  footer.BorderSizePixel = 0
766
865
  footer.Parent = root
767
866
 
@@ -774,7 +873,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
774
873
  prompt.Text = "rbx\u{00B7}studio"
775
874
  prompt.Font = Enum.Font.Code
776
875
  prompt.TextSize = 11
777
- prompt.TextColor3 = PALETTE.violet
876
+ themed(prompt, "TextColor3", "violet")
778
877
  prompt.TextXAlignment = Enum.TextXAlignment.Left
779
878
  prompt.BackgroundTransparency = 1
780
879
  prompt.Size = UDim2.new(0, 70, 1, 0)
@@ -793,7 +892,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
793
892
  counters.Text = "idle"
794
893
  counters.Font = Enum.Font.Code
795
894
  counters.TextSize = 11
796
- counters.TextColor3 = PALETTE.dim
895
+ themed(counters, "TextColor3", "dim")
797
896
  counters.TextXAlignment = Enum.TextXAlignment.Right
798
897
  counters.BackgroundTransparency = 1
799
898
  counters.AnchorPoint = Vector2.new(1, 0)
@@ -810,14 +909,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
810
909
  costs legibility is a bad trade however good it looks, and this is a
811
910
  console before it is anything else.
812
911
  ]]
813
- Visuals.mount(root, {
814
- background = PALETTE.background,
815
- surface = PALETTE.surface,
816
- text = PALETTE.text,
817
- dim = PALETTE.dim,
818
- violet = PALETTE.violet,
819
- red = PALETTE.red,
820
- })
912
+ Visuals.mount(root)
821
913
  local band = root:FindFirstChild("ActivityBand") :: Frame
822
914
  band.Position = UDim2.new(0, 0, 0, 33)
823
915
  band.Size = UDim2.new(1, 0, 0, Visuals.BAND_HEIGHT)
@@ -846,6 +938,20 @@ function Console.mount(parent: Instance, handlers: Handlers)
846
938
  visualsButton.Text = if Visuals.isVisible() then "\u{25C6} visuals" else "visuals"
847
939
  end)
848
940
 
941
+ --[[
942
+ The preset drawer, mounted last so its tab sits over everything.
943
+
944
+ It has to be the last child of `root` as well as the highest ZIndex: the
945
+ band, the log and the footer are all built before it and a drawer that
946
+ opens behind the log is a drawer nobody can click.
947
+ ]]
948
+ ThemePicker.mount(root, function(id)
949
+ Console.applyTheme()
950
+ ThemePicker.applyTheme()
951
+ layoutLog()
952
+ handlers.onTheme(id)
953
+ end)
954
+
849
955
  -- Treat "scrolled away from the bottom" as the user reading history, and
850
956
  -- stop yanking the view down under them until they scroll back.
851
957
  scroller:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
@@ -854,4 +960,39 @@ function Console.mount(parent: Instance, handlers: Handlers)
854
960
  end)
855
961
  end
856
962
 
963
+ --[[
964
+ Switches the whole panel to the active preset.
965
+
966
+ Three things have to move together or the switch looks broken: the palette
967
+ this file reads, the Instances that copied a colour out of it, and the log,
968
+ whose rows are re-rendered from records rather than recoloured in place. The
969
+ band goes last because remounting a preset is the expensive part and there
970
+ is no reason to make the text wait for it.
971
+ ]]
972
+ function Console.applyTheme()
973
+ PALETTE = Themes.palette()
974
+
975
+ for _, role in roles do
976
+ -- A widget can outlive its registration if the panel is rebuilt, and
977
+ -- writing to a destroyed Instance throws.
978
+ if role.instance.Parent ~= nil or role.instance:IsA("UIStroke") then
979
+ pcall(function()
980
+ (role.instance :: any)[role.property] = PALETTE[role.key]
981
+ end)
982
+ end
983
+ end
984
+
985
+ local ruleFade = state.ruleFade
986
+ if ruleFade then
987
+ ruleFade.Color = ColorSequence.new(PALETTE.rule)
988
+ end
989
+
990
+ refreshCounters()
991
+ -- Replayed rather than recoloured: the status light's colour is a fact about
992
+ -- the connection, and the only thing that knows it is `setStatus`.
993
+ Console.setStatus(state.status, state.statusMeta)
994
+ redraw()
995
+ Visuals.applyTheme()
996
+ end
997
+
857
998
  return Console