@el4cteo/rbx-studio-mcp 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,838 +1,1281 @@
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
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 connecting wave: what the trace does while there is no session to plot.
70
+
71
+ A connect or a reconnect is the one moment the trace has nothing true to
72
+ say. No calls have happened yet, so it is a bare baseline -- which looks
73
+ exactly like a panel that has died, at the one time the user is most likely
74
+ to be watching it to find out whether it has.
75
+
76
+ So the columns wave instead: a swell travelling along the trace for as long
77
+ as the transport is reaching for the bridge, and gone the moment it either
78
+ arrives or gives up.
79
+
80
+ It is drawn THROUGH THE ACTIVE PRESET's slot painter rather than painted
81
+ here, for the same reason history is. Eight presets already know how to draw
82
+ a column of a given height and freshness, so routing the wave through them
83
+ means it arrives in all eight without eight implementations of it -- a theme
84
+ that draws history as rising points draws the wave as an arc of points, and
85
+ nobody had to tell it to.
86
+ ]]
87
+ --[[
88
+ ONE GRID, shared with the history. This is the whole trick.
89
+
90
+ The wave used to sit on its own grid -- a fixed count of columns spread by
91
+ scale across the trace -- and history sits on a stride grid anchored to the
92
+ right edge. Two grids in one strip is two instruments in one strip: on a
93
+ reconnect the wave's columns landed between and behind the bars at a
94
+ different spacing, which read as interference rather than as one surface.
95
+
96
+ So the trace is now a single row of BAR_STRIDE-spaced slots, numbered 1 at
97
+ the right edge and rising leftwards. History fills slots 1..n. The wave
98
+ fills every slot LEFT of those and never one of them, so the two can no
99
+ longer overlap by construction rather than by draw order. And because they
100
+ share the numbering, the same swell that lifts a wave column also lifts the
101
+ bars it rolls over -- the history rides the wave instead of standing still
102
+ in the middle of it.
103
+ ]]
104
+ -- The most slots the wave will ever fill, which bounds the pool a very wide
105
+ -- panel can ask for.
106
+ local WAVE_MAX = 160
107
+ -- Seconds for one swell to cross the trace.
108
+ local WAVE_PERIOD = 1.8
109
+ -- The swell's width as a fraction of the trace. Narrow enough to read as a
110
+ -- pulse travelling along it rather than as the whole trace breathing.
111
+ local WAVE_SPREAD = 0.2
112
+ --[[
113
+ The fine ripple riding on the swell, as SLOTS PER CYCLE rather than cycles
114
+ per trace.
115
+
116
+ Fixed cycles made the texture a function of the panel's width: the same wave
117
+ was a coarse comb in a narrow dock and a smooth featureless hump in a wide
118
+ one. Per-slot keeps a ripple the same physical size at any width, which is
119
+ what makes it read as strips moving at all.
120
+ ]]
121
+ local WAVE_RIPPLE_STRIDE = 16
122
+ --[[
123
+ How far the swell lifts a history bar, against the headroom that bar has
124
+ left.
125
+
126
+ Additive and proportional, not a replacement. A bar already near the top of
127
+ the trace barely moves and a stub moves nearly the full amount, so the wave
128
+ is visible across the history without flattening the profile that history is
129
+ there to show. The hover readout reports the recorded milliseconds either
130
+ way -- what the wave changes is the drawing, never the reading.
131
+ ]]
132
+ local WAVE_LIFT = 0.8
133
+ -- Fade rates, in and out. Out is the slower of the two: an attempt that fails
134
+ -- immediately should ebb rather than blink off.
135
+ local WAVE_IN = 7
136
+ local WAVE_OUT = 2.5
137
+ --[[
138
+ The shortest time the wave stays up once it has started.
139
+
140
+ A handshake against a port with nothing behind it fails in milliseconds, and
141
+ the transport reports "connecting" before every one of those attempts. Without
142
+ a floor the wave would be a single-frame flash per retry, which reads as a
143
+ glitch rather than as an attempt being made.
144
+ ]]
145
+ local WAVE_HOLD = 0.7
146
+ -- Where the columns rest when the wave is not lifting them. Low enough to read
147
+ -- as an instrument waiting rather than as invented data, high enough to be
148
+ -- there at all.
149
+ local WAVE_FLOOR = 0.05
150
+
151
+ --[[
152
+ The arrival: what the trace does the moment a session actually exists.
153
+
154
+ The connecting wave is a searching motion and says so -- the same swell over
155
+ and over, going nowhere. Landing deserves a different shape, once, or the
156
+ only thing that marks the event is a word in the header going green.
157
+
158
+ So the columns are struck rather than swept. A bright front races along the
159
+ trace and every column it reaches leaps and rings down behind it, which
160
+ reads as a thing arriving rather than a thing looking. It plays once per
161
+ connection and cannot repeat on its own -- the status line only fires it on
162
+ a real transition into `connected`, so a theme switch or a port change
163
+ replaying the same status does not replay the celebration.
164
+ ]]
165
+ -- How long the whole flourish lasts.
166
+ local CHEER_TIME = 1.4
167
+ -- The fraction of that time the front takes to cross the trace. Well under
168
+ -- half: the sweep should outrun the ringing rather than drag it along.
169
+ local CHEER_SWEEP = 0.38
170
+ -- How fast a struck column settles. Against the sweep above this leaves about
171
+ -- two visible bounces before it is back on the baseline.
172
+ local CHEER_DECAY = 3.2
173
+ -- Bounces per column, over the flourish's own length.
174
+ local CHEER_RING = 2.5
175
+ --[[
176
+ How abruptly the flourish fades.
177
+
178
+ Opacity holds at full for the first stretch and only gives way at the end,
179
+ so the celebration reads as ringing out rather than as being dimmed while it
180
+ is still moving. `1 / CHEER_FADE` is the fraction of the tail spent fading.
181
+ ]]
182
+ local CHEER_FADE = 2.5
183
+
184
+ --[[
185
+ The strip's height, and the geometry around the cell.
186
+
187
+ The cell was originally as tall as the band and read as looming; it crowded
188
+ the caption and made the strip feel like a panel. It is an indicator, not the
189
+ subject, so it sits small with air around it.
190
+
191
+ INSET and GUTTER both grew after the cell stopped being a wireframe and
192
+ started being eight different things. At six pixels from the panel edge the
193
+ cell was pressed into the corner, which reads as cramped for a prism and
194
+ simply wrong for a starfield -- a sky needs a horizon around it, not a
195
+ border. The band grew with them so the extra margin comes out of empty space
196
+ rather than out of the trace, whose bars are what people try to point at.
197
+ ]]
198
+ Visuals.BAND_HEIGHT = 58
199
+ local CELL = 40
200
+ local INSET = 12
201
+ local GUTTER = 22
202
+
203
+ type Bar = {
204
+ frame: Frame,
205
+ --[[
206
+ An invisible column over the bar, full height of the trace, wide enough to
207
+ hit. The bar itself is three pixels wide and usually one pixel tall -- a
208
+ fast call is a stub on the baseline -- so hovering the bar is not something
209
+ a person can reliably do. The hit target is the whole column above it, so
210
+ pointing anywhere near a bar selects it.
211
+ ]]
212
+ hit: TextButton,
213
+ milliseconds: number,
214
+ ok: boolean,
215
+ --[[
216
+ What ran, in the same words the log uses.
217
+
218
+ The trace plotted durations with nothing to attach them to, so a tall bar
219
+ raised the question it could not answer: forty numbers and no way to tell
220
+ which of them was the script edit. Carried per bar rather than looked up,
221
+ because the bar outlives whatever produced it.
222
+ ]]
223
+ title: string,
224
+ }
225
+
226
+ type Runtime = {
227
+ band: Frame?,
228
+ cell: Frame?,
229
+ trace: Frame?,
230
+ divider: Frame?,
231
+ baseline: Frame?,
232
+ highlight: Frame?,
233
+ readout: TextLabel?,
234
+ caption: TextLabel?,
235
+ bars: { Bar },
236
+ connection: RBXScriptConnection?,
237
+
238
+ spin: Vector3,
239
+ angle: Vector3,
240
+
241
+ energy: number,
242
+ shake: number,
243
+ --[[
244
+ The cell's size, as a spring rather than a value.
245
+
246
+ Scale was previously a small function of energy -- a 24% range that
247
+ nobody could see. Driving it as a spring means each event can push it and
248
+ let physics do the rest: a dispatch pulls it in, a reply throws it out
249
+ past its resting size, and it settles on its own.
250
+ ]]
251
+ scale: number,
252
+ scaleVelocity: number,
253
+ -- Spikes to 1 the instant a reply lands, gone in a third of a second.
254
+ flare: number,
255
+ -- Seconds since anything last ran. Drives the wind-down.
256
+ quiet: number,
257
+ tint: Color3,
258
+ targetTint: Color3,
259
+ baseTint: Color3,
260
+ activeTint: Color3,
261
+ activity: number,
262
+ lastTitle: string?,
263
+ -- Whether the active preset has been given its Instances yet.
264
+ mounted: boolean,
265
+
266
+ -- The wave's columns, pooled at mount and hidden until a connect needs them.
267
+ wave: { Frame },
268
+ -- What the transport last said: true while it is trying to reach the bridge.
269
+ waveWanted: boolean,
270
+ -- The ramp, 0 to 1. Separate from `waveWanted` so the wave fades rather than
271
+ -- appearing and vanishing with the status line.
272
+ waveLevel: number,
273
+ -- Seconds the wave is held up regardless of `waveWanted`. See WAVE_HOLD.
274
+ waveHold: number,
275
+ -- Whether the columns are currently on screen. Kept rather than derived, so
276
+ -- a settled wave hides them once instead of every frame afterwards.
277
+ waveShown: boolean,
278
+ -- The arrival flourish: 1 the instant a session connects, down to 0 over
279
+ -- CHEER_TIME. Overrides the connecting wave's shape while it runs.
280
+ cheer: number,
281
+ }
282
+
283
+ local runtime: Runtime = {
284
+ band = nil,
285
+ cell = nil,
286
+ trace = nil,
287
+ divider = nil,
288
+ baseline = nil,
289
+ highlight = nil,
290
+ readout = nil,
291
+ caption = nil,
292
+ bars = {},
293
+ connection = nil,
294
+ spin = Vector3.new(0.18, 0.27, 0.11),
295
+ angle = Vector3.zero,
296
+ energy = 0,
297
+ shake = 0,
298
+ scale = 1,
299
+ scaleVelocity = 0,
300
+ flare = 0,
301
+ quiet = 0,
302
+ tint = Color3.fromRGB(167, 139, 250),
303
+ targetTint = Color3.fromRGB(167, 139, 250),
304
+ baseTint = Color3.fromRGB(167, 139, 250),
305
+ activeTint = Color3.fromRGB(167, 139, 250),
306
+ activity = 0,
307
+ lastTitle = nil,
308
+ mounted = false,
309
+ wave = {},
310
+ waveWanted = false,
311
+ waveLevel = 0,
312
+ waveHold = 0,
313
+ waveShown = false,
314
+ cheer = 0,
315
+ }
316
+
317
+ --[[
318
+ The simulation, packaged for whichever preset is drawing it.
319
+
320
+ Rebuilt every frame rather than mutated in place. A preset holding onto the
321
+ table between frames would see values change under it, and the one rule that
322
+ keeps eight renderers honest is that they read this and draw -- they do not
323
+ own any of it.
324
+ ]]
325
+ local function context(): Themes.Context
326
+ return {
327
+ palette = Themes.palette(),
328
+ tint = runtime.tint,
329
+ energy = runtime.energy,
330
+ activity = runtime.activity,
331
+ flare = runtime.flare,
332
+ shake = runtime.shake,
333
+ scale = runtime.scale,
334
+ quiet = runtime.quiet,
335
+ angle = runtime.angle,
336
+ clock = os.clock(),
337
+ size = CELL,
338
+ centre = Vector2.new(CELL / 2, CELL / 2),
339
+ }
340
+ end
341
+
342
+ -- Declared here and defined below, because it draws through the same slot
343
+ -- painter history does and that helper is defined after this function.
344
+ local stepWave: (delta: number) -> ()
345
+
346
+ local function step(delta: number)
347
+ local band = runtime.band
348
+ local cell = runtime.cell
349
+ if band == nil or cell == nil or not band.Visible then
350
+ return
351
+ end
352
+
353
+ runtime.energy = math.max(0, runtime.energy - delta * 0.9)
354
+ runtime.shake = math.max(0, runtime.shake - delta * 2.4)
355
+ --[[
356
+ Slower than it was. The cell is meant to climb as the session works, and
357
+ at the old rates a call had to arrive every three seconds just to hold the
358
+ middle of the range -- so the top of it was, in practice, unreachable. A
359
+ burst of four calls now gets there, and it unwinds over about ten seconds
360
+ afterwards.
361
+ ]]
362
+ runtime.activity = math.max(0, runtime.activity - delta * 0.28)
363
+ runtime.flare = math.max(0, runtime.flare - delta * 3)
364
+ runtime.quiet += delta
365
+ --[[
366
+ Two colours, blended by how recently something happened.
367
+
368
+ `baseTint` is the connection state, which is what the panel should read
369
+ as when nothing is going on. `activeTint` is the kind of work last done
370
+ -- reading, writing, running, debugging -- and it takes over while that
371
+ work is fresh, then fades back. So the strip is not one fixed colour: it
372
+ leans toward whatever the session is actually doing and returns to
373
+ resting on its own.
374
+ ]]
375
+ local blend = math.clamp(runtime.energy, 0, 1)
376
+ runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
377
+ runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
378
+
379
+ -- Turning faster while busy makes the rate itself readable: a glance says
380
+ -- whether anything is happening without reading a word.
381
+ --
382
+ -- And nearly stopping once the session has gone quiet, for the same reason in
383
+ -- reverse: a strip that keeps moving at its working rate hours after the
384
+ -- last command claims an activity that is not happening.
385
+ local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
386
+ runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
387
+
388
+ --[[
389
+ Where the cell wants to be, before the spring gets a say.
390
+
391
+ Three things move it and they are deliberately ordered. Energy -- how
392
+ recently work happened -- swells it while a session is busy. The breathe
393
+ is a slow sine that only survives while energy is near zero, so an idle
394
+ panel is visibly alive without a resting animation competing with the
395
+ reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
396
+ in and stays there: the picture of a session that has stopped.
397
+ ]]
398
+ local calm = 1 - math.clamp(runtime.energy, 0, 1)
399
+ local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
400
+ local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
401
+ if runtime.quiet > QUIET_AFTER then
402
+ target = 0.82
403
+ end
404
+
405
+ --[[
406
+ A damped spring rather than a lerp, because a lerp cannot overshoot and
407
+ overshoot is the whole point: a reply that pushes the cell past its
408
+ resting size and lets it fall back reads as a thing being struck, where
409
+ easing toward a value reads as a slider being dragged.
410
+
411
+ Integrated semi-implicitly -- velocity first, then position from the new
412
+ velocity -- which stays stable at the frame times Studio actually hands us
413
+ rather than only at small ones.
414
+ ]]
415
+ --[[
416
+ Substepped rather than truncated.
417
+
418
+ `math.min(delta, 1/30)` kept the integration stable, but it also THREW
419
+ AWAY the rest of a long frame: at 20fps a third of every frame's motion
420
+ simply did not happen, so the spring ran slower in wall-clock time
421
+ exactly when Studio was busiest -- which is during a tool call, which is
422
+ when the spring is reacting to something. Stepping the leftover instead
423
+ keeps the reaction the same length whatever the frame rate, and the cap
424
+ on the number of steps stops a two-second hitch from paying it all back
425
+ in one frame.
426
+ ]]
427
+ local remaining = math.min(delta, 0.25)
428
+ for _ = 1, 8 do
429
+ if remaining <= 0 then
430
+ break
431
+ end
432
+ local integration = math.min(remaining, 1 / 60)
433
+ remaining -= integration
434
+
435
+ runtime.scaleVelocity += (target - runtime.scale) * SPRING * integration
436
+ runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * integration
437
+ runtime.scale += runtime.scaleVelocity * integration
438
+
439
+ --[[
440
+ Clamping the position without killing the velocity is what made a
441
+ reply read as a jolt rather than a bounce. The reply impulse is
442
+ `scaleVelocity += 9`, which crosses the whole 0.55..1.7 range in
443
+ about two frames, so the scale pinned itself against the ceiling
444
+ while still carrying all that speed -- and then sat there, motionless
445
+ but not at rest, until the spring had spent the stored velocity. The
446
+ eye reads that as a snap, a stall, and a second snap.
447
+
448
+ Zeroing the component pushing into the rail turns it into a stop.
449
+ ]]
450
+ if runtime.scale <= 0.55 then
451
+ runtime.scale = 0.55
452
+ runtime.scaleVelocity = math.max(runtime.scaleVelocity, 0)
453
+ elseif runtime.scale >= 1.7 then
454
+ runtime.scale = 1.7
455
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
456
+ end
457
+ end
458
+
459
+ -- Stepped before the cell is handed off, so a preset that errors and
460
+ -- disables the strip does not also take the connect animation with it.
461
+ stepWave(delta)
462
+
463
+ --[[
464
+ Handed off. A preset that throws must not take the console's status
465
+ display down with it, so a failure here disables the strip and says so
466
+ once rather than erroring sixty times a second.
467
+ ]]
468
+ local ok, err = pcall(Themes.active().paint, context(), delta)
469
+ if not ok then
470
+ band.Visible = false
471
+ warn(string.format("[rbx-studio] theme %q failed to paint: %s", Themes.activeId(), tostring(err)))
472
+ end
473
+ end
474
+
475
+ --[[
476
+ Puts the highlight behind one bar and prints its timing.
477
+
478
+ The trace is only legible if you already know what it plots, and nothing on
479
+ screen said so. Hovering answers it directly: the column lights up, and the
480
+ number that made that bar its height appears in words. One reading at a time,
481
+ so a single highlight and a single label are moved around rather than one of
482
+ each being built per bar.
483
+ ]]
484
+ local function showReading(entry: Bar)
485
+ local highlight = runtime.highlight
486
+ local readout = runtime.readout
487
+ if highlight == nil or readout == nil then
488
+ return
489
+ end
490
+
491
+ --[[
492
+ The Y here has to be 1, and was 0.
493
+
494
+ The highlight is anchored to its own bottom edge, so a Y scale of 0 put
495
+ that edge on the trace's top line and drew the whole 26px column above it
496
+ -- outside a frame that clips its descendants. The hover was therefore
497
+ never visible to anyone: the hit targets fired, the readout appeared, and
498
+ the highlight they were meant to explain was off screen every time.
499
+ ]]
500
+ highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
501
+ highlight.Visible = true
502
+
503
+ --[[
504
+ Named, not just timed.
505
+
506
+ A duration with nothing attached to it raises the question it cannot
507
+ answer -- forty bars, one of them tall, and no way to tell whether that
508
+ was a script edit or a screenshot. The title is the same phrase the log
509
+ row uses, so pointing at a bar and reading the log agree with each other.
510
+ ]]
511
+ local palette = Themes.palette()
512
+ readout.Text = if entry.ok
513
+ then string.format("%s: %d ms", entry.title, math.round(entry.milliseconds))
514
+ else string.format("%s: failed after %d ms", entry.title, math.round(entry.milliseconds))
515
+ readout.TextColor3 = if entry.ok then palette.text else palette.red
516
+ readout.Visible = true
517
+ end
518
+
519
+ local function clearReading()
520
+ if runtime.highlight then
521
+ runtime.highlight.Visible = false
522
+ end
523
+ if runtime.readout then
524
+ runtime.readout.Visible = false
525
+ end
526
+ end
527
+
528
+ --[[
529
+ Bars, for a preset that does not want to draw its own history.
530
+
531
+ Height is duration and colour is outcome, so a slow call and a failed one are
532
+ distinguishable at a glance -- which is the pair of questions that actually
533
+ get asked of a log this size.
534
+ ]]
535
+ local function defaultSlot(slot: Themes.Slot, ctx: Themes.Context)
536
+ local frame = slot.frame
537
+ frame.AnchorPoint = Vector2.new(0.5, 1)
538
+ frame.Size = UDim2.new(0, 3, math.max(slot.weight, 0.06), 0)
539
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
540
+ frame.BackgroundColor3 = if slot.ok then ctx.palette.violet else ctx.palette.red
541
+ frame.BackgroundTransparency = 0.15 + slot.age * 0.6
542
+ frame.Rotation = 0
543
+ end
544
+
545
+ --[[
546
+ Whether the transport is currently reaching for the bridge.
547
+
548
+ Told rather than inferred, because "connecting" is the transport's word and
549
+ the trace must never disagree with the status dot about what the session is
550
+ doing.
551
+ ]]
552
+ function Visuals.setConnecting(active: boolean)
553
+ if active and not runtime.waveWanted then
554
+ runtime.waveHold = WAVE_HOLD
555
+ end
556
+ runtime.waveWanted = active
557
+ end
558
+
559
+ --[[
560
+ Plays the arrival flourish, once.
561
+
562
+ Edge-triggered by the caller rather than latched here: only the status line
563
+ knows whether this is a session actually landing or the same `connected`
564
+ state being repeated after a theme switch, and a celebration that replays
565
+ itself every time the port is re-read stops meaning anything.
566
+ ]]
567
+ function Visuals.celebrate()
568
+ runtime.cheer = 1
569
+ -- The search is over. Nothing should be left holding the connecting wave up
570
+ -- underneath a flourish that has replaced it.
571
+ runtime.waveWanted = false
572
+ runtime.waveHold = 0
573
+ end
574
+
575
+ --[[
576
+ Re-places every bar and hands each to the active preset to draw.
577
+
578
+ Laid out right to left so the newest bar is always at the same edge and the
579
+ trace reads as scrolling rather than reshuffling. The horizontal position is
580
+ set here and the preset is expected to keep it -- everything else about the
581
+ bar is the preset's to decide.
582
+ ]]
583
+ --[[
584
+ Where slot `number` sits, counting 1 at the right edge and rising leftwards.
585
+
586
+ The one place the trace's spacing is decided. Both the history and the wave
587
+ call it, which is what guarantees they land on the same grid rather than on
588
+ two that merely look similar.
589
+ ]]
590
+ local function slotOffset(number: number): number
591
+ return -(number - 0.5) * BAR_STRIDE
592
+ end
593
+
594
+ --[[
595
+ Re-places every bar and hands each to the active preset to draw.
596
+
597
+ Laid out right to left so the newest bar is always at the same edge and the
598
+ trace reads as scrolling rather than reshuffling. The horizontal position is
599
+ set here and the preset is expected to keep it -- everything else about the
600
+ bar is the preset's to decide.
601
+
602
+ `modulate` is the wave, when one is running. It is handed a bar's slot
603
+ number and its true height and freshness, and returns what to draw instead,
604
+ so the history is lifted by the same swell that lifts the empty slots beside
605
+ it. Absent, every bar is drawn from its own recorded timing, which is what
606
+ restores the true trace the moment a wave ends.
607
+ ]]
608
+ local function relayout(modulate: ((number, number, number) -> (number, number))?)
609
+ local ctx = context()
610
+ local painter = Themes.active().paintSlot or defaultSlot
611
+ local total = #runtime.bars
612
+
613
+ for index, item in runtime.bars do
614
+ local fromRight = total - index
615
+ local number = fromRight + 1
616
+ local offset = slotOffset(number)
617
+
618
+ item.frame.Position = UDim2.new(1, offset, 1, 0)
619
+ item.hit.Position = UDim2.new(1, offset, 1, 0)
620
+
621
+ -- 0 for the newest, rising toward 1 for the oldest still shown.
622
+ local age = if TRACE > 1 then fromRight / (TRACE - 1) else 0
623
+ local weight = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1)
624
+ if modulate ~= nil then
625
+ weight, age = modulate(number, weight, age)
626
+ end
627
+
628
+ local slot: Themes.Slot = {
629
+ frame = item.frame,
630
+ milliseconds = item.milliseconds,
631
+ ok = item.ok,
632
+ title = item.title,
633
+ age = age,
634
+ weight = weight,
635
+ }
636
+ local ok, err = pcall(painter, slot, ctx)
637
+ if not ok then
638
+ -- One bad slot must not leave the other thirty-nine unplaced.
639
+ defaultSlot(slot, ctx)
640
+ --[[
641
+ Silent under a wave. This runs sixty times a second while one is
642
+ playing, and a preset that throws would fill the log faster than
643
+ anyone could read it -- the unmodulated path below still says so
644
+ once the next call lands.
645
+ ]]
646
+ if modulate == nil then
647
+ warn(string.format("[rbx-studio] theme %q failed on a slot: %s", Themes.activeId(), tostring(err)))
648
+ end
649
+ end
650
+ end
651
+ end
652
+
653
+ --[[
654
+ How many slots the trace has room for, at the shared stride.
655
+
656
+ Read from the live width rather than fixed, because the dock is resizable and
657
+ a wave that stops halfway across a widened panel is worse than no wave.
658
+ ]]
659
+ local function slotsAcross(): number
660
+ local trace = runtime.trace
661
+ if trace == nil then
662
+ return 0
663
+ end
664
+ return math.clamp(math.floor(trace.AbsoluteSize.X / BAR_STRIDE), 0, WAVE_MAX)
665
+ end
666
+
667
+ --[[
668
+ Makes sure the wave has a column for every slot up to `count`.
669
+
670
+ Grown on demand and never shrunk. The pool only ever reaches the width the
671
+ panel actually had, it is built the first time a connection is attempted
672
+ rather than at mount, and a column costs nothing while it is hidden.
673
+ ]]
674
+ local function growWave(count: number)
675
+ local trace = runtime.trace
676
+ if trace == nil then
677
+ return
678
+ end
679
+ for _ = #runtime.wave + 1, count do
680
+ local column = Instance.new("Frame")
681
+ column.Name = "Wave"
682
+ column.AnchorPoint = Vector2.new(0.5, 1)
683
+ column.Size = UDim2.fromOffset(2, 1)
684
+ column.BackgroundColor3 = Themes.palette().violet
685
+ column.BorderSizePixel = 0
686
+ column.Visible = false
687
+ -- Under the bars and the hover highlight. They never share a slot, but
688
+ -- depth should not be the thing standing between them if that changes.
689
+ column.ZIndex = 1
690
+ column.Parent = trace
691
+
692
+ -- Presets draw points as well as bars, same as the history slots.
693
+ local round = Instance.new("UICorner")
694
+ round.CornerRadius = UDim.new(0, 1)
695
+ round.Parent = column
696
+
697
+ table.insert(runtime.wave, column)
698
+ end
699
+ end
700
+
701
+ --[[
702
+ Runs the connecting wave for one frame.
703
+
704
+ Cheap when nothing is connecting: the ramp settles at zero, the columns are
705
+ hidden once, the true trace is restored once, and every later frame leaves
706
+ after two comparisons.
707
+ ]]
708
+ function stepWave(delta: number)
709
+ runtime.cheer = math.max(0, runtime.cheer - delta / CHEER_TIME)
710
+
711
+ runtime.waveHold = math.max(0, runtime.waveHold - delta)
712
+ local target = if runtime.waveWanted or runtime.waveHold > 0 then 1 else 0
713
+ local rate = if target > runtime.waveLevel then WAVE_IN else WAVE_OUT
714
+ runtime.waveLevel += (target - runtime.waveLevel) * math.min(1, delta * rate)
715
+
716
+ --[[
717
+ Two sources, one opacity.
718
+
719
+ They overlap for a moment at every connection -- the status goes green,
720
+ which drops `waveWanted`, while the flourish is only just starting -- and
721
+ taking the louder of the two is what carries the handover without a dip
722
+ between the search ending and the arrival beginning.
723
+ ]]
724
+ local celebration = math.min(1, runtime.cheer * CHEER_FADE)
725
+ local strength = math.max(runtime.waveLevel, celebration)
726
+
727
+ if strength <= 0.01 then
728
+ if runtime.waveShown then
729
+ runtime.waveShown = false
730
+ runtime.waveLevel = 0
731
+ for _, column in runtime.wave do
732
+ column.Visible = false
733
+ end
734
+ -- The bars were being drawn lifted. Put the real timings back, once,
735
+ -- rather than leaving the history frozen mid-swell.
736
+ relayout()
737
+ end
738
+ return
739
+ end
740
+ runtime.waveShown = true
741
+
742
+ local slots = slotsAcross()
743
+ if slots == 0 then
744
+ return
745
+ end
746
+ growWave(slots)
747
+
748
+ local ctx = context()
749
+ local painter = Themes.active().paintSlot or defaultSlot
750
+ local phase = ctx.clock / WAVE_PERIOD
751
+ local crest = phase % 1
752
+ local ripple = slots / WAVE_RIPPLE_STRIDE
753
+ -- The flourish runs on its own progress rather than on the clock, so it is
754
+ -- the same length however long the connection took to land.
755
+ local arriving = runtime.cheer > 0
756
+ local progress = 1 - runtime.cheer
757
+
758
+ --[[
759
+ The wave itself, as a pure function of where a slot is.
760
+
761
+ Taking a slot number rather than a column is what lets the history use it
762
+ too: a bar at slot 6 and an empty column at slot 7 are handed the same
763
+ swell, so the surface is continuous across the join instead of stopping
764
+ where the data starts.
765
+ ]]
766
+ local function shape(number: number): (number, number)
767
+ -- 0 at the left edge of the trace, 1 at the right.
768
+ local x = 1 - (number - 0.5) / slots
769
+
770
+ if arriving then
771
+ --[[
772
+ Struck, not swept. `since` is how long ago the front passed this
773
+ slot: negative means it has not arrived, and the column waits
774
+ flat and dark rather than anticipating it.
775
+ ]]
776
+ local since = progress - x * CHEER_SWEEP
777
+ if since < 0 then
778
+ return WAVE_FLOOR, 1
779
+ end
780
+ -- One envelope for both height and brightness, so a column is at its
781
+ -- tallest exactly when it is at its brightest.
782
+ local settle = math.exp(-since * CHEER_DECAY)
783
+ local bounce = 0.5 + 0.5 * math.cos(since * CHEER_RING * math.pi * 2)
784
+ return math.clamp(WAVE_FLOOR + settle * bounce * 0.95, WAVE_FLOOR, 1),
785
+ math.clamp(1 - settle, 0, 1)
786
+ end
787
+
788
+ --[[
789
+ Distance to the crest, wrapped around the ends, so the swell leaves
790
+ the right edge and re-enters at the left instead of jumping back
791
+ across a trace it has just crossed.
792
+ ]]
793
+ local distance = math.abs(x - crest)
794
+ distance = math.min(distance, 1 - distance)
795
+ local swell = math.exp(-(distance * distance) / (2 * WAVE_SPREAD * WAVE_SPREAD))
796
+ local texture = 0.5 + 0.5 * math.sin((x * ripple - phase * 2) * math.pi * 2)
797
+
798
+ --[[
799
+ A floor everywhere and a hump at the crest. Away from the swell the
800
+ columns sit just off the baseline, which keeps the trace reading as
801
+ an instrument at rest rather than as a screenful of invented data.
802
+ ]]
803
+ return math.clamp(WAVE_FLOOR + swell * (0.55 + 0.45 * texture) * 0.9, WAVE_FLOOR, 1),
804
+ -- Freshness follows the swell rather than position. Every preset
805
+ -- fades a slot by how recent it is, so handing the crest an age of
806
+ -- zero lights it and leaves the rest of the trace resting.
807
+ math.clamp(1 - swell, 0, 1)
808
+ end
809
+
810
+ --[[
811
+ The history, riding the same swell.
812
+
813
+ Lifted against its own headroom and brightened to whichever of the two is
814
+ fresher, so a bar keeps its shape and its colour and simply moves with
815
+ the wave rolling under it.
816
+ ]]
817
+ local barCount = #runtime.bars
818
+ if barCount > 0 then
819
+ relayout(function(number: number, weight: number, age: number): (number, number)
820
+ local lift, lightness = shape(number)
821
+ --[[
822
+ Both channels scale with the ramp, which is what makes the wave
823
+ let go of the history rather than drop it. At full strength a bar
824
+ is lifted and lit by the swell; as the ramp falls it slides back
825
+ onto its own timing and its own age, and the last frame of the
826
+ wave and the first frame without one are the same picture.
827
+ ]]
828
+ local raised = weight + (lift - WAVE_FLOOR) * (1 - weight) * WAVE_LIFT * strength
829
+ local lit = age + (math.min(age, lightness) - age) * strength
830
+ return math.clamp(raised, weight, 1), lit
831
+ end)
832
+ end
833
+
834
+ for number, column in runtime.wave do
835
+ if number > slots or number <= barCount then
836
+ --[[
837
+ Past the panel's edge, or a slot the history owns. Either way the
838
+ wave has no business drawing here -- this is what keeps the two
839
+ from ever landing on the same five pixels.
840
+ ]]
841
+ column.Visible = false
842
+ continue
843
+ end
844
+
845
+ local weight, age = shape(number)
846
+ local slot: Themes.Slot = {
847
+ frame = column,
848
+ -- Kept agreeing with the height, since that is the pair every
849
+ -- painter is written against, even though none of them reads it.
850
+ milliseconds = weight * SLOW_MS,
851
+ ok = true,
852
+ title = if arriving then "connected" else "connecting",
853
+ age = age,
854
+ weight = weight,
855
+ }
856
+
857
+ -- Set before the painter runs, off the same helper the bars use: every
858
+ -- painter preserves the X it is given and decides the rest.
859
+ column.Position = UDim2.new(1, slotOffset(number), 1, 0)
860
+ if not pcall(painter, slot, ctx) then
861
+ -- Silent, for the same reason the modulated history path is: a
862
+ -- preset that throws here would throw sixty times a second.
863
+ defaultSlot(slot, ctx)
864
+ end
865
+
866
+ --[[
867
+ Faded as one thing, after whoever drew it. The ramp belongs to the
868
+ wave and not to any preset, and applying it to the transparency the
869
+ painter chose keeps each theme's own weighting intact.
870
+ ]]
871
+ column.BackgroundTransparency = 1 - (1 - column.BackgroundTransparency) * strength
872
+ column.Visible = true
873
+ end
874
+ end
875
+
876
+ --[[
877
+ Records one finished call and re-draws the trace.
878
+ ]]
879
+ function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
880
+ runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
881
+ runtime.activity = math.min(1, runtime.activity + 0.34)
882
+ runtime.quiet = 0
883
+ runtime.flare = 1
884
+
885
+ --[[
886
+ A shove rather than a new target.
887
+
888
+ Setting the scale outright would snap; giving the spring velocity lets it
889
+ carry past its resting size and fall back, which is the difference between
890
+ a value changing and something being struck. A failure shoves the other
891
+ way and knocks the axis with it.
892
+ ]]
893
+ if ok then
894
+ --[[
895
+ Sized to peak just under the clamp rather than against it.
896
+
897
+ At +9 the shove carried the scale into the 1.7 ceiling within about a
898
+ tenth of a second, so what should have been an arc became a rise, a
899
+ flat spot, and a drop -- and a burst of calls, each adding another 9
900
+ to a velocity that was already railed, held it pinned there for as
901
+ long as the burst lasted. +6 tops out around 1.55, which reads as the
902
+ same strike and stays on the curve. The accumulated case is capped
903
+ for the same reason: several calls at once should look busy, not
904
+ stuck.
905
+ ]]
906
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity + 6, 8)
907
+ else
908
+ runtime.scale = 0.8
909
+ runtime.scaleVelocity = -2
910
+ runtime.shake = 1
911
+ end
912
+
913
+ local trace = runtime.trace
914
+ if trace == nil then
915
+ return
916
+ end
917
+
918
+ local bar = Instance.new("Frame")
919
+ bar.AnchorPoint = Vector2.new(0.5, 1)
920
+ bar.BorderSizePixel = 0
921
+ -- Above the wave's columns and the hover highlight. A plugin widget draws
922
+ -- with ZIndexBehavior.Global, so depth is stated rather than inherited from
923
+ -- the order things happened to be built in.
924
+ bar.ZIndex = 2
925
+ bar.Parent = trace
926
+ -- Presets draw points as well as bars, and a square point is not a point.
927
+ local corner = Instance.new("UICorner")
928
+ corner.CornerRadius = UDim.new(0, 1)
929
+ corner.Parent = bar
930
+
931
+ -- A TextButton rather than a Frame: buttons take mouse events reliably in a
932
+ -- plugin widget, and with no text and no background it is purely a target.
933
+ local hit = Instance.new("TextButton")
934
+ hit.AnchorPoint = Vector2.new(0.5, 1)
935
+ hit.BackgroundTransparency = 1
936
+ hit.Text = ""
937
+ hit.AutoButtonColor = false
938
+ hit.BorderSizePixel = 0
939
+ hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
940
+ hit.Parent = trace
941
+
942
+ local entry: Bar = {
943
+ frame = bar,
944
+ hit = hit,
945
+ milliseconds = milliseconds,
946
+ ok = ok,
947
+ title = if title ~= "" then title else "call",
948
+ }
949
+ table.insert(runtime.bars, entry)
950
+
951
+ hit.MouseEnter:Connect(function()
952
+ showReading(entry)
953
+ end)
954
+ hit.MouseLeave:Connect(function()
955
+ clearReading()
956
+ end)
957
+
958
+ while #runtime.bars > TRACE do
959
+ local oldest = runtime.bars[1]
960
+ oldest.frame:Destroy()
961
+ oldest.hit:Destroy()
962
+ table.remove(runtime.bars, 1)
963
+ end
964
+
965
+ relayout()
966
+
967
+ -- The bars have all moved, so a reading still on screen now names the wrong
968
+ -- one. Cheaper and more honest to drop it than to work out which bar the
969
+ -- pointer has ended up over.
970
+ clearReading()
971
+ end
972
+
973
+ --[[
974
+ The resting colour, which is the connection state.
975
+ ]]
976
+ function Visuals.setTint(color: Color3)
977
+ runtime.baseTint = color
978
+ end
979
+
980
+ --[[
981
+ The colour and pace of the work now running.
982
+
983
+ Both are set from the same call because they describe the same thing: what
984
+ kind of command this is. Reads are quick and cool, writes are slower and
985
+ warm, so the strip's colour and its rate agree with each other instead of
986
+ moving independently.
987
+ ]]
988
+ function Visuals.setKind(color: Color3, urgency: number)
989
+ runtime.activeTint = color
990
+ runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
991
+ runtime.quiet = 0
992
+ --[[
993
+ Drawn in as the command goes out, so the pair reads as one gesture: the
994
+ cell contracts while the request is in flight and springs open when the
995
+ answer arrives. On a fast call the two are almost one motion, which is
996
+ itself the report -- a slow call visibly holds its breath.
997
+ ]]
998
+ runtime.scale = math.min(runtime.scale, 0.85)
999
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
1000
+ -- Re-aimed per kind so successive commands of different types visibly
1001
+ -- change the axis rather than continuing the same turn.
1002
+ runtime.spin = Vector3.new(
1003
+ 0.12 + math.random() * 0.16,
1004
+ 0.18 + math.random() * 0.22,
1005
+ 0.06 + math.random() * 0.12
1006
+ )
1007
+ end
1008
+
1009
+ --[[
1010
+ What is running, in the same words the log uses. The motion says something is
1011
+ happening; this says what, and neither answers the other's question.
1012
+ ]]
1013
+ function Visuals.setCaption(title: string)
1014
+ runtime.lastTitle = title
1015
+ if runtime.caption then
1016
+ runtime.caption.Text = title
1017
+ end
1018
+ end
1019
+
1020
+ --[[
1021
+ Falls back to the last thing that ran once a command finishes.
1022
+
1023
+ The footer already reports totals, so repeating "idle" here would say the
1024
+ same word twice on one screen. What has just happened is more useful and is
1025
+ not shown anywhere else.
1026
+ ]]
1027
+ function Visuals.setIdle()
1028
+ if runtime.caption == nil then
1029
+ return
1030
+ end
1031
+ runtime.caption.Text = if runtime.lastTitle ~= nil
1032
+ then "last: " .. runtime.lastTitle
1033
+ else "waiting for a command"
1034
+ end
1035
+
1036
+ --[[
1037
+ Winds the cell down, because the session has stopped rather than paused.
1038
+
1039
+ The strip already decays on its own, but decay bottoms out at "idle and
1040
+ moving", which looks the same after twenty seconds as after two hours. This
1041
+ is the console telling the picture what it has just told the log, so the two
1042
+ agree.
1043
+ ]]
1044
+ function Visuals.setQuiet()
1045
+ runtime.quiet = QUIET_AFTER + 1
1046
+ runtime.activity = 0
1047
+ end
1048
+
1049
+ --[[
1050
+ Empties the trace.
1051
+
1052
+ Paired with the console's clear button. The bars used to survive it while the
1053
+ footer counters reset, so "clear" wiped the log, wiped the statistics, and
1054
+ left forty timings from the session it had just erased sitting on screen.
1055
+ ]]
1056
+ function Visuals.clearTrace()
1057
+ for _, entry in runtime.bars do
1058
+ entry.frame:Destroy()
1059
+ entry.hit:Destroy()
1060
+ end
1061
+ table.clear(runtime.bars)
1062
+ clearReading()
1063
+ end
1064
+
1065
+ function Visuals.setVisible(visible: boolean)
1066
+ local band = runtime.band
1067
+ if band == nil then
1068
+ return
1069
+ end
1070
+ band.Visible = visible
1071
+
1072
+ -- Connected only while on screen: a hidden strip that keeps projecting
1073
+ -- geometry every frame is a battery complaint waiting to happen.
1074
+ if visible and runtime.connection == nil then
1075
+ runtime.connection = RunService.Heartbeat:Connect(step)
1076
+ elseif not visible and runtime.connection ~= nil then
1077
+ runtime.connection:Disconnect()
1078
+ runtime.connection = nil
1079
+ end
1080
+ end
1081
+
1082
+ function Visuals.isVisible(): boolean
1083
+ local band = runtime.band
1084
+ return band ~= nil and band.Visible
1085
+ end
1086
+
1087
+ --[[
1088
+ Swaps the cell's contents and recolours the strip's own chrome.
1089
+
1090
+ The order matters. The outgoing preset is unmounted before the cell is
1091
+ emptied, so a preset that keeps references cannot be handed destroyed
1092
+ Instances; the cell is then cleared outright rather than trusted to be
1093
+ clean, because a preset that errored midway through `mount` will have left
1094
+ something behind.
1095
+ ]]
1096
+ function Visuals.applyTheme()
1097
+ local cell = runtime.cell
1098
+ local band = runtime.band
1099
+ if cell == nil or band == nil then
1100
+ return
1101
+ end
1102
+
1103
+ if runtime.mounted then
1104
+ pcall(function()
1105
+ Themes.active().unmount()
1106
+ end)
1107
+ end
1108
+ for _, child in cell:GetChildren() do
1109
+ child:Destroy()
1110
+ end
1111
+
1112
+ local palette = Themes.palette()
1113
+ band.BackgroundColor3 = palette.surface
1114
+ if runtime.divider then
1115
+ (runtime.divider :: Frame).BackgroundColor3 = palette.dim
1116
+ end
1117
+ if runtime.baseline then
1118
+ (runtime.baseline :: Frame).BackgroundColor3 = palette.dim
1119
+ end
1120
+ if runtime.highlight then
1121
+ (runtime.highlight :: Frame).BackgroundColor3 = palette.text
1122
+ end
1123
+ if runtime.caption then
1124
+ (runtime.caption :: TextLabel).TextColor3 = palette.text
1125
+ end
1126
+ if runtime.readout then
1127
+ (runtime.readout :: TextLabel).BackgroundColor3 = palette.background
1128
+ end
1129
+
1130
+ local ok, err = pcall(function()
1131
+ Themes.active().mount(cell)
1132
+ end)
1133
+ runtime.mounted = ok
1134
+ if not ok then
1135
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
1136
+ end
1137
+
1138
+ relayout()
1139
+ -- A preset that failed to paint disabled the band; a new one deserves the
1140
+ -- chance to prove it works.
1141
+ band.Visible = true
1142
+ end
1143
+
1144
+ --[[
1145
+ Builds the strip. Everything except the cell's contents is created here and
1146
+ never again, which is what keeps the per-frame path allocation-free.
1147
+ ]]
1148
+ function Visuals.mount(parent: Instance)
1149
+ local palette = Themes.palette()
1150
+
1151
+ local band = Instance.new("Frame")
1152
+ band.Name = "ActivityBand"
1153
+ band.BackgroundColor3 = palette.surface
1154
+ band.BorderSizePixel = 0
1155
+ band.Visible = false
1156
+ band.ClipsDescendants = true
1157
+ band.Parent = parent
1158
+ runtime.band = band
1159
+
1160
+ local cell = Instance.new("Frame")
1161
+ cell.Name = "Cell"
1162
+ cell.Position = UDim2.fromOffset(INSET, (Visuals.BAND_HEIGHT - CELL) / 2)
1163
+ cell.Size = UDim2.fromOffset(CELL, CELL)
1164
+ cell.BackgroundTransparency = 1
1165
+ cell.ClipsDescendants = true
1166
+ cell.Parent = band
1167
+ runtime.cell = cell
1168
+
1169
+ -- A hairline between the cell and the trace, so the strip reads as two
1170
+ -- instruments rather than one busy rectangle. Centred in the gutter, which
1171
+ -- is what keeps the extra margin as air on both sides rather than as a gap
1172
+ -- on one.
1173
+ local divider = Instance.new("Frame")
1174
+ divider.Name = "Divider"
1175
+ divider.Position = UDim2.fromOffset(INSET + CELL + GUTTER / 2, 10)
1176
+ divider.Size = UDim2.new(0, 1, 1, -20)
1177
+ divider.BackgroundColor3 = palette.dim
1178
+ divider.BackgroundTransparency = 0.78
1179
+ divider.BorderSizePixel = 0
1180
+ divider.Parent = band
1181
+ runtime.divider = divider
1182
+
1183
+ local right = INSET + CELL + GUTTER
1184
+
1185
+ local caption = Instance.new("TextLabel")
1186
+ caption.Name = "Caption"
1187
+ caption.Position = UDim2.new(0, right, 0, 6)
1188
+ caption.Size = UDim2.new(1, -right - 12, 0, 14)
1189
+ caption.BackgroundTransparency = 1
1190
+ caption.Font = Enum.Font.Code
1191
+ caption.TextSize = 11
1192
+ caption.TextColor3 = palette.text
1193
+ caption.TextXAlignment = Enum.TextXAlignment.Left
1194
+ caption.TextTruncate = Enum.TextTruncate.AtEnd
1195
+ caption.Text = "idle"
1196
+ caption.Parent = band
1197
+ runtime.caption = caption
1198
+
1199
+ local trace = Instance.new("Frame")
1200
+ trace.Name = "Trace"
1201
+ trace.Position = UDim2.new(0, right, 0, 24)
1202
+ trace.Size = UDim2.new(1, -right - 12, 0, TRACE_HEIGHT)
1203
+ trace.BackgroundTransparency = 1
1204
+ trace.ClipsDescendants = true
1205
+ trace.Parent = band
1206
+ runtime.trace = trace
1207
+
1208
+ -- A baseline under the bars, so an empty trace still reads as an instrument
1209
+ -- waiting for data rather than as a blank gap.
1210
+ local baseline = Instance.new("Frame")
1211
+ baseline.Name = "Baseline"
1212
+ baseline.AnchorPoint = Vector2.new(0, 1)
1213
+ baseline.Position = UDim2.fromScale(0, 1)
1214
+ baseline.Size = UDim2.new(1, 0, 0, 1)
1215
+ baseline.BackgroundColor3 = palette.dim
1216
+ baseline.BackgroundTransparency = 0.75
1217
+ baseline.BorderSizePixel = 0
1218
+ baseline.Parent = trace
1219
+ runtime.baseline = baseline
1220
+
1221
+ --[[
1222
+ Built once and moved, and created BEFORE the bars exist so it sits under
1223
+ them in draw order -- a highlight drawn over a one-pixel bar would hide the
1224
+ very thing it is pointing at.
1225
+ ]]
1226
+ local highlight = Instance.new("Frame")
1227
+ highlight.Name = "Highlight"
1228
+ highlight.AnchorPoint = Vector2.new(0.5, 1)
1229
+ highlight.Position = UDim2.new(1, 0, 1, 0)
1230
+ highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
1231
+ highlight.BackgroundColor3 = palette.text
1232
+ -- Faint enough not to hide the bar it sits behind, solid enough to be seen
1233
+ -- at all.
1234
+ highlight.BackgroundTransparency = 0.7
1235
+ highlight.BorderSizePixel = 0
1236
+ highlight.Visible = false
1237
+ highlight.Parent = trace
1238
+ runtime.highlight = highlight
1239
+
1240
+ --[[
1241
+ The reading sits at the left end of the trace rather than beside the bar it
1242
+ describes. Following the pointer would put it off the right edge for the
1243
+ newest bars, which are the ones most often asked about, and the left end is
1244
+ empty until forty calls have accumulated.
1245
+ ]]
1246
+ local readout = Instance.new("TextLabel")
1247
+ readout.Name = "Readout"
1248
+ readout.AnchorPoint = Vector2.new(0, 0.5)
1249
+ readout.Position = UDim2.new(0, 0, 0.5, 0)
1250
+ -- Sized by its text now that it carries a name as well as a number. A fixed
1251
+ -- 110px was enough for "18 ms" and truncates anything with a phrase in front
1252
+ -- of it, which is the half worth reading.
1253
+ readout.AutomaticSize = Enum.AutomaticSize.X
1254
+ readout.Size = UDim2.new(0, 0, 0, 14)
1255
+ readout.BackgroundColor3 = palette.background
1256
+ readout.BackgroundTransparency = 0.15
1257
+ readout.BorderSizePixel = 0
1258
+ readout.Font = Enum.Font.Code
1259
+ readout.TextSize = 11
1260
+ readout.TextXAlignment = Enum.TextXAlignment.Left
1261
+ readout.Text = ""
1262
+ readout.Visible = false
1263
+ readout.ZIndex = 3
1264
+ readout.Parent = trace
1265
+ runtime.readout = readout
1266
+
1267
+ local readoutPadding = Instance.new("UIPadding")
1268
+ readoutPadding.PaddingLeft = UDim.new(0, 4)
1269
+ readoutPadding.PaddingRight = UDim.new(0, 4)
1270
+ readoutPadding.Parent = readout
1271
+
1272
+ local ok, err = pcall(function()
1273
+ Themes.active().mount(cell)
1274
+ end)
1275
+ runtime.mounted = ok
1276
+ if not ok then
1277
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
1278
+ end
1279
+ end
1280
+
1281
+ return Visuals