@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
@@ -1,907 +1,838 @@
1
- --!strict
2
- --[[
3
- The console's activity band: a turning wireframe beside a live latency trace.
4
-
5
- Watching an agent work is mostly waiting, and the log answers "what is it
6
- doing" precisely while answering "is it still alive" badly -- a stalled
7
- session and a thinking one produce the same still screen.
8
-
9
- This began as a full-panel overlay and that was wrong. It sat on top of the
10
- log, so the thing you actually read was covered and, being slightly
11
- transparent, softened underneath. Decoration that costs legibility is a bad
12
- trade however good it looks. So it is a band now: a fixed strip above the
13
- log, which keeps every line at full contrast and never moves under the
14
- reader. The log shrinks by the band's height and nothing overlaps.
15
-
16
- The strip earns its space twice over. The wireframe on the left says the
17
- session is live; the trace beside it is the last forty calls, each a bar as
18
- tall as it was slow and coloured by whether it worked. That turns "is it
19
- alive" and "is it healthy" into one glance, which is more than the log gives
20
- and more than an animation alone would.
21
-
22
- The solid is a real projection, not a canned loop: vertices rotate in three
23
- dimensions and pass through a perspective divide every frame, so near edges
24
- are brighter and thicker because they genuinely are nearer. Roblox has no
25
- line primitive, so each edge is a thin Frame turned about its midpoint, and
26
- the frames are pooled -- allocating Instances inside a per-frame loop is how
27
- a decorative strip becomes a performance complaint.
28
- ]]
29
-
30
- local RunService = game:GetService("RunService")
31
-
32
- local Visuals = {}
33
-
34
- local PHI = (1 + math.sqrt(5)) / 2
35
-
36
- type Shape = {
37
- name: string,
38
- vertices: { Vector3 },
39
- edges: { { number } },
40
- }
41
-
42
- --[[
43
- Ordered by how busy they look. The session climbs this list as it works, so
44
- the shape itself reports load: an idle plugin shows a tetrahedron, a busy one
45
- an icosahedron.
46
- ]]
47
- local SHAPES: { Shape } = {
48
- {
49
- name = "tetrahedron",
50
- vertices = {
51
- Vector3.new(1, 1, 1),
52
- Vector3.new(1, -1, -1),
53
- Vector3.new(-1, 1, -1),
54
- Vector3.new(-1, -1, 1),
55
- },
56
- edges = { { 1, 2 }, { 1, 3 }, { 1, 4 }, { 2, 3 }, { 2, 4 }, { 3, 4 } },
57
- },
58
- {
59
- name = "octahedron",
60
- vertices = {
61
- Vector3.new(1, 0, 0),
62
- Vector3.new(-1, 0, 0),
63
- Vector3.new(0, 1, 0),
64
- Vector3.new(0, -1, 0),
65
- Vector3.new(0, 0, 1),
66
- Vector3.new(0, 0, -1),
67
- },
68
- edges = {
69
- { 1, 3 }, { 1, 4 }, { 1, 5 }, { 1, 6 },
70
- { 2, 3 }, { 2, 4 }, { 2, 5 }, { 2, 6 },
71
- { 3, 5 }, { 5, 4 }, { 4, 6 }, { 6, 3 },
72
- },
73
- },
74
- {
75
- name = "cube",
76
- vertices = {
77
- Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
78
- Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
79
- Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
80
- Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
81
- },
82
- edges = {
83
- { 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
84
- { 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
85
- { 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
86
- },
87
- },
88
- {
89
- name = "icosahedron",
90
- vertices = {
91
- Vector3.new(0, 1, PHI), Vector3.new(0, -1, PHI),
92
- Vector3.new(0, 1, -PHI), Vector3.new(0, -1, -PHI),
93
- Vector3.new(1, PHI, 0), Vector3.new(-1, PHI, 0),
94
- Vector3.new(1, -PHI, 0), Vector3.new(-1, -PHI, 0),
95
- Vector3.new(PHI, 0, 1), Vector3.new(PHI, 0, -1),
96
- Vector3.new(-PHI, 0, 1), Vector3.new(-PHI, 0, -1),
97
- },
98
- edges = {
99
- { 1, 2 }, { 1, 5 }, { 1, 6 }, { 1, 9 }, { 1, 11 },
100
- { 2, 7 }, { 2, 8 }, { 2, 9 }, { 2, 11 },
101
- { 3, 4 }, { 3, 5 }, { 3, 6 }, { 3, 10 }, { 3, 12 },
102
- { 4, 7 }, { 4, 8 }, { 4, 10 }, { 4, 12 },
103
- { 5, 6 }, { 5, 9 }, { 5, 10 },
104
- { 6, 11 }, { 6, 12 },
105
- { 7, 8 }, { 7, 9 }, { 7, 10 },
106
- { 8, 11 }, { 8, 12 },
107
- { 9, 10 }, { 11, 12 },
108
- },
109
- },
110
- }
111
-
112
- local MAX_EDGES = 30
113
- local MAX_VERTICES = 12
114
- local FOCAL = 3.4
115
-
116
- -- The inner solid, counter-rotating inside the outer one. An octahedron
117
- -- regardless of what is outside it: it reads as a core rather than as a second
118
- -- object competing for attention, and its 12 edges are cheap.
119
- local INNER_SHAPE = 2
120
- local INNER_SCALE = 0.42
121
-
122
- -- The strip's height, and the width reserved for the solid at its left end.
123
- -- The solid was originally as tall as the band and read as looming: it crowded
124
- -- the caption and made a 46px strip feel like a panel. It is an indicator, not
125
- -- the subject, so it sits small with air around it.
126
- --
127
- -- Both grew when the solid learned to overshoot. `cell` clips its descendants,
128
- -- so a shape that swells to 1.4x has to have somewhere to swell into, and the
129
- -- extra band height goes to the trace, whose bars are what people try to point
130
- -- at.
131
- Visuals.BAND_HEIGHT = 52
132
- local CELL = 40
133
- local GUTTER = 16
134
-
135
- --[[
136
- The solid's resting radius as a fraction of the cell.
137
-
138
- Tuned against the new cell so resting size is unchanged: 40 x 0.185 is 7.4px
139
- where 34 x 0.2 was 6.8px. At full overshoot the perspective divide pushes a
140
- near vertex to roughly 15px, which still clears the cell's 20px half-width.
141
- ]]
142
- local BASE_RADIUS = 0.185
143
-
144
- --[[
145
- Spring constants for the scale. Stiff enough that a call reads as a snap
146
- rather than a swell, damped just under critical so a reply overshoots once
147
- and settles instead of ringing.
148
- ]]
149
- local SPRING = 90
150
- local DAMPING = 14
151
-
152
- -- How long the session must go without a command before the solid winds down.
153
- -- Matches the console's idle notice, so the picture and the log agree.
154
- local QUIET_AFTER = 20
155
-
156
- -- How many calls the trace remembers. Forty is about a screen's width of bars
157
- -- at two pixels each, and about as far back as anyone reads a trend.
158
- local TRACE = 40
159
-
160
- -- Bars are scaled against this, so a normal call is a low bar and a slow one is
161
- -- obvious. Anything past it clamps to full height rather than flattening the
162
- -- rest of the trace into invisibility.
163
- local SLOW_MS = 400
164
-
165
- -- Pixels from one bar to the next. Also the width of a bar's hit target and of
166
- -- the hover highlight, so pointing between two bars still selects one of them
167
- -- rather than falling into a dead gap.
168
- local BAR_STRIDE = 5
169
-
170
- -- Height of the trace, and so of every bar's hit column. The taller band spends
171
- -- its extra pixels here: a 19px column was a small thing to aim a pointer at.
172
- local TRACE_HEIGHT = 26
173
-
174
- type Bar = {
175
- frame: Frame,
176
- --[[
177
- An invisible column over the bar, full height of the trace, wide enough to
178
- hit. The bar itself is three pixels wide and usually one pixel tall -- a
179
- fast call is a stub on the baseline -- so hovering the bar is not something
180
- a person can reliably do. The hit target is the whole column above it, so
181
- pointing anywhere near a bar selects it.
182
- ]]
183
- hit: TextButton,
184
- milliseconds: number,
185
- ok: boolean,
186
- --[[
187
- What ran, in the same words the log uses.
188
-
189
- The trace plotted durations with nothing to attach them to, so a tall bar
190
- raised the question it could not answer: forty numbers and no way to tell
191
- which of them was the script edit. Carried per bar rather than looked up,
192
- because the bar outlives whatever produced it.
193
- ]]
194
- title: string,
195
- }
196
-
197
- type Runtime = {
198
- band: Frame?,
199
- cell: Frame?,
200
- trace: Frame?,
201
- highlight: Frame?,
202
- readout: TextLabel?,
203
- caption: TextLabel?,
204
- edges: { Frame },
205
- innerEdges: { Frame },
206
- dots: { Frame },
207
- bars: { Bar },
208
- connection: RBXScriptConnection?,
209
-
210
- spin: Vector3,
211
- angle: Vector3,
212
- shapeIndex: number,
213
-
214
- energy: number,
215
- shake: number,
216
- --[[
217
- The solid's size, as a spring rather than a value.
218
-
219
- Scale was previously a small function of energy -- a 24% range that
220
- nobody could see. Driving it as a spring means each event can push it and
221
- let physics do the rest: a dispatch pulls it in, a reply throws it out
222
- past its resting size, and it settles on its own.
223
- ]]
224
- scale: number,
225
- scaleVelocity: number,
226
- -- Brightens and fattens the vertices for a moment after a call lands.
227
- flare: number,
228
- -- Seconds since anything last ran. Drives the wind-down.
229
- quiet: number,
230
- tint: Color3,
231
- targetTint: Color3,
232
- baseTint: Color3,
233
- activeTint: Color3,
234
- activity: number,
235
- lastTitle: string?,
236
- palette: { [string]: Color3 },
237
- }
238
-
239
- local runtime: Runtime = {
240
- band = nil,
241
- cell = nil,
242
- trace = nil,
243
- highlight = nil,
244
- readout = nil,
245
- caption = nil,
246
- edges = {},
247
- innerEdges = {},
248
- dots = {},
249
- bars = {},
250
- connection = nil,
251
- spin = Vector3.new(0.18, 0.27, 0.11),
252
- angle = Vector3.zero,
253
- shapeIndex = 1,
254
- energy = 0,
255
- shake = 0,
256
- scale = 1,
257
- scaleVelocity = 0,
258
- flare = 0,
259
- quiet = 0,
260
- tint = Color3.fromRGB(167, 139, 250),
261
- targetTint = Color3.fromRGB(167, 139, 250),
262
- baseTint = Color3.fromRGB(167, 139, 250),
263
- activeTint = Color3.fromRGB(167, 139, 250),
264
- activity = 0,
265
- lastTitle = nil,
266
- palette = {},
267
- }
268
-
269
- local function rotate(point: Vector3, angle: Vector3): Vector3
270
- local sinX, cosX = math.sin(angle.X), math.cos(angle.X)
271
- local sinY, cosY = math.sin(angle.Y), math.cos(angle.Y)
272
- local sinZ, cosZ = math.sin(angle.Z), math.cos(angle.Z)
273
-
274
- local y1 = point.Y * cosX - point.Z * sinX
275
- local z1 = point.Y * sinX + point.Z * cosX
276
- local x2 = point.X * cosY + z1 * sinY
277
- local z2 = -point.X * sinY + z1 * cosY
278
- local x3 = x2 * cosZ - y1 * sinZ
279
- local y3 = x2 * sinZ + y1 * cosZ
280
-
281
- return Vector3.new(x3, y3, z2)
282
- end
283
-
284
- --[[
285
- One edge as a rectangle turned about its midpoint. `Rotation` pivots a
286
- GuiObject around its centre, which is exactly what a segment needs.
287
- ]]
288
- local function drawEdge(frame: Frame, a: Vector2, b: Vector2, thickness: number, color: Color3, transparency: number)
289
- local delta = b - a
290
- local length = delta.Magnitude
291
- if length < 0.5 then
292
- frame.Visible = false
293
- return
294
- end
295
- frame.Visible = true
296
- frame.Position = UDim2.fromOffset((a.X + b.X) / 2, (a.Y + b.Y) / 2)
297
- frame.Size = UDim2.fromOffset(math.ceil(length), thickness)
298
- frame.Rotation = math.deg(math.atan2(delta.Y, delta.X))
299
- frame.BackgroundColor3 = color
300
- frame.BackgroundTransparency = transparency
301
- end
302
-
303
- local function step(delta: number)
304
- local band = runtime.band
305
- if band == nil or not band.Visible then
306
- return
307
- end
308
-
309
- runtime.energy = math.max(0, runtime.energy - delta * 0.9)
310
- runtime.shake = math.max(0, runtime.shake - delta * 2.4)
311
- --[[
312
- Slower than it was. The shape is meant to climb as the session works, and
313
- at the old rates a call had to arrive every three seconds just to hold the
314
- octahedron -- so the icosahedron at the top of the list was, in practice,
315
- unreachable. A burst of four calls now gets there, and it unwinds over
316
- about ten seconds afterwards.
317
- ]]
318
- runtime.activity = math.max(0, runtime.activity - delta * 0.28)
319
- runtime.flare = math.max(0, runtime.flare - delta * 3)
320
- runtime.quiet += delta
321
- --[[
322
- Two colours, blended by how recently something happened.
323
-
324
- `baseTint` is the connection state, which is what the panel should read
325
- as when nothing is going on. `activeTint` is the kind of work last done
326
- -- reading, writing, running, debugging -- and it takes over while that
327
- work is fresh, then fades back. So the strip is not one fixed colour: it
328
- leans toward whatever the session is actually doing and returns to
329
- resting on its own.
330
- ]]
331
- local blend = math.clamp(runtime.energy, 0, 1)
332
- runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
333
- runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
334
-
335
- -- Turning faster while busy makes the rate itself readable: a glance says
336
- -- whether anything is happening without reading a word.
337
- --
338
- -- And nearly stopping once the session has gone quiet, for the same reason in
339
- -- reverse: a strip that keeps spinning at its working rate hours after the
340
- -- last command claims an activity that is not happening.
341
- local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
342
- runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
343
-
344
- --[[
345
- Where the solid wants to be, before the spring gets a say.
346
-
347
- Three things move it and they are deliberately ordered. Energy -- how
348
- recently work happened -- swells it while a session is busy. The breathe
349
- is a slow sine that only survives while energy is near zero, so an idle
350
- panel is visibly alive without a resting animation competing with the
351
- reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
352
- in and stays there: the picture of a session that has stopped.
353
- ]]
354
- local calm = 1 - math.clamp(runtime.energy, 0, 1)
355
- local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
356
- local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
357
- if runtime.quiet > QUIET_AFTER then
358
- target = 0.82
359
- end
360
-
361
- --[[
362
- A damped spring rather than a lerp, because a lerp cannot overshoot and
363
- overshoot is the whole point: a reply that pushes the solid past its
364
- resting size and lets it fall back reads as a thing being struck, where
365
- easing toward a value reads as a slider being dragged.
366
-
367
- Integrated semi-implicitly -- velocity first, then position from the new
368
- velocity -- which stays stable at the frame times Studio actually hands us
369
- rather than only at small ones.
370
- ]]
371
- local step = math.min(delta, 1 / 30)
372
- runtime.scaleVelocity += (target - runtime.scale) * SPRING * step
373
- runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * step
374
- runtime.scale += runtime.scaleVelocity * step
375
- runtime.scale = math.clamp(runtime.scale, 0.55, 1.7)
376
-
377
- local shape = SHAPES[runtime.shapeIndex]
378
- local centre = Vector2.new(CELL / 2, CELL / 2)
379
- -- Kept clear of the cell's edge. The perspective divide pushes a near vertex
380
- -- outward, so sizing to the full half-width clipped the solid against its own
381
- -- container whenever a corner swung toward the viewer.
382
- local radius = CELL * BASE_RADIUS * runtime.scale
383
-
384
- -- Wobble the axis rather than the panel: shaking the strip would read as a
385
- -- glitch, tilting the solid reads as it being knocked.
386
- local wobble = if runtime.shake > 0
387
- then Vector3.new(
388
- math.sin(os.clock() * 37) * runtime.shake * 0.12,
389
- math.cos(os.clock() * 29) * runtime.shake * 0.12,
390
- 0
391
- )
392
- else Vector3.zero
393
-
394
- local projected: { Vector2 } = {}
395
- local depths: { number } = {}
396
- for index, vertex in shape.vertices do
397
- local turned = rotate(vertex, runtime.angle + wobble)
398
- local scale = FOCAL / (FOCAL - turned.Z)
399
- projected[index] = centre + Vector2.new(turned.X * scale * radius, turned.Y * scale * radius)
400
- depths[index] = turned.Z
401
- end
402
-
403
- for index = 1, MAX_EDGES do
404
- local frame = runtime.edges[index]
405
- local edge = shape.edges[index]
406
- if edge == nil then
407
- frame.Visible = false
408
- continue
409
- end
410
- local a, b = edge[1], edge[2]
411
- local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
412
- drawEdge(
413
- frame,
414
- projected[a],
415
- projected[b],
416
- math.max(1, math.floor(1 + depth * 1.6 + runtime.flare * 0.8)),
417
- runtime.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35 + runtime.flare * 0.3),
418
- math.max(0, 0.84 - depth * 0.74 - runtime.flare * 0.3)
419
- )
420
- end
421
-
422
- --[[
423
- Vertices as points, brighter than the edges they join.
424
-
425
- Wireframes read as flat when every stroke is the same weight. Lighting
426
- the corners gives the eye something to track as the solid turns, which is
427
- what makes it read as rotating rather than merely flickering.
428
- ]]
429
- for index = 1, MAX_VERTICES do
430
- local dot = runtime.dots[index]
431
- local point = projected[index]
432
- if point == nil then
433
- dot.Visible = false
434
- continue
435
- end
436
- local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
437
- -- The corners carry the flare. A pulse of light travelling through the
438
- -- vertices is what makes a completed call land as an event rather than as
439
- -- a bar quietly appearing somewhere off to the right.
440
- local size = math.max(2, math.floor(1.5 + depth * 2.5 + runtime.flare * 2.5))
441
- dot.Visible = true
442
- dot.Position = UDim2.fromOffset(point.X, point.Y)
443
- dot.Size = UDim2.fromOffset(size, size)
444
- dot.BackgroundColor3 = runtime.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5)
445
- dot.BackgroundTransparency = math.max(0, 0.55 - depth * 0.5 - runtime.flare * 0.45)
446
- end
447
-
448
- --[[
449
- A second solid inside the first, turning the other way.
450
-
451
- One rotating outline is a loading spinner. Two, at different rates and
452
- opposite directions, read as a mechanism -- and the counter-rotation
453
- makes the outer solid's direction obvious, which a lone wireframe hides
454
- whenever it passes through a symmetrical angle.
455
- ]]
456
- local inner = SHAPES[INNER_SHAPE]
457
- local innerAngle = -runtime.angle * 1.6
458
- local innerProjected: { Vector2 } = {}
459
- local innerDepths: { number } = {}
460
- for index, vertex in inner.vertices do
461
- local turned = rotate(vertex, innerAngle)
462
- local scale = FOCAL / (FOCAL - turned.Z)
463
- innerProjected[index] = centre
464
- + Vector2.new(turned.X * scale * radius * INNER_SCALE, turned.Y * scale * radius * INNER_SCALE)
465
- innerDepths[index] = turned.Z
466
- end
467
- for index, frame in runtime.innerEdges do
468
- local edge = inner.edges[index]
469
- if edge == nil then
470
- frame.Visible = false
471
- continue
472
- end
473
- local a, b = edge[1], edge[2]
474
- local depth = math.clamp(((innerDepths[a] + innerDepths[b]) / 2 + 2) / 4, 0, 1)
475
- drawEdge(
476
- frame,
477
- innerProjected[a],
478
- innerProjected[b],
479
- 1,
480
- runtime.tint:Lerp(Color3.new(1, 1, 1), 0.5),
481
- 0.9 - depth * 0.35
482
- )
483
- end
484
- end
485
-
486
- --[[
487
- Puts the highlight behind one bar and prints its timing.
488
-
489
- The trace is only legible if you already know what it plots, and nothing on
490
- screen said so. Hovering answers it directly: the column lights up, and the
491
- number that made that bar its height appears in words. One reading at a time,
492
- so a single highlight and a single label are moved around rather than one of
493
- each being built per bar.
494
- ]]
495
- local function showReading(entry: Bar)
496
- local highlight = runtime.highlight
497
- local readout = runtime.readout
498
- if highlight == nil or readout == nil then
499
- return
500
- end
501
-
502
- --[[
503
- The Y here has to be 1, and was 0.
504
-
505
- The highlight is anchored to its own bottom edge, so a Y scale of 0 put
506
- that edge on the trace's top line and drew the whole 26px column above it
507
- -- outside a frame that clips its descendants. The hover has therefore
508
- never been visible to anyone: the hit targets fired, the readout appeared,
509
- and the highlight they were meant to explain was off screen every time.
510
- ]]
511
- highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
512
- highlight.Visible = true
513
-
514
- --[[
515
- Named, not just timed.
516
-
517
- A duration with nothing attached to it raises the question it cannot
518
- answer -- forty bars, one of them tall, and no way to tell whether that
519
- was a script edit or a screenshot. The title is the same phrase the log
520
- row uses, so pointing at a bar and reading the log agree with each other.
521
- ]]
522
- readout.Text = if entry.ok
523
- then string.format("%s: %d ms", entry.title, math.round(entry.milliseconds))
524
- else string.format("%s: failed after %d ms", entry.title, math.round(entry.milliseconds))
525
- readout.TextColor3 = if entry.ok
526
- then runtime.palette.text or Color3.fromRGB(226, 232, 240)
527
- else runtime.palette.red or Color3.fromRGB(251, 113, 133)
528
- readout.Visible = true
529
- end
530
-
531
- local function clearReading()
532
- if runtime.highlight then
533
- runtime.highlight.Visible = false
534
- end
535
- if runtime.readout then
536
- runtime.readout.Visible = false
537
- end
538
- end
539
-
540
- --[[
541
- Records one finished call as a bar on the trace.
542
-
543
- Height is duration and colour is outcome, so a slow call and a failed one are
544
- distinguishable at a glance -- which is the pair of questions that actually
545
- get asked of a log this size.
546
- ]]
547
- function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
548
- runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
549
- runtime.activity = math.min(1, runtime.activity + 0.34)
550
- runtime.shapeIndex = math.clamp(1 + math.floor(runtime.activity * (#SHAPES - 0.001)), 1, #SHAPES)
551
- runtime.quiet = 0
552
- runtime.flare = 1
553
-
554
- --[[
555
- A shove rather than a new target.
556
-
557
- Setting the scale outright would snap; giving the spring velocity lets it
558
- carry past its resting size and fall back, which is the difference between
559
- a value changing and something being struck. A failure shoves the other
560
- way and knocks the axis with it.
561
- ]]
562
- if ok then
563
- runtime.scaleVelocity += 9
564
- else
565
- runtime.scale = 0.8
566
- runtime.scaleVelocity = -2
567
- runtime.shake = 1
568
- end
569
-
570
- local trace = runtime.trace
571
- if trace == nil then
572
- return
573
- end
574
-
575
- local bar = Instance.new("Frame")
576
- bar.AnchorPoint = Vector2.new(0, 1)
577
- bar.BorderSizePixel = 0
578
- bar.Parent = trace
579
-
580
- -- A TextButton rather than a Frame: buttons take mouse events reliably in a
581
- -- plugin widget, and with no text and no background it is purely a target.
582
- local hit = Instance.new("TextButton")
583
- hit.AnchorPoint = Vector2.new(0.5, 1)
584
- hit.BackgroundTransparency = 1
585
- hit.Text = ""
586
- hit.AutoButtonColor = false
587
- hit.BorderSizePixel = 0
588
- hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
589
- hit.Parent = trace
590
-
591
- local entry: Bar = {
592
- frame = bar,
593
- hit = hit,
594
- milliseconds = milliseconds,
595
- ok = ok,
596
- title = if title ~= "" then title else "call",
597
- }
598
- table.insert(runtime.bars, entry)
599
-
600
- hit.MouseEnter:Connect(function()
601
- showReading(entry)
602
- end)
603
- hit.MouseLeave:Connect(function()
604
- clearReading()
605
- end)
606
-
607
- while #runtime.bars > TRACE do
608
- local oldest = runtime.bars[1]
609
- oldest.frame:Destroy()
610
- oldest.hit:Destroy()
611
- table.remove(runtime.bars, 1)
612
- end
613
-
614
- -- Laid out right to left so the newest bar is always at the same edge and
615
- -- the trace reads as scrolling rather than reshuffling.
616
- local total = #runtime.bars
617
- for index, item in runtime.bars do
618
- local fromRight = total - index
619
- local height = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1)
620
- local offset = -(fromRight + 1) * BAR_STRIDE
621
- item.frame.Position = UDim2.new(1, offset, 1, 0)
622
- item.frame.Size = UDim2.new(0, 3, height, 0)
623
- item.frame.BackgroundColor3 = if item.ok
624
- then runtime.palette.violet or runtime.tint
625
- else runtime.palette.red or Color3.fromRGB(251, 113, 133)
626
- item.frame.BackgroundTransparency = 0.15 + (fromRight / TRACE) * 0.6
627
- -- Centred on the bar, so the column a person points at is the one they get.
628
- item.hit.Position = UDim2.new(1, offset + 1, 1, 0)
629
- end
630
-
631
- -- The bars have all moved, so a reading still on screen now names the wrong
632
- -- one. Cheaper and more honest to drop it than to work out which bar the
633
- -- pointer has ended up over.
634
- clearReading()
635
- end
636
-
637
- --[[
638
- The resting colour, which is the connection state.
639
- ]]
640
- function Visuals.setTint(color: Color3)
641
- runtime.baseTint = color
642
- end
643
-
644
- --[[
645
- The colour and pace of the work now running.
646
-
647
- Both are set from the same call because they describe the same thing: what
648
- kind of command this is. Reads are quick and cool, writes are slower and
649
- warm, so the strip's colour and its rate agree with each other instead of
650
- moving independently.
651
- ]]
652
- function Visuals.setKind(color: Color3, urgency: number)
653
- runtime.activeTint = color
654
- runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
655
- runtime.quiet = 0
656
- --[[
657
- Drawn in as the command goes out, so the pair reads as one gesture: the
658
- solid contracts while the request is in flight and springs open when the
659
- answer arrives. On a fast call the two are almost one motion, which is
660
- itself the report -- a slow call visibly holds its breath.
661
- ]]
662
- runtime.scale = math.min(runtime.scale, 0.85)
663
- runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
664
- -- Re-aimed per kind so successive commands of different types visibly
665
- -- change the axis rather than continuing the same turn.
666
- runtime.spin = Vector3.new(
667
- 0.12 + math.random() * 0.16,
668
- 0.18 + math.random() * 0.22,
669
- 0.06 + math.random() * 0.12
670
- )
671
- end
672
-
673
- --[[
674
- What is running, in the same words the log uses. The motion says something is
675
- happening; this says what, and neither answers the other's question.
676
- ]]
677
- function Visuals.setCaption(title: string)
678
- runtime.lastTitle = title
679
- if runtime.caption then
680
- runtime.caption.Text = title
681
- end
682
- end
683
-
684
- --[[
685
- Falls back to the last thing that ran once a command finishes.
686
-
687
- The footer already reports totals, so repeating "idle" here would say the
688
- same word twice on one screen. What has just happened is more useful and is
689
- not shown anywhere else.
690
- ]]
691
- function Visuals.setIdle()
692
- if runtime.caption == nil then
693
- return
694
- end
695
- runtime.caption.Text = if runtime.lastTitle ~= nil
696
- then "last: " .. runtime.lastTitle
697
- else "waiting for a command"
698
- end
699
-
700
- --[[
701
- Winds the solid down, because the session has stopped rather than paused.
702
-
703
- The strip already decays on its own, but decay bottoms out at "idle and
704
- turning", which looks the same after twenty seconds as after two hours. This
705
- is the console telling the picture what it has just told the log, so the two
706
- agree: the shape falls back to a tetrahedron, the spin drops to a quarter
707
- pace, and the solid draws in.
708
- ]]
709
- function Visuals.setQuiet()
710
- runtime.quiet = QUIET_AFTER + 1
711
- runtime.activity = 0
712
- runtime.shapeIndex = 1
713
- end
714
-
715
- --[[
716
- Empties the trace.
717
-
718
- Paired with the console's clear button. The bars used to survive it while the
719
- footer counters reset, so "clear" wiped the log, wiped the statistics, and
720
- left forty timings from the session it had just erased sitting on screen.
721
- ]]
722
- function Visuals.clearTrace()
723
- for _, entry in runtime.bars do
724
- entry.frame:Destroy()
725
- entry.hit:Destroy()
726
- end
727
- table.clear(runtime.bars)
728
- clearReading()
729
- end
730
-
731
- function Visuals.setVisible(visible: boolean)
732
- local band = runtime.band
733
- if band == nil then
734
- return
735
- end
736
- band.Visible = visible
737
-
738
- -- Connected only while on screen: a hidden strip that keeps projecting
739
- -- geometry every frame is a battery complaint waiting to happen.
740
- if visible and runtime.connection == nil then
741
- runtime.connection = RunService.Heartbeat:Connect(step)
742
- elseif not visible and runtime.connection ~= nil then
743
- runtime.connection:Disconnect()
744
- runtime.connection = nil
745
- end
746
- end
747
-
748
- function Visuals.isVisible(): boolean
749
- local band = runtime.band
750
- return band ~= nil and band.Visible
751
- end
752
-
753
- --[[
754
- Builds the strip. Every edge frame is created here and never again, which is
755
- what keeps the per-frame path allocation-free.
756
- ]]
757
- function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
758
- runtime.palette = palette
759
-
760
- local band = Instance.new("Frame")
761
- band.Name = "ActivityBand"
762
- band.BackgroundColor3 = palette.surface
763
- band.BorderSizePixel = 0
764
- band.Visible = false
765
- band.ClipsDescendants = true
766
- band.Parent = parent
767
- runtime.band = band
768
-
769
- local cell = Instance.new("Frame")
770
- cell.Name = "Solid"
771
- cell.Position = UDim2.fromOffset(6, (Visuals.BAND_HEIGHT - CELL) / 2)
772
- cell.Size = UDim2.fromOffset(CELL, CELL)
773
- cell.BackgroundTransparency = 1
774
- cell.ClipsDescendants = true
775
- cell.Parent = band
776
- runtime.cell = cell
777
-
778
- for index = 1, MAX_EDGES do
779
- local edge = Instance.new("Frame")
780
- edge.Name = string.format("Edge%02d", index)
781
- edge.AnchorPoint = Vector2.new(0.5, 0.5)
782
- edge.BorderSizePixel = 0
783
- edge.Visible = false
784
- edge.Parent = cell
785
- runtime.edges[index] = edge
786
- end
787
-
788
- -- A hairline between the solid and the trace, so the strip reads as two
789
- -- instruments rather than one busy rectangle.
790
- local divider = Instance.new("Frame")
791
- divider.Position = UDim2.fromOffset(CELL + GUTTER / 2, 8)
792
- divider.Size = UDim2.new(0, 1, 1, -16)
793
- divider.BackgroundColor3 = palette.dim
794
- divider.BackgroundTransparency = 0.7
795
- divider.BorderSizePixel = 0
796
- divider.Parent = band
797
-
798
- for index = 1, MAX_VERTICES do
799
- local dot = Instance.new("Frame")
800
- dot.Name = string.format("Vertex%02d", index)
801
- dot.AnchorPoint = Vector2.new(0.5, 0.5)
802
- dot.BorderSizePixel = 0
803
- dot.Visible = false
804
- dot.Parent = cell
805
- local round = Instance.new("UICorner")
806
- round.CornerRadius = UDim.new(1, 0)
807
- round.Parent = dot
808
- runtime.dots[index] = dot
809
- end
810
-
811
- for index = 1, #SHAPES[INNER_SHAPE].edges do
812
- local edge = Instance.new("Frame")
813
- edge.Name = string.format("Inner%02d", index)
814
- edge.AnchorPoint = Vector2.new(0.5, 0.5)
815
- edge.BorderSizePixel = 0
816
- edge.Visible = false
817
- edge.Parent = cell
818
- runtime.innerEdges[index] = edge
819
- end
820
-
821
- local caption = Instance.new("TextLabel")
822
- caption.Position = UDim2.new(0, CELL + GUTTER, 0, 4)
823
- caption.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, 14)
824
- caption.BackgroundTransparency = 1
825
- caption.Font = Enum.Font.Code
826
- caption.TextSize = 11
827
- caption.TextColor3 = palette.text
828
- caption.TextXAlignment = Enum.TextXAlignment.Left
829
- caption.TextTruncate = Enum.TextTruncate.AtEnd
830
- caption.Text = "idle"
831
- caption.Parent = band
832
- runtime.caption = caption
833
-
834
- local trace = Instance.new("Frame")
835
- trace.Name = "Trace"
836
- trace.Position = UDim2.new(0, CELL + GUTTER, 0, 21)
837
- trace.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, TRACE_HEIGHT)
838
- trace.BackgroundTransparency = 1
839
- trace.ClipsDescendants = true
840
- trace.Parent = band
841
- runtime.trace = trace
842
-
843
- -- A baseline under the bars, so an empty trace still reads as an instrument
844
- -- waiting for data rather than as a blank gap.
845
- local baseline = Instance.new("Frame")
846
- baseline.AnchorPoint = Vector2.new(0, 1)
847
- baseline.Position = UDim2.fromScale(0, 1)
848
- baseline.Size = UDim2.new(1, 0, 0, 1)
849
- baseline.BackgroundColor3 = palette.dim
850
- baseline.BackgroundTransparency = 0.75
851
- baseline.BorderSizePixel = 0
852
- baseline.Parent = trace
853
-
854
- --[[
855
- Built once and moved, and created BEFORE the bars exist so it sits under
856
- them in draw order -- a highlight drawn over a one-pixel bar would hide the
857
- very thing it is pointing at.
858
- ]]
859
- local highlight = Instance.new("Frame")
860
- highlight.Name = "Highlight"
861
- highlight.AnchorPoint = Vector2.new(0.5, 1)
862
- highlight.Position = UDim2.new(1, 0, 1, 0)
863
- highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
864
- highlight.BackgroundColor3 = palette.text or Color3.fromRGB(226, 232, 240)
865
- -- Faint enough not to hide the bar it sits behind, solid enough to be seen
866
- -- at all. The previous value was chosen for a highlight that never rendered,
867
- -- so it had never been looked at.
868
- highlight.BackgroundTransparency = 0.7
869
- highlight.BorderSizePixel = 0
870
- highlight.Visible = false
871
- highlight.Parent = trace
872
- runtime.highlight = highlight
873
-
874
- --[[
875
- The reading sits at the left end of the trace rather than beside the bar it
876
- describes. Following the pointer would put it off the right edge for the
877
- newest bars, which are the ones most often asked about, and the left end is
878
- empty until forty calls have accumulated.
879
- ]]
880
- local readout = Instance.new("TextLabel")
881
- readout.Name = "Readout"
882
- readout.AnchorPoint = Vector2.new(0, 0.5)
883
- readout.Position = UDim2.new(0, 0, 0.5, 0)
884
- -- Sized by its text now that it carries a name as well as a number. A fixed
885
- -- 110px was enough for "18 ms" and truncates anything with a phrase in front
886
- -- of it, which is the half worth reading.
887
- readout.AutomaticSize = Enum.AutomaticSize.X
888
- readout.Size = UDim2.new(0, 0, 0, 14)
889
- readout.BackgroundColor3 = palette.background or Color3.fromRGB(11, 12, 20)
890
- readout.BackgroundTransparency = 0.15
891
- readout.BorderSizePixel = 0
892
- readout.Font = Enum.Font.Code
893
- readout.TextSize = 11
894
- readout.TextXAlignment = Enum.TextXAlignment.Left
895
- readout.Text = ""
896
- readout.Visible = false
897
- readout.ZIndex = 3
898
- readout.Parent = trace
899
- runtime.readout = readout
900
-
901
- local readoutPadding = Instance.new("UIPadding")
902
- readoutPadding.PaddingLeft = UDim.new(0, 4)
903
- readoutPadding.PaddingRight = UDim.new(0, 4)
904
- readoutPadding.Parent = readout
905
- end
906
-
907
- return Visuals
1
+ --!strict
2
+ --[[
3
+ The console's activity band: a themed cell beside a live latency trace.
4
+
5
+ Watching an agent work is mostly waiting, and the log answers "what is it
6
+ doing" precisely while answering "is it still alive" badly -- a stalled
7
+ session and a thinking one produce the same still screen.
8
+
9
+ This began as a full-panel overlay and that was wrong. It sat on top of the
10
+ log, so the thing you actually read was covered and, being slightly
11
+ transparent, softened underneath. Decoration that costs legibility is a bad
12
+ trade however good it looks. So it is a band now: a fixed strip above the
13
+ log, which keeps every line at full contrast and never moves under the
14
+ reader. The log shrinks by the band's height and nothing overlaps.
15
+
16
+ The strip earns its space twice over. The cell on the left says the session
17
+ is live; the trace beside it is the last forty calls, each drawn as tall as
18
+ it was slow and coloured by whether it worked. That turns "is it alive" and
19
+ "is it healthy" into one glance, which is more than the log gives and more
20
+ than an animation alone would.
21
+
22
+ WHAT THIS FILE OWNS, since it used to own everything: the strip's Instances,
23
+ the pointer handling on the trace, and the simulation -- energy, activity,
24
+ flare, shake, the scale spring, the quiet timer and the blended tint. What
25
+ it does NOT own is any drawing inside the cell. That belongs to whichever
26
+ preset is active (see Themes/), which is handed the simulation each frame
27
+ and paints whatever it likes with it. Eight presets are therefore eight
28
+ painters over one set of physics rather than eight animation systems, and
29
+ only one of them is ever mounted.
30
+ ]]
31
+
32
+ local RunService = game:GetService("RunService")
33
+
34
+ local Themes = require(script.Parent.Themes)
35
+
36
+ local Visuals = {}
37
+
38
+ -- How long the session must go without a command before the cell winds down.
39
+ -- Matches the console's idle notice, so the picture and the log agree.
40
+ local QUIET_AFTER = 20
41
+
42
+ --[[
43
+ Spring constants for the scale. Stiff enough that a call reads as a snap
44
+ rather than a swell, damped just under critical so a reply overshoots once
45
+ and settles instead of ringing.
46
+ ]]
47
+ local SPRING = 90
48
+ local DAMPING = 14
49
+
50
+ -- How many calls the trace remembers. Forty is about a screen's width of bars
51
+ -- at five pixels each, and about as far back as anyone reads a trend.
52
+ local TRACE = 40
53
+
54
+ -- Bars are scaled against this, so a normal call is a low bar and a slow one is
55
+ -- obvious. Anything past it clamps to full height rather than flattening the
56
+ -- rest of the trace into invisibility.
57
+ local SLOW_MS = 400
58
+
59
+ -- Pixels from one bar to the next. Also the width of a bar's hit target and of
60
+ -- the hover highlight, so pointing between two bars still selects one of them
61
+ -- rather than falling into a dead gap.
62
+ local BAR_STRIDE = 5
63
+
64
+ -- Height of the trace, and so of every bar's hit column. The taller band spends
65
+ -- its extra pixels here: a 19px column was a small thing to aim a pointer at.
66
+ local TRACE_HEIGHT = 26
67
+
68
+ --[[
69
+ The strip's height, and the geometry around the cell.
70
+
71
+ The cell was originally as tall as the band and read as looming; it crowded
72
+ the caption and made the strip feel like a panel. It is an indicator, not the
73
+ subject, so it sits small with air around it.
74
+
75
+ INSET and GUTTER both grew after the cell stopped being a wireframe and
76
+ started being eight different things. At six pixels from the panel edge the
77
+ cell was pressed into the corner, which reads as cramped for a prism and
78
+ simply wrong for a starfield -- a sky needs a horizon around it, not a
79
+ border. The band grew with them so the extra margin comes out of empty space
80
+ rather than out of the trace, whose bars are what people try to point at.
81
+ ]]
82
+ Visuals.BAND_HEIGHT = 58
83
+ local CELL = 40
84
+ local INSET = 12
85
+ local GUTTER = 22
86
+
87
+ type Bar = {
88
+ frame: Frame,
89
+ --[[
90
+ An invisible column over the bar, full height of the trace, wide enough to
91
+ hit. The bar itself is three pixels wide and usually one pixel tall -- a
92
+ fast call is a stub on the baseline -- so hovering the bar is not something
93
+ a person can reliably do. The hit target is the whole column above it, so
94
+ pointing anywhere near a bar selects it.
95
+ ]]
96
+ hit: TextButton,
97
+ milliseconds: number,
98
+ ok: boolean,
99
+ --[[
100
+ What ran, in the same words the log uses.
101
+
102
+ The trace plotted durations with nothing to attach them to, so a tall bar
103
+ raised the question it could not answer: forty numbers and no way to tell
104
+ which of them was the script edit. Carried per bar rather than looked up,
105
+ because the bar outlives whatever produced it.
106
+ ]]
107
+ title: string,
108
+ }
109
+
110
+ type Runtime = {
111
+ band: Frame?,
112
+ cell: Frame?,
113
+ trace: Frame?,
114
+ divider: Frame?,
115
+ baseline: Frame?,
116
+ highlight: Frame?,
117
+ readout: TextLabel?,
118
+ caption: TextLabel?,
119
+ bars: { Bar },
120
+ connection: RBXScriptConnection?,
121
+
122
+ spin: Vector3,
123
+ angle: Vector3,
124
+
125
+ energy: number,
126
+ shake: number,
127
+ --[[
128
+ The cell's size, as a spring rather than a value.
129
+
130
+ Scale was previously a small function of energy -- a 24% range that
131
+ nobody could see. Driving it as a spring means each event can push it and
132
+ let physics do the rest: a dispatch pulls it in, a reply throws it out
133
+ past its resting size, and it settles on its own.
134
+ ]]
135
+ scale: number,
136
+ scaleVelocity: number,
137
+ -- Spikes to 1 the instant a reply lands, gone in a third of a second.
138
+ flare: number,
139
+ -- Seconds since anything last ran. Drives the wind-down.
140
+ quiet: number,
141
+ tint: Color3,
142
+ targetTint: Color3,
143
+ baseTint: Color3,
144
+ activeTint: Color3,
145
+ activity: number,
146
+ lastTitle: string?,
147
+ -- Whether the active preset has been given its Instances yet.
148
+ mounted: boolean,
149
+ }
150
+
151
+ local runtime: Runtime = {
152
+ band = nil,
153
+ cell = nil,
154
+ trace = nil,
155
+ divider = nil,
156
+ baseline = nil,
157
+ highlight = nil,
158
+ readout = nil,
159
+ caption = nil,
160
+ bars = {},
161
+ connection = nil,
162
+ spin = Vector3.new(0.18, 0.27, 0.11),
163
+ angle = Vector3.zero,
164
+ energy = 0,
165
+ shake = 0,
166
+ scale = 1,
167
+ scaleVelocity = 0,
168
+ flare = 0,
169
+ quiet = 0,
170
+ tint = Color3.fromRGB(167, 139, 250),
171
+ targetTint = Color3.fromRGB(167, 139, 250),
172
+ baseTint = Color3.fromRGB(167, 139, 250),
173
+ activeTint = Color3.fromRGB(167, 139, 250),
174
+ activity = 0,
175
+ lastTitle = nil,
176
+ mounted = false,
177
+ }
178
+
179
+ --[[
180
+ The simulation, packaged for whichever preset is drawing it.
181
+
182
+ Rebuilt every frame rather than mutated in place. A preset holding onto the
183
+ table between frames would see values change under it, and the one rule that
184
+ keeps eight renderers honest is that they read this and draw -- they do not
185
+ own any of it.
186
+ ]]
187
+ local function context(): Themes.Context
188
+ return {
189
+ palette = Themes.palette(),
190
+ tint = runtime.tint,
191
+ energy = runtime.energy,
192
+ activity = runtime.activity,
193
+ flare = runtime.flare,
194
+ shake = runtime.shake,
195
+ scale = runtime.scale,
196
+ quiet = runtime.quiet,
197
+ angle = runtime.angle,
198
+ clock = os.clock(),
199
+ size = CELL,
200
+ centre = Vector2.new(CELL / 2, CELL / 2),
201
+ }
202
+ end
203
+
204
+ local function step(delta: number)
205
+ local band = runtime.band
206
+ local cell = runtime.cell
207
+ if band == nil or cell == nil or not band.Visible then
208
+ return
209
+ end
210
+
211
+ runtime.energy = math.max(0, runtime.energy - delta * 0.9)
212
+ runtime.shake = math.max(0, runtime.shake - delta * 2.4)
213
+ --[[
214
+ Slower than it was. The cell is meant to climb as the session works, and
215
+ at the old rates a call had to arrive every three seconds just to hold the
216
+ middle of the range -- so the top of it was, in practice, unreachable. A
217
+ burst of four calls now gets there, and it unwinds over about ten seconds
218
+ afterwards.
219
+ ]]
220
+ runtime.activity = math.max(0, runtime.activity - delta * 0.28)
221
+ runtime.flare = math.max(0, runtime.flare - delta * 3)
222
+ runtime.quiet += delta
223
+ --[[
224
+ Two colours, blended by how recently something happened.
225
+
226
+ `baseTint` is the connection state, which is what the panel should read
227
+ as when nothing is going on. `activeTint` is the kind of work last done
228
+ -- reading, writing, running, debugging -- and it takes over while that
229
+ work is fresh, then fades back. So the strip is not one fixed colour: it
230
+ leans toward whatever the session is actually doing and returns to
231
+ resting on its own.
232
+ ]]
233
+ local blend = math.clamp(runtime.energy, 0, 1)
234
+ runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
235
+ runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
236
+
237
+ -- Turning faster while busy makes the rate itself readable: a glance says
238
+ -- whether anything is happening without reading a word.
239
+ --
240
+ -- And nearly stopping once the session has gone quiet, for the same reason in
241
+ -- reverse: a strip that keeps moving at its working rate hours after the
242
+ -- last command claims an activity that is not happening.
243
+ local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
244
+ runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
245
+
246
+ --[[
247
+ Where the cell wants to be, before the spring gets a say.
248
+
249
+ Three things move it and they are deliberately ordered. Energy -- how
250
+ recently work happened -- swells it while a session is busy. The breathe
251
+ is a slow sine that only survives while energy is near zero, so an idle
252
+ panel is visibly alive without a resting animation competing with the
253
+ reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
254
+ in and stays there: the picture of a session that has stopped.
255
+ ]]
256
+ local calm = 1 - math.clamp(runtime.energy, 0, 1)
257
+ local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
258
+ local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
259
+ if runtime.quiet > QUIET_AFTER then
260
+ target = 0.82
261
+ end
262
+
263
+ --[[
264
+ A damped spring rather than a lerp, because a lerp cannot overshoot and
265
+ overshoot is the whole point: a reply that pushes the cell past its
266
+ resting size and lets it fall back reads as a thing being struck, where
267
+ easing toward a value reads as a slider being dragged.
268
+
269
+ Integrated semi-implicitly -- velocity first, then position from the new
270
+ velocity -- which stays stable at the frame times Studio actually hands us
271
+ rather than only at small ones.
272
+ ]]
273
+ --[[
274
+ Substepped rather than truncated.
275
+
276
+ `math.min(delta, 1/30)` kept the integration stable, but it also THREW
277
+ AWAY the rest of a long frame: at 20fps a third of every frame's motion
278
+ simply did not happen, so the spring ran slower in wall-clock time
279
+ exactly when Studio was busiest -- which is during a tool call, which is
280
+ when the spring is reacting to something. Stepping the leftover instead
281
+ keeps the reaction the same length whatever the frame rate, and the cap
282
+ on the number of steps stops a two-second hitch from paying it all back
283
+ in one frame.
284
+ ]]
285
+ local remaining = math.min(delta, 0.25)
286
+ for _ = 1, 8 do
287
+ if remaining <= 0 then
288
+ break
289
+ end
290
+ local integration = math.min(remaining, 1 / 60)
291
+ remaining -= integration
292
+
293
+ runtime.scaleVelocity += (target - runtime.scale) * SPRING * integration
294
+ runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * integration
295
+ runtime.scale += runtime.scaleVelocity * integration
296
+
297
+ --[[
298
+ Clamping the position without killing the velocity is what made a
299
+ reply read as a jolt rather than a bounce. The reply impulse is
300
+ `scaleVelocity += 9`, which crosses the whole 0.55..1.7 range in
301
+ about two frames, so the scale pinned itself against the ceiling
302
+ while still carrying all that speed -- and then sat there, motionless
303
+ but not at rest, until the spring had spent the stored velocity. The
304
+ eye reads that as a snap, a stall, and a second snap.
305
+
306
+ Zeroing the component pushing into the rail turns it into a stop.
307
+ ]]
308
+ if runtime.scale <= 0.55 then
309
+ runtime.scale = 0.55
310
+ runtime.scaleVelocity = math.max(runtime.scaleVelocity, 0)
311
+ elseif runtime.scale >= 1.7 then
312
+ runtime.scale = 1.7
313
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
314
+ end
315
+ end
316
+
317
+ --[[
318
+ Handed off. A preset that throws must not take the console's status
319
+ display down with it, so a failure here disables the strip and says so
320
+ once rather than erroring sixty times a second.
321
+ ]]
322
+ local ok, err = pcall(Themes.active().paint, context(), delta)
323
+ if not ok then
324
+ band.Visible = false
325
+ warn(string.format("[rbx-studio] theme %q failed to paint: %s", Themes.activeId(), tostring(err)))
326
+ end
327
+ end
328
+
329
+ --[[
330
+ Puts the highlight behind one bar and prints its timing.
331
+
332
+ The trace is only legible if you already know what it plots, and nothing on
333
+ screen said so. Hovering answers it directly: the column lights up, and the
334
+ number that made that bar its height appears in words. One reading at a time,
335
+ so a single highlight and a single label are moved around rather than one of
336
+ each being built per bar.
337
+ ]]
338
+ local function showReading(entry: Bar)
339
+ local highlight = runtime.highlight
340
+ local readout = runtime.readout
341
+ if highlight == nil or readout == nil then
342
+ return
343
+ end
344
+
345
+ --[[
346
+ The Y here has to be 1, and was 0.
347
+
348
+ The highlight is anchored to its own bottom edge, so a Y scale of 0 put
349
+ that edge on the trace's top line and drew the whole 26px column above it
350
+ -- outside a frame that clips its descendants. The hover was therefore
351
+ never visible to anyone: the hit targets fired, the readout appeared, and
352
+ the highlight they were meant to explain was off screen every time.
353
+ ]]
354
+ highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
355
+ highlight.Visible = true
356
+
357
+ --[[
358
+ Named, not just timed.
359
+
360
+ A duration with nothing attached to it raises the question it cannot
361
+ answer -- forty bars, one of them tall, and no way to tell whether that
362
+ was a script edit or a screenshot. The title is the same phrase the log
363
+ row uses, so pointing at a bar and reading the log agree with each other.
364
+ ]]
365
+ local palette = Themes.palette()
366
+ readout.Text = if entry.ok
367
+ then string.format("%s: %d ms", entry.title, math.round(entry.milliseconds))
368
+ else string.format("%s: failed after %d ms", entry.title, math.round(entry.milliseconds))
369
+ readout.TextColor3 = if entry.ok then palette.text else palette.red
370
+ readout.Visible = true
371
+ end
372
+
373
+ local function clearReading()
374
+ if runtime.highlight then
375
+ runtime.highlight.Visible = false
376
+ end
377
+ if runtime.readout then
378
+ runtime.readout.Visible = false
379
+ end
380
+ end
381
+
382
+ --[[
383
+ Bars, for a preset that does not want to draw its own history.
384
+
385
+ Height is duration and colour is outcome, so a slow call and a failed one are
386
+ distinguishable at a glance -- which is the pair of questions that actually
387
+ get asked of a log this size.
388
+ ]]
389
+ local function defaultSlot(slot: Themes.Slot, ctx: Themes.Context)
390
+ local frame = slot.frame
391
+ frame.AnchorPoint = Vector2.new(0.5, 1)
392
+ frame.Size = UDim2.new(0, 3, math.max(slot.weight, 0.06), 0)
393
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
394
+ frame.BackgroundColor3 = if slot.ok then ctx.palette.violet else ctx.palette.red
395
+ frame.BackgroundTransparency = 0.15 + slot.age * 0.6
396
+ frame.Rotation = 0
397
+ end
398
+
399
+ --[[
400
+ Re-places every bar and hands each to the active preset to draw.
401
+
402
+ Laid out right to left so the newest bar is always at the same edge and the
403
+ trace reads as scrolling rather than reshuffling. The horizontal position is
404
+ set here and the preset is expected to keep it -- everything else about the
405
+ bar is the preset's to decide.
406
+ ]]
407
+ local function relayout()
408
+ local ctx = context()
409
+ local painter = Themes.active().paintSlot or defaultSlot
410
+ local total = #runtime.bars
411
+
412
+ for index, item in runtime.bars do
413
+ local fromRight = total - index
414
+ local offset = -(fromRight + 1) * BAR_STRIDE + BAR_STRIDE / 2
415
+
416
+ item.frame.Position = UDim2.new(1, offset, 1, 0)
417
+ item.hit.Position = UDim2.new(1, offset, 1, 0)
418
+
419
+ local slot: Themes.Slot = {
420
+ frame = item.frame,
421
+ milliseconds = item.milliseconds,
422
+ ok = item.ok,
423
+ title = item.title,
424
+ -- 0 for the newest, rising toward 1 for the oldest still shown.
425
+ age = if TRACE > 1 then fromRight / (TRACE - 1) else 0,
426
+ weight = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1),
427
+ }
428
+ local ok, err = pcall(painter, slot, ctx)
429
+ if not ok then
430
+ -- One bad slot must not leave the other thirty-nine unplaced.
431
+ defaultSlot(slot, ctx)
432
+ warn(string.format("[rbx-studio] theme %q failed on a slot: %s", Themes.activeId(), tostring(err)))
433
+ end
434
+ end
435
+ end
436
+
437
+ --[[
438
+ Records one finished call and re-draws the trace.
439
+ ]]
440
+ function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
441
+ runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
442
+ runtime.activity = math.min(1, runtime.activity + 0.34)
443
+ runtime.quiet = 0
444
+ runtime.flare = 1
445
+
446
+ --[[
447
+ A shove rather than a new target.
448
+
449
+ Setting the scale outright would snap; giving the spring velocity lets it
450
+ carry past its resting size and fall back, which is the difference between
451
+ a value changing and something being struck. A failure shoves the other
452
+ way and knocks the axis with it.
453
+ ]]
454
+ if ok then
455
+ --[[
456
+ Sized to peak just under the clamp rather than against it.
457
+
458
+ At +9 the shove carried the scale into the 1.7 ceiling within about a
459
+ tenth of a second, so what should have been an arc became a rise, a
460
+ flat spot, and a drop -- and a burst of calls, each adding another 9
461
+ to a velocity that was already railed, held it pinned there for as
462
+ long as the burst lasted. +6 tops out around 1.55, which reads as the
463
+ same strike and stays on the curve. The accumulated case is capped
464
+ for the same reason: several calls at once should look busy, not
465
+ stuck.
466
+ ]]
467
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity + 6, 8)
468
+ else
469
+ runtime.scale = 0.8
470
+ runtime.scaleVelocity = -2
471
+ runtime.shake = 1
472
+ end
473
+
474
+ local trace = runtime.trace
475
+ if trace == nil then
476
+ return
477
+ end
478
+
479
+ local bar = Instance.new("Frame")
480
+ bar.AnchorPoint = Vector2.new(0.5, 1)
481
+ bar.BorderSizePixel = 0
482
+ bar.Parent = trace
483
+ -- Presets draw points as well as bars, and a square point is not a point.
484
+ local corner = Instance.new("UICorner")
485
+ corner.CornerRadius = UDim.new(0, 1)
486
+ corner.Parent = bar
487
+
488
+ -- A TextButton rather than a Frame: buttons take mouse events reliably in a
489
+ -- plugin widget, and with no text and no background it is purely a target.
490
+ local hit = Instance.new("TextButton")
491
+ hit.AnchorPoint = Vector2.new(0.5, 1)
492
+ hit.BackgroundTransparency = 1
493
+ hit.Text = ""
494
+ hit.AutoButtonColor = false
495
+ hit.BorderSizePixel = 0
496
+ hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
497
+ hit.Parent = trace
498
+
499
+ local entry: Bar = {
500
+ frame = bar,
501
+ hit = hit,
502
+ milliseconds = milliseconds,
503
+ ok = ok,
504
+ title = if title ~= "" then title else "call",
505
+ }
506
+ table.insert(runtime.bars, entry)
507
+
508
+ hit.MouseEnter:Connect(function()
509
+ showReading(entry)
510
+ end)
511
+ hit.MouseLeave:Connect(function()
512
+ clearReading()
513
+ end)
514
+
515
+ while #runtime.bars > TRACE do
516
+ local oldest = runtime.bars[1]
517
+ oldest.frame:Destroy()
518
+ oldest.hit:Destroy()
519
+ table.remove(runtime.bars, 1)
520
+ end
521
+
522
+ relayout()
523
+
524
+ -- The bars have all moved, so a reading still on screen now names the wrong
525
+ -- one. Cheaper and more honest to drop it than to work out which bar the
526
+ -- pointer has ended up over.
527
+ clearReading()
528
+ end
529
+
530
+ --[[
531
+ The resting colour, which is the connection state.
532
+ ]]
533
+ function Visuals.setTint(color: Color3)
534
+ runtime.baseTint = color
535
+ end
536
+
537
+ --[[
538
+ The colour and pace of the work now running.
539
+
540
+ Both are set from the same call because they describe the same thing: what
541
+ kind of command this is. Reads are quick and cool, writes are slower and
542
+ warm, so the strip's colour and its rate agree with each other instead of
543
+ moving independently.
544
+ ]]
545
+ function Visuals.setKind(color: Color3, urgency: number)
546
+ runtime.activeTint = color
547
+ runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
548
+ runtime.quiet = 0
549
+ --[[
550
+ Drawn in as the command goes out, so the pair reads as one gesture: the
551
+ cell contracts while the request is in flight and springs open when the
552
+ answer arrives. On a fast call the two are almost one motion, which is
553
+ itself the report -- a slow call visibly holds its breath.
554
+ ]]
555
+ runtime.scale = math.min(runtime.scale, 0.85)
556
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
557
+ -- Re-aimed per kind so successive commands of different types visibly
558
+ -- change the axis rather than continuing the same turn.
559
+ runtime.spin = Vector3.new(
560
+ 0.12 + math.random() * 0.16,
561
+ 0.18 + math.random() * 0.22,
562
+ 0.06 + math.random() * 0.12
563
+ )
564
+ end
565
+
566
+ --[[
567
+ What is running, in the same words the log uses. The motion says something is
568
+ happening; this says what, and neither answers the other's question.
569
+ ]]
570
+ function Visuals.setCaption(title: string)
571
+ runtime.lastTitle = title
572
+ if runtime.caption then
573
+ runtime.caption.Text = title
574
+ end
575
+ end
576
+
577
+ --[[
578
+ Falls back to the last thing that ran once a command finishes.
579
+
580
+ The footer already reports totals, so repeating "idle" here would say the
581
+ same word twice on one screen. What has just happened is more useful and is
582
+ not shown anywhere else.
583
+ ]]
584
+ function Visuals.setIdle()
585
+ if runtime.caption == nil then
586
+ return
587
+ end
588
+ runtime.caption.Text = if runtime.lastTitle ~= nil
589
+ then "last: " .. runtime.lastTitle
590
+ else "waiting for a command"
591
+ end
592
+
593
+ --[[
594
+ Winds the cell down, because the session has stopped rather than paused.
595
+
596
+ The strip already decays on its own, but decay bottoms out at "idle and
597
+ moving", which looks the same after twenty seconds as after two hours. This
598
+ is the console telling the picture what it has just told the log, so the two
599
+ agree.
600
+ ]]
601
+ function Visuals.setQuiet()
602
+ runtime.quiet = QUIET_AFTER + 1
603
+ runtime.activity = 0
604
+ end
605
+
606
+ --[[
607
+ Empties the trace.
608
+
609
+ Paired with the console's clear button. The bars used to survive it while the
610
+ footer counters reset, so "clear" wiped the log, wiped the statistics, and
611
+ left forty timings from the session it had just erased sitting on screen.
612
+ ]]
613
+ function Visuals.clearTrace()
614
+ for _, entry in runtime.bars do
615
+ entry.frame:Destroy()
616
+ entry.hit:Destroy()
617
+ end
618
+ table.clear(runtime.bars)
619
+ clearReading()
620
+ end
621
+
622
+ function Visuals.setVisible(visible: boolean)
623
+ local band = runtime.band
624
+ if band == nil then
625
+ return
626
+ end
627
+ band.Visible = visible
628
+
629
+ -- Connected only while on screen: a hidden strip that keeps projecting
630
+ -- geometry every frame is a battery complaint waiting to happen.
631
+ if visible and runtime.connection == nil then
632
+ runtime.connection = RunService.Heartbeat:Connect(step)
633
+ elseif not visible and runtime.connection ~= nil then
634
+ runtime.connection:Disconnect()
635
+ runtime.connection = nil
636
+ end
637
+ end
638
+
639
+ function Visuals.isVisible(): boolean
640
+ local band = runtime.band
641
+ return band ~= nil and band.Visible
642
+ end
643
+
644
+ --[[
645
+ Swaps the cell's contents and recolours the strip's own chrome.
646
+
647
+ The order matters. The outgoing preset is unmounted before the cell is
648
+ emptied, so a preset that keeps references cannot be handed destroyed
649
+ Instances; the cell is then cleared outright rather than trusted to be
650
+ clean, because a preset that errored midway through `mount` will have left
651
+ something behind.
652
+ ]]
653
+ function Visuals.applyTheme()
654
+ local cell = runtime.cell
655
+ local band = runtime.band
656
+ if cell == nil or band == nil then
657
+ return
658
+ end
659
+
660
+ if runtime.mounted then
661
+ pcall(function()
662
+ Themes.active().unmount()
663
+ end)
664
+ end
665
+ for _, child in cell:GetChildren() do
666
+ child:Destroy()
667
+ end
668
+
669
+ local palette = Themes.palette()
670
+ band.BackgroundColor3 = palette.surface
671
+ if runtime.divider then
672
+ (runtime.divider :: Frame).BackgroundColor3 = palette.dim
673
+ end
674
+ if runtime.baseline then
675
+ (runtime.baseline :: Frame).BackgroundColor3 = palette.dim
676
+ end
677
+ if runtime.highlight then
678
+ (runtime.highlight :: Frame).BackgroundColor3 = palette.text
679
+ end
680
+ if runtime.caption then
681
+ (runtime.caption :: TextLabel).TextColor3 = palette.text
682
+ end
683
+ if runtime.readout then
684
+ (runtime.readout :: TextLabel).BackgroundColor3 = palette.background
685
+ end
686
+
687
+ local ok, err = pcall(function()
688
+ Themes.active().mount(cell)
689
+ end)
690
+ runtime.mounted = ok
691
+ if not ok then
692
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
693
+ end
694
+
695
+ relayout()
696
+ -- A preset that failed to paint disabled the band; a new one deserves the
697
+ -- chance to prove it works.
698
+ band.Visible = true
699
+ end
700
+
701
+ --[[
702
+ Builds the strip. Everything except the cell's contents is created here and
703
+ never again, which is what keeps the per-frame path allocation-free.
704
+ ]]
705
+ function Visuals.mount(parent: Instance)
706
+ local palette = Themes.palette()
707
+
708
+ local band = Instance.new("Frame")
709
+ band.Name = "ActivityBand"
710
+ band.BackgroundColor3 = palette.surface
711
+ band.BorderSizePixel = 0
712
+ band.Visible = false
713
+ band.ClipsDescendants = true
714
+ band.Parent = parent
715
+ runtime.band = band
716
+
717
+ local cell = Instance.new("Frame")
718
+ cell.Name = "Cell"
719
+ cell.Position = UDim2.fromOffset(INSET, (Visuals.BAND_HEIGHT - CELL) / 2)
720
+ cell.Size = UDim2.fromOffset(CELL, CELL)
721
+ cell.BackgroundTransparency = 1
722
+ cell.ClipsDescendants = true
723
+ cell.Parent = band
724
+ runtime.cell = cell
725
+
726
+ -- A hairline between the cell and the trace, so the strip reads as two
727
+ -- instruments rather than one busy rectangle. Centred in the gutter, which
728
+ -- is what keeps the extra margin as air on both sides rather than as a gap
729
+ -- on one.
730
+ local divider = Instance.new("Frame")
731
+ divider.Name = "Divider"
732
+ divider.Position = UDim2.fromOffset(INSET + CELL + GUTTER / 2, 10)
733
+ divider.Size = UDim2.new(0, 1, 1, -20)
734
+ divider.BackgroundColor3 = palette.dim
735
+ divider.BackgroundTransparency = 0.78
736
+ divider.BorderSizePixel = 0
737
+ divider.Parent = band
738
+ runtime.divider = divider
739
+
740
+ local right = INSET + CELL + GUTTER
741
+
742
+ local caption = Instance.new("TextLabel")
743
+ caption.Name = "Caption"
744
+ caption.Position = UDim2.new(0, right, 0, 6)
745
+ caption.Size = UDim2.new(1, -right - 12, 0, 14)
746
+ caption.BackgroundTransparency = 1
747
+ caption.Font = Enum.Font.Code
748
+ caption.TextSize = 11
749
+ caption.TextColor3 = palette.text
750
+ caption.TextXAlignment = Enum.TextXAlignment.Left
751
+ caption.TextTruncate = Enum.TextTruncate.AtEnd
752
+ caption.Text = "idle"
753
+ caption.Parent = band
754
+ runtime.caption = caption
755
+
756
+ local trace = Instance.new("Frame")
757
+ trace.Name = "Trace"
758
+ trace.Position = UDim2.new(0, right, 0, 24)
759
+ trace.Size = UDim2.new(1, -right - 12, 0, TRACE_HEIGHT)
760
+ trace.BackgroundTransparency = 1
761
+ trace.ClipsDescendants = true
762
+ trace.Parent = band
763
+ runtime.trace = trace
764
+
765
+ -- A baseline under the bars, so an empty trace still reads as an instrument
766
+ -- waiting for data rather than as a blank gap.
767
+ local baseline = Instance.new("Frame")
768
+ baseline.Name = "Baseline"
769
+ baseline.AnchorPoint = Vector2.new(0, 1)
770
+ baseline.Position = UDim2.fromScale(0, 1)
771
+ baseline.Size = UDim2.new(1, 0, 0, 1)
772
+ baseline.BackgroundColor3 = palette.dim
773
+ baseline.BackgroundTransparency = 0.75
774
+ baseline.BorderSizePixel = 0
775
+ baseline.Parent = trace
776
+ runtime.baseline = baseline
777
+
778
+ --[[
779
+ Built once and moved, and created BEFORE the bars exist so it sits under
780
+ them in draw order -- a highlight drawn over a one-pixel bar would hide the
781
+ very thing it is pointing at.
782
+ ]]
783
+ local highlight = Instance.new("Frame")
784
+ highlight.Name = "Highlight"
785
+ highlight.AnchorPoint = Vector2.new(0.5, 1)
786
+ highlight.Position = UDim2.new(1, 0, 1, 0)
787
+ highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
788
+ highlight.BackgroundColor3 = palette.text
789
+ -- Faint enough not to hide the bar it sits behind, solid enough to be seen
790
+ -- at all.
791
+ highlight.BackgroundTransparency = 0.7
792
+ highlight.BorderSizePixel = 0
793
+ highlight.Visible = false
794
+ highlight.Parent = trace
795
+ runtime.highlight = highlight
796
+
797
+ --[[
798
+ The reading sits at the left end of the trace rather than beside the bar it
799
+ describes. Following the pointer would put it off the right edge for the
800
+ newest bars, which are the ones most often asked about, and the left end is
801
+ empty until forty calls have accumulated.
802
+ ]]
803
+ local readout = Instance.new("TextLabel")
804
+ readout.Name = "Readout"
805
+ readout.AnchorPoint = Vector2.new(0, 0.5)
806
+ readout.Position = UDim2.new(0, 0, 0.5, 0)
807
+ -- Sized by its text now that it carries a name as well as a number. A fixed
808
+ -- 110px was enough for "18 ms" and truncates anything with a phrase in front
809
+ -- of it, which is the half worth reading.
810
+ readout.AutomaticSize = Enum.AutomaticSize.X
811
+ readout.Size = UDim2.new(0, 0, 0, 14)
812
+ readout.BackgroundColor3 = palette.background
813
+ readout.BackgroundTransparency = 0.15
814
+ readout.BorderSizePixel = 0
815
+ readout.Font = Enum.Font.Code
816
+ readout.TextSize = 11
817
+ readout.TextXAlignment = Enum.TextXAlignment.Left
818
+ readout.Text = ""
819
+ readout.Visible = false
820
+ readout.ZIndex = 3
821
+ readout.Parent = trace
822
+ runtime.readout = readout
823
+
824
+ local readoutPadding = Instance.new("UIPadding")
825
+ readoutPadding.PaddingLeft = UDim.new(0, 4)
826
+ readoutPadding.PaddingRight = UDim.new(0, 4)
827
+ readoutPadding.Parent = readout
828
+
829
+ local ok, err = pcall(function()
830
+ Themes.active().mount(cell)
831
+ end)
832
+ runtime.mounted = ok
833
+ if not ok then
834
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
835
+ end
836
+ end
837
+
838
+ return Visuals