@el4cteo/rbx-studio-mcp 0.4.6 → 0.5.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,1332 +1,1409 @@
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
- --[[
236
- What the caption said before a note took it over.
237
-
238
- Nil means no note is showing, which is why this is the flag as well as
239
- the storage: a second `showNote` while one is up must not overwrite the
240
- text being held, or clearing would restore a note instead of the
241
- caption.
242
- ]]
243
- noted: string?,
244
- bars: { Bar },
245
- connection: RBXScriptConnection?,
246
-
247
- spin: Vector3,
248
- angle: Vector3,
249
-
250
- energy: number,
251
- shake: number,
252
- --[[
253
- The cell's size, as a spring rather than a value.
254
-
255
- Scale was previously a small function of energy -- a 24% range that
256
- nobody could see. Driving it as a spring means each event can push it and
257
- let physics do the rest: a dispatch pulls it in, a reply throws it out
258
- past its resting size, and it settles on its own.
259
- ]]
260
- scale: number,
261
- scaleVelocity: number,
262
- -- Spikes to 1 the instant a reply lands, gone in a third of a second.
263
- flare: number,
264
- -- Seconds since anything last ran. Drives the wind-down.
265
- quiet: number,
266
- tint: Color3,
267
- targetTint: Color3,
268
- baseTint: Color3,
269
- activeTint: Color3,
270
- activity: number,
271
- lastTitle: string?,
272
- -- Whether the active preset has been given its Instances yet.
273
- mounted: boolean,
274
-
275
- -- The wave's columns, pooled at mount and hidden until a connect needs them.
276
- wave: { Frame },
277
- -- What the transport last said: true while it is trying to reach the bridge.
278
- waveWanted: boolean,
279
- -- The ramp, 0 to 1. Separate from `waveWanted` so the wave fades rather than
280
- -- appearing and vanishing with the status line.
281
- waveLevel: number,
282
- -- Seconds the wave is held up regardless of `waveWanted`. See WAVE_HOLD.
283
- waveHold: number,
284
- -- Whether the columns are currently on screen. Kept rather than derived, so
285
- -- a settled wave hides them once instead of every frame afterwards.
286
- waveShown: boolean,
287
- -- The arrival flourish: 1 the instant a session connects, down to 0 over
288
- -- CHEER_TIME. Overrides the connecting wave's shape while it runs.
289
- cheer: number,
290
- }
291
-
292
- local runtime: Runtime = {
293
- band = nil,
294
- cell = nil,
295
- trace = nil,
296
- divider = nil,
297
- baseline = nil,
298
- highlight = nil,
299
- readout = nil,
300
- caption = nil,
301
- noted = nil,
302
- bars = {},
303
- connection = nil,
304
- spin = Vector3.new(0.18, 0.27, 0.11),
305
- angle = Vector3.zero,
306
- energy = 0,
307
- shake = 0,
308
- scale = 1,
309
- scaleVelocity = 0,
310
- flare = 0,
311
- quiet = 0,
312
- tint = Color3.fromRGB(167, 139, 250),
313
- targetTint = Color3.fromRGB(167, 139, 250),
314
- baseTint = Color3.fromRGB(167, 139, 250),
315
- activeTint = Color3.fromRGB(167, 139, 250),
316
- activity = 0,
317
- lastTitle = nil,
318
- mounted = false,
319
- wave = {},
320
- waveWanted = false,
321
- waveLevel = 0,
322
- waveHold = 0,
323
- waveShown = false,
324
- cheer = 0,
325
- }
326
-
327
- --[[
328
- The simulation, packaged for whichever preset is drawing it.
329
-
330
- Rebuilt every frame rather than mutated in place. A preset holding onto the
331
- table between frames would see values change under it, and the one rule that
332
- keeps eight renderers honest is that they read this and draw -- they do not
333
- own any of it.
334
- ]]
335
- local function context(): Themes.Context
336
- return {
337
- palette = Themes.palette(),
338
- tint = runtime.tint,
339
- energy = runtime.energy,
340
- activity = runtime.activity,
341
- flare = runtime.flare,
342
- shake = runtime.shake,
343
- scale = runtime.scale,
344
- quiet = runtime.quiet,
345
- angle = runtime.angle,
346
- clock = os.clock(),
347
- size = CELL,
348
- centre = Vector2.new(CELL / 2, CELL / 2),
349
- }
350
- end
351
-
352
- -- Declared here and defined below, because it draws through the same slot
353
- -- painter history does and that helper is defined after this function.
354
- local stepWave: (delta: number) -> ()
355
-
356
- local function step(delta: number)
357
- local band = runtime.band
358
- local cell = runtime.cell
359
- if band == nil or cell == nil or not band.Visible then
360
- return
361
- end
362
-
363
- runtime.energy = math.max(0, runtime.energy - delta * 0.9)
364
- runtime.shake = math.max(0, runtime.shake - delta * 2.4)
365
- --[[
366
- Slower than it was. The cell is meant to climb as the session works, and
367
- at the old rates a call had to arrive every three seconds just to hold the
368
- middle of the range -- so the top of it was, in practice, unreachable. A
369
- burst of four calls now gets there, and it unwinds over about ten seconds
370
- afterwards.
371
- ]]
372
- runtime.activity = math.max(0, runtime.activity - delta * 0.28)
373
- runtime.flare = math.max(0, runtime.flare - delta * 3)
374
- runtime.quiet += delta
375
- --[[
376
- Two colours, blended by how recently something happened.
377
-
378
- `baseTint` is the connection state, which is what the panel should read
379
- as when nothing is going on. `activeTint` is the kind of work last done
380
- -- reading, writing, running, debugging -- and it takes over while that
381
- work is fresh, then fades back. So the strip is not one fixed colour: it
382
- leans toward whatever the session is actually doing and returns to
383
- resting on its own.
384
- ]]
385
- local blend = math.clamp(runtime.energy, 0, 1)
386
- runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
387
- runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
388
-
389
- -- Turning faster while busy makes the rate itself readable: a glance says
390
- -- whether anything is happening without reading a word.
391
- --
392
- -- And nearly stopping once the session has gone quiet, for the same reason in
393
- -- reverse: a strip that keeps moving at its working rate hours after the
394
- -- last command claims an activity that is not happening.
395
- local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
396
- runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
397
-
398
- --[[
399
- Where the cell wants to be, before the spring gets a say.
400
-
401
- Three things move it and they are deliberately ordered. Energy -- how
402
- recently work happened -- swells it while a session is busy. The breathe
403
- is a slow sine that only survives while energy is near zero, so an idle
404
- panel is visibly alive without a resting animation competing with the
405
- reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
406
- in and stays there: the picture of a session that has stopped.
407
- ]]
408
- local calm = 1 - math.clamp(runtime.energy, 0, 1)
409
- local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
410
- local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
411
- if runtime.quiet > QUIET_AFTER then
412
- target = 0.82
413
- end
414
-
415
- --[[
416
- A damped spring rather than a lerp, because a lerp cannot overshoot and
417
- overshoot is the whole point: a reply that pushes the cell past its
418
- resting size and lets it fall back reads as a thing being struck, where
419
- easing toward a value reads as a slider being dragged.
420
-
421
- Integrated semi-implicitly -- velocity first, then position from the new
422
- velocity -- which stays stable at the frame times Studio actually hands us
423
- rather than only at small ones.
424
- ]]
425
- --[[
426
- Substepped rather than truncated.
427
-
428
- `math.min(delta, 1/30)` kept the integration stable, but it also THREW
429
- AWAY the rest of a long frame: at 20fps a third of every frame's motion
430
- simply did not happen, so the spring ran slower in wall-clock time
431
- exactly when Studio was busiest -- which is during a tool call, which is
432
- when the spring is reacting to something. Stepping the leftover instead
433
- keeps the reaction the same length whatever the frame rate, and the cap
434
- on the number of steps stops a two-second hitch from paying it all back
435
- in one frame.
436
- ]]
437
- local remaining = math.min(delta, 0.25)
438
- for _ = 1, 8 do
439
- if remaining <= 0 then
440
- break
441
- end
442
- local integration = math.min(remaining, 1 / 60)
443
- remaining -= integration
444
-
445
- runtime.scaleVelocity += (target - runtime.scale) * SPRING * integration
446
- runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * integration
447
- runtime.scale += runtime.scaleVelocity * integration
448
-
449
- --[[
450
- Clamping the position without killing the velocity is what made a
451
- reply read as a jolt rather than a bounce. The reply impulse is
452
- `scaleVelocity += 9`, which crosses the whole 0.55..1.7 range in
453
- about two frames, so the scale pinned itself against the ceiling
454
- while still carrying all that speed -- and then sat there, motionless
455
- but not at rest, until the spring had spent the stored velocity. The
456
- eye reads that as a snap, a stall, and a second snap.
457
-
458
- Zeroing the component pushing into the rail turns it into a stop.
459
- ]]
460
- if runtime.scale <= 0.55 then
461
- runtime.scale = 0.55
462
- runtime.scaleVelocity = math.max(runtime.scaleVelocity, 0)
463
- elseif runtime.scale >= 1.7 then
464
- runtime.scale = 1.7
465
- runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
466
- end
467
- end
468
-
469
- -- Stepped before the cell is handed off, so a preset that errors and
470
- -- disables the strip does not also take the connect animation with it.
471
- stepWave(delta)
472
-
473
- --[[
474
- Handed off. A preset that throws must not take the console's status
475
- display down with it, so a failure here disables the strip and says so
476
- once rather than erroring sixty times a second.
477
- ]]
478
- local ok, err = pcall(Themes.active().paint, context(), delta)
479
- if not ok then
480
- band.Visible = false
481
- warn(string.format("[rbx-studio] theme %q failed to paint: %s", Themes.activeId(), tostring(err)))
482
- end
483
- end
484
-
485
- --[[
486
- Puts the highlight behind one bar and prints its timing.
487
-
488
- The trace is only legible if you already know what it plots, and nothing on
489
- screen said so. Hovering answers it directly: the column lights up, and the
490
- number that made that bar its height appears in words. One reading at a time,
491
- so a single highlight and a single label are moved around rather than one of
492
- each being built per bar.
493
- ]]
494
- local function showReading(entry: Bar)
495
- local highlight = runtime.highlight
496
- local readout = runtime.readout
497
- if highlight == nil or readout == nil then
498
- return
499
- end
500
-
501
- --[[
502
- The Y here has to be 1, and was 0.
503
-
504
- The highlight is anchored to its own bottom edge, so a Y scale of 0 put
505
- that edge on the trace's top line and drew the whole 26px column above it
506
- -- outside a frame that clips its descendants. The hover was therefore
507
- never visible to anyone: the hit targets fired, the readout appeared, and
508
- the highlight they were meant to explain was off screen every time.
509
- ]]
510
- highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
511
- highlight.Visible = true
512
-
513
- --[[
514
- Named, not just timed.
515
-
516
- A duration with nothing attached to it raises the question it cannot
517
- answer -- forty bars, one of them tall, and no way to tell whether that
518
- was a script edit or a screenshot. The title is the same phrase the log
519
- row uses, so pointing at a bar and reading the log agree with each other.
520
- ]]
521
- local palette = Themes.palette()
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 then palette.text else palette.red
526
- readout.Visible = true
527
- end
528
-
529
- local function clearReading()
530
- if runtime.highlight then
531
- runtime.highlight.Visible = false
532
- end
533
- if runtime.readout then
534
- runtime.readout.Visible = false
535
- end
536
- end
537
-
538
- --[[
539
- Bars, for a preset that does not want to draw its own history.
540
-
541
- Height is duration and colour is outcome, so a slow call and a failed one are
542
- distinguishable at a glance -- which is the pair of questions that actually
543
- get asked of a log this size.
544
- ]]
545
- local function defaultSlot(slot: Themes.Slot, ctx: Themes.Context)
546
- local frame = slot.frame
547
- frame.AnchorPoint = Vector2.new(0.5, 1)
548
- frame.Size = UDim2.new(0, 3, math.max(slot.weight, 0.06), 0)
549
- frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
550
- frame.BackgroundColor3 = if slot.ok then ctx.palette.violet else ctx.palette.red
551
- frame.BackgroundTransparency = 0.15 + slot.age * 0.6
552
- frame.Rotation = 0
553
- end
554
-
555
- --[[
556
- Whether the transport is currently reaching for the bridge.
557
-
558
- Told rather than inferred, because "connecting" is the transport's word and
559
- the trace must never disagree with the status dot about what the session is
560
- doing.
561
- ]]
562
- function Visuals.setConnecting(active: boolean)
563
- if active and not runtime.waveWanted then
564
- runtime.waveHold = WAVE_HOLD
565
- end
566
- runtime.waveWanted = active
567
- end
568
-
569
- --[[
570
- Plays the arrival flourish, once.
571
-
572
- Edge-triggered by the caller rather than latched here: only the status line
573
- knows whether this is a session actually landing or the same `connected`
574
- state being repeated after a theme switch, and a celebration that replays
575
- itself every time the port is re-read stops meaning anything.
576
- ]]
577
- function Visuals.celebrate()
578
- runtime.cheer = 1
579
- -- The search is over. Nothing should be left holding the connecting wave up
580
- -- underneath a flourish that has replaced it.
581
- runtime.waveWanted = false
582
- runtime.waveHold = 0
583
- end
584
-
585
- --[[
586
- Re-places every bar and hands each to the active preset to draw.
587
-
588
- Laid out right to left so the newest bar is always at the same edge and the
589
- trace reads as scrolling rather than reshuffling. The horizontal position is
590
- set here and the preset is expected to keep it -- everything else about the
591
- bar is the preset's to decide.
592
- ]]
593
- --[[
594
- Where slot `number` sits, counting 1 at the right edge and rising leftwards.
595
-
596
- The one place the trace's spacing is decided. Both the history and the wave
597
- call it, which is what guarantees they land on the same grid rather than on
598
- two that merely look similar.
599
- ]]
600
- local function slotOffset(number: number): number
601
- return -(number - 0.5) * BAR_STRIDE
602
- end
603
-
604
- --[[
605
- Re-places every bar and hands each to the active preset to draw.
606
-
607
- Laid out right to left so the newest bar is always at the same edge and the
608
- trace reads as scrolling rather than reshuffling. The horizontal position is
609
- set here and the preset is expected to keep it -- everything else about the
610
- bar is the preset's to decide.
611
-
612
- `modulate` is the wave, when one is running. It is handed a bar's slot
613
- number and its true height and freshness, and returns what to draw instead,
614
- so the history is lifted by the same swell that lifts the empty slots beside
615
- it. Absent, every bar is drawn from its own recorded timing, which is what
616
- restores the true trace the moment a wave ends.
617
- ]]
618
- local function relayout(modulate: ((number, number, number) -> (number, number))?)
619
- local ctx = context()
620
- local painter = Themes.active().paintSlot or defaultSlot
621
- local total = #runtime.bars
622
-
623
- for index, item in runtime.bars do
624
- local fromRight = total - index
625
- local number = fromRight + 1
626
- local offset = slotOffset(number)
627
-
628
- item.frame.Position = UDim2.new(1, offset, 1, 0)
629
- item.hit.Position = UDim2.new(1, offset, 1, 0)
630
-
631
- -- 0 for the newest, rising toward 1 for the oldest still shown.
632
- local age = if TRACE > 1 then fromRight / (TRACE - 1) else 0
633
- local weight = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1)
634
- if modulate ~= nil then
635
- weight, age = modulate(number, weight, age)
636
- end
637
-
638
- local slot: Themes.Slot = {
639
- frame = item.frame,
640
- milliseconds = item.milliseconds,
641
- ok = item.ok,
642
- title = item.title,
643
- age = age,
644
- weight = weight,
645
- }
646
- local ok, err = pcall(painter, slot, ctx)
647
- if not ok then
648
- -- One bad slot must not leave the other thirty-nine unplaced.
649
- defaultSlot(slot, ctx)
650
- --[[
651
- Silent under a wave. This runs sixty times a second while one is
652
- playing, and a preset that throws would fill the log faster than
653
- anyone could read it -- the unmodulated path below still says so
654
- once the next call lands.
655
- ]]
656
- if modulate == nil then
657
- warn(string.format("[rbx-studio] theme %q failed on a slot: %s", Themes.activeId(), tostring(err)))
658
- end
659
- end
660
- end
661
- end
662
-
663
- --[[
664
- How many slots the trace has room for, at the shared stride.
665
-
666
- Read from the live width rather than fixed, because the dock is resizable and
667
- a wave that stops halfway across a widened panel is worse than no wave.
668
- ]]
669
- local function slotsAcross(): number
670
- local trace = runtime.trace
671
- if trace == nil then
672
- return 0
673
- end
674
- return math.clamp(math.floor(trace.AbsoluteSize.X / BAR_STRIDE), 0, WAVE_MAX)
675
- end
676
-
677
- --[[
678
- Makes sure the wave has a column for every slot up to `count`.
679
-
680
- Grown on demand and never shrunk. The pool only ever reaches the width the
681
- panel actually had, it is built the first time a connection is attempted
682
- rather than at mount, and a column costs nothing while it is hidden.
683
- ]]
684
- local function growWave(count: number)
685
- local trace = runtime.trace
686
- if trace == nil then
687
- return
688
- end
689
- for _ = #runtime.wave + 1, count do
690
- local column = Instance.new("Frame")
691
- column.Name = "Wave"
692
- column.AnchorPoint = Vector2.new(0.5, 1)
693
- column.Size = UDim2.fromOffset(2, 1)
694
- column.BackgroundColor3 = Themes.palette().violet
695
- column.BorderSizePixel = 0
696
- column.Visible = false
697
- -- Under the bars and the hover highlight. They never share a slot, but
698
- -- depth should not be the thing standing between them if that changes.
699
- column.ZIndex = 1
700
- column.Parent = trace
701
-
702
- -- Presets draw points as well as bars, same as the history slots.
703
- local round = Instance.new("UICorner")
704
- round.CornerRadius = UDim.new(0, 1)
705
- round.Parent = column
706
-
707
- table.insert(runtime.wave, column)
708
- end
709
- end
710
-
711
- --[[
712
- Runs the connecting wave for one frame.
713
-
714
- Cheap when nothing is connecting: the ramp settles at zero, the columns are
715
- hidden once, the true trace is restored once, and every later frame leaves
716
- after two comparisons.
717
- ]]
718
- function stepWave(delta: number)
719
- runtime.cheer = math.max(0, runtime.cheer - delta / CHEER_TIME)
720
-
721
- runtime.waveHold = math.max(0, runtime.waveHold - delta)
722
- local target = if runtime.waveWanted or runtime.waveHold > 0 then 1 else 0
723
- local rate = if target > runtime.waveLevel then WAVE_IN else WAVE_OUT
724
- runtime.waveLevel += (target - runtime.waveLevel) * math.min(1, delta * rate)
725
-
726
- --[[
727
- Two sources, one opacity.
728
-
729
- They overlap for a moment at every connection -- the status goes green,
730
- which drops `waveWanted`, while the flourish is only just starting -- and
731
- taking the louder of the two is what carries the handover without a dip
732
- between the search ending and the arrival beginning.
733
- ]]
734
- local celebration = math.min(1, runtime.cheer * CHEER_FADE)
735
- local strength = math.max(runtime.waveLevel, celebration)
736
-
737
- if strength <= 0.01 then
738
- if runtime.waveShown then
739
- runtime.waveShown = false
740
- runtime.waveLevel = 0
741
- for _, column in runtime.wave do
742
- column.Visible = false
743
- end
744
- -- The bars were being drawn lifted. Put the real timings back, once,
745
- -- rather than leaving the history frozen mid-swell.
746
- relayout()
747
- end
748
- return
749
- end
750
- runtime.waveShown = true
751
-
752
- local slots = slotsAcross()
753
- if slots == 0 then
754
- return
755
- end
756
- growWave(slots)
757
-
758
- local ctx = context()
759
- local painter = Themes.active().paintSlot or defaultSlot
760
- local phase = ctx.clock / WAVE_PERIOD
761
- local crest = phase % 1
762
- local ripple = slots / WAVE_RIPPLE_STRIDE
763
- -- The flourish runs on its own progress rather than on the clock, so it is
764
- -- the same length however long the connection took to land.
765
- local arriving = runtime.cheer > 0
766
- local progress = 1 - runtime.cheer
767
-
768
- --[[
769
- The wave itself, as a pure function of where a slot is.
770
-
771
- Taking a slot number rather than a column is what lets the history use it
772
- too: a bar at slot 6 and an empty column at slot 7 are handed the same
773
- swell, so the surface is continuous across the join instead of stopping
774
- where the data starts.
775
- ]]
776
- local function shape(number: number): (number, number)
777
- -- 0 at the left edge of the trace, 1 at the right.
778
- local x = 1 - (number - 0.5) / slots
779
-
780
- if arriving then
781
- --[[
782
- Struck, not swept. `since` is how long ago the front passed this
783
- slot: negative means it has not arrived, and the column waits
784
- flat and dark rather than anticipating it.
785
- ]]
786
- local since = progress - x * CHEER_SWEEP
787
- if since < 0 then
788
- return WAVE_FLOOR, 1
789
- end
790
- -- One envelope for both height and brightness, so a column is at its
791
- -- tallest exactly when it is at its brightest.
792
- local settle = math.exp(-since * CHEER_DECAY)
793
- local bounce = 0.5 + 0.5 * math.cos(since * CHEER_RING * math.pi * 2)
794
- return math.clamp(WAVE_FLOOR + settle * bounce * 0.95, WAVE_FLOOR, 1),
795
- math.clamp(1 - settle, 0, 1)
796
- end
797
-
798
- --[[
799
- Distance to the crest, wrapped around the ends, so the swell leaves
800
- the right edge and re-enters at the left instead of jumping back
801
- across a trace it has just crossed.
802
- ]]
803
- local distance = math.abs(x - crest)
804
- distance = math.min(distance, 1 - distance)
805
- local swell = math.exp(-(distance * distance) / (2 * WAVE_SPREAD * WAVE_SPREAD))
806
- local texture = 0.5 + 0.5 * math.sin((x * ripple - phase * 2) * math.pi * 2)
807
-
808
- --[[
809
- A floor everywhere and a hump at the crest. Away from the swell the
810
- columns sit just off the baseline, which keeps the trace reading as
811
- an instrument at rest rather than as a screenful of invented data.
812
- ]]
813
- return math.clamp(WAVE_FLOOR + swell * (0.55 + 0.45 * texture) * 0.9, WAVE_FLOOR, 1),
814
- -- Freshness follows the swell rather than position. Every preset
815
- -- fades a slot by how recent it is, so handing the crest an age of
816
- -- zero lights it and leaves the rest of the trace resting.
817
- math.clamp(1 - swell, 0, 1)
818
- end
819
-
820
- --[[
821
- The history, riding the same swell.
822
-
823
- Lifted against its own headroom and brightened to whichever of the two is
824
- fresher, so a bar keeps its shape and its colour and simply moves with
825
- the wave rolling under it.
826
- ]]
827
- local barCount = #runtime.bars
828
- if barCount > 0 then
829
- relayout(function(number: number, weight: number, age: number): (number, number)
830
- local lift, lightness = shape(number)
831
- --[[
832
- Both channels scale with the ramp, which is what makes the wave
833
- let go of the history rather than drop it. At full strength a bar
834
- is lifted and lit by the swell; as the ramp falls it slides back
835
- onto its own timing and its own age, and the last frame of the
836
- wave and the first frame without one are the same picture.
837
- ]]
838
- local raised = weight + (lift - WAVE_FLOOR) * (1 - weight) * WAVE_LIFT * strength
839
- local lit = age + (math.min(age, lightness) - age) * strength
840
- return math.clamp(raised, weight, 1), lit
841
- end)
842
- end
843
-
844
- for number, column in runtime.wave do
845
- if number > slots or number <= barCount then
846
- --[[
847
- Past the panel's edge, or a slot the history owns. Either way the
848
- wave has no business drawing here -- this is what keeps the two
849
- from ever landing on the same five pixels.
850
- ]]
851
- column.Visible = false
852
- continue
853
- end
854
-
855
- local weight, age = shape(number)
856
- local slot: Themes.Slot = {
857
- frame = column,
858
- -- Kept agreeing with the height, since that is the pair every
859
- -- painter is written against, even though none of them reads it.
860
- milliseconds = weight * SLOW_MS,
861
- ok = true,
862
- title = if arriving then "connected" else "connecting",
863
- age = age,
864
- weight = weight,
865
- }
866
-
867
- -- Set before the painter runs, off the same helper the bars use: every
868
- -- painter preserves the X it is given and decides the rest.
869
- column.Position = UDim2.new(1, slotOffset(number), 1, 0)
870
- if not pcall(painter, slot, ctx) then
871
- -- Silent, for the same reason the modulated history path is: a
872
- -- preset that throws here would throw sixty times a second.
873
- defaultSlot(slot, ctx)
874
- end
875
-
876
- --[[
877
- Faded as one thing, after whoever drew it. The ramp belongs to the
878
- wave and not to any preset, and applying it to the transparency the
879
- painter chose keeps each theme's own weighting intact.
880
- ]]
881
- column.BackgroundTransparency = 1 - (1 - column.BackgroundTransparency) * strength
882
- column.Visible = true
883
- end
884
- end
885
-
886
- --[[
887
- Records one finished call and re-draws the trace.
888
- ]]
889
- function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
890
- runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
891
- runtime.activity = math.min(1, runtime.activity + 0.34)
892
- runtime.quiet = 0
893
- runtime.flare = 1
894
-
895
- --[[
896
- A shove rather than a new target.
897
-
898
- Setting the scale outright would snap; giving the spring velocity lets it
899
- carry past its resting size and fall back, which is the difference between
900
- a value changing and something being struck. A failure shoves the other
901
- way and knocks the axis with it.
902
- ]]
903
- if ok then
904
- --[[
905
- Sized to peak just under the clamp rather than against it.
906
-
907
- At +9 the shove carried the scale into the 1.7 ceiling within about a
908
- tenth of a second, so what should have been an arc became a rise, a
909
- flat spot, and a drop -- and a burst of calls, each adding another 9
910
- to a velocity that was already railed, held it pinned there for as
911
- long as the burst lasted. +6 tops out around 1.55, which reads as the
912
- same strike and stays on the curve. The accumulated case is capped
913
- for the same reason: several calls at once should look busy, not
914
- stuck.
915
- ]]
916
- runtime.scaleVelocity = math.min(runtime.scaleVelocity + 6, 8)
917
- else
918
- runtime.scale = 0.8
919
- runtime.scaleVelocity = -2
920
- runtime.shake = 1
921
- end
922
-
923
- local trace = runtime.trace
924
- if trace == nil then
925
- return
926
- end
927
-
928
- local bar = Instance.new("Frame")
929
- bar.AnchorPoint = Vector2.new(0.5, 1)
930
- bar.BorderSizePixel = 0
931
- -- Above the wave's columns and the hover highlight. A plugin widget draws
932
- -- with ZIndexBehavior.Global, so depth is stated rather than inherited from
933
- -- the order things happened to be built in.
934
- bar.ZIndex = 2
935
- bar.Parent = trace
936
- -- Presets draw points as well as bars, and a square point is not a point.
937
- local corner = Instance.new("UICorner")
938
- corner.CornerRadius = UDim.new(0, 1)
939
- corner.Parent = bar
940
-
941
- -- A TextButton rather than a Frame: buttons take mouse events reliably in a
942
- -- plugin widget, and with no text and no background it is purely a target.
943
- local hit = Instance.new("TextButton")
944
- hit.AnchorPoint = Vector2.new(0.5, 1)
945
- hit.BackgroundTransparency = 1
946
- hit.Text = ""
947
- hit.AutoButtonColor = false
948
- hit.BorderSizePixel = 0
949
- hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
950
- hit.Parent = trace
951
-
952
- local entry: Bar = {
953
- frame = bar,
954
- hit = hit,
955
- milliseconds = milliseconds,
956
- ok = ok,
957
- title = if title ~= "" then title else "call",
958
- }
959
- table.insert(runtime.bars, entry)
960
-
961
- hit.MouseEnter:Connect(function()
962
- showReading(entry)
963
- end)
964
- hit.MouseLeave:Connect(function()
965
- clearReading()
966
- end)
967
-
968
- while #runtime.bars > TRACE do
969
- local oldest = runtime.bars[1]
970
- oldest.frame:Destroy()
971
- oldest.hit:Destroy()
972
- table.remove(runtime.bars, 1)
973
- end
974
-
975
- relayout()
976
-
977
- -- The bars have all moved, so a reading still on screen now names the wrong
978
- -- one. Cheaper and more honest to drop it than to work out which bar the
979
- -- pointer has ended up over.
980
- clearReading()
981
- end
982
-
983
- --[[
984
- The resting colour, which is the connection state.
985
- ]]
986
- function Visuals.setTint(color: Color3)
987
- runtime.baseTint = color
988
- end
989
-
990
- --[[
991
- The colour and pace of the work now running.
992
-
993
- Both are set from the same call because they describe the same thing: what
994
- kind of command this is. Reads are quick and cool, writes are slower and
995
- warm, so the strip's colour and its rate agree with each other instead of
996
- moving independently.
997
- ]]
998
- function Visuals.setKind(color: Color3, urgency: number)
999
- runtime.activeTint = color
1000
- runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
1001
- runtime.quiet = 0
1002
- --[[
1003
- Drawn in as the command goes out, so the pair reads as one gesture: the
1004
- cell contracts while the request is in flight and springs open when the
1005
- answer arrives. On a fast call the two are almost one motion, which is
1006
- itself the report -- a slow call visibly holds its breath.
1007
- ]]
1008
- runtime.scale = math.min(runtime.scale, 0.85)
1009
- runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
1010
- -- Re-aimed per kind so successive commands of different types visibly
1011
- -- change the axis rather than continuing the same turn.
1012
- runtime.spin = Vector3.new(
1013
- 0.12 + math.random() * 0.16,
1014
- 0.18 + math.random() * 0.22,
1015
- 0.06 + math.random() * 0.12
1016
- )
1017
- end
1018
-
1019
- --[[
1020
- What is running, in the same words the log uses. The motion says something is
1021
- happening; this says what, and neither answers the other's question.
1022
- ]]
1023
- function Visuals.setCaption(title: string)
1024
- runtime.lastTitle = title
1025
- -- A note is holding the caption's real text for later. Update what will be
1026
- -- restored, not what is on screen, or the note is wiped by the next command
1027
- -- and clearing it would put back a line that is already out of date.
1028
- if runtime.noted ~= nil then
1029
- runtime.noted = title
1030
- return
1031
- end
1032
- if runtime.caption then
1033
- runtime.caption.Text = title
1034
- end
1035
- end
1036
-
1037
- --[[
1038
- Borrows the caption to answer something the user is pointing at.
1039
-
1040
- The caption is the band's one line of prose, so a note goes there rather
1041
- than into a floating panel: there is exactly one place on this widget that
1042
- explains what you are looking at, and two would be one too many. Whatever
1043
- the caption was saying is put back by `clearNote`, so a note never costs the
1044
- user the line they were reading.
1045
- ]]
1046
- function Visuals.showNote(text: string)
1047
- if runtime.caption == nil or text == "" then
1048
- return
1049
- end
1050
- if runtime.noted == nil then
1051
- runtime.noted = runtime.caption.Text
1052
- end
1053
- runtime.caption.Text = text
1054
- end
1055
-
1056
- function Visuals.clearNote()
1057
- local held = runtime.noted
1058
- if held == nil or runtime.caption == nil then
1059
- return
1060
- end
1061
- runtime.noted = nil
1062
- runtime.caption.Text = held
1063
- end
1064
-
1065
- --[[
1066
- Falls back to the last thing that ran once a command finishes.
1067
-
1068
- The footer already reports totals, so repeating "idle" here would say the
1069
- same word twice on one screen. What has just happened is more useful and is
1070
- not shown anywhere else.
1071
- ]]
1072
- function Visuals.setIdle()
1073
- if runtime.caption == nil then
1074
- return
1075
- end
1076
- local text = if runtime.lastTitle ~= nil
1077
- then "last: " .. runtime.lastTitle
1078
- else "waiting for a command"
1079
- -- Same rule as setCaption: a note owns the line until it is cleared.
1080
- if runtime.noted ~= nil then
1081
- runtime.noted = text
1082
- return
1083
- end
1084
- runtime.caption.Text = text
1085
- end
1086
-
1087
- --[[
1088
- Winds the cell down, because the session has stopped rather than paused.
1089
-
1090
- The strip already decays on its own, but decay bottoms out at "idle and
1091
- moving", which looks the same after twenty seconds as after two hours. This
1092
- is the console telling the picture what it has just told the log, so the two
1093
- agree.
1094
- ]]
1095
- function Visuals.setQuiet()
1096
- runtime.quiet = QUIET_AFTER + 1
1097
- runtime.activity = 0
1098
- end
1099
-
1100
- --[[
1101
- Empties the trace.
1102
-
1103
- Paired with the console's clear button. The bars used to survive it while the
1104
- footer counters reset, so "clear" wiped the log, wiped the statistics, and
1105
- left forty timings from the session it had just erased sitting on screen.
1106
- ]]
1107
- function Visuals.clearTrace()
1108
- for _, entry in runtime.bars do
1109
- entry.frame:Destroy()
1110
- entry.hit:Destroy()
1111
- end
1112
- table.clear(runtime.bars)
1113
- clearReading()
1114
- end
1115
-
1116
- function Visuals.setVisible(visible: boolean)
1117
- local band = runtime.band
1118
- if band == nil then
1119
- return
1120
- end
1121
- band.Visible = visible
1122
-
1123
- -- Connected only while on screen: a hidden strip that keeps projecting
1124
- -- geometry every frame is a battery complaint waiting to happen.
1125
- if visible and runtime.connection == nil then
1126
- runtime.connection = RunService.Heartbeat:Connect(step)
1127
- elseif not visible and runtime.connection ~= nil then
1128
- runtime.connection:Disconnect()
1129
- runtime.connection = nil
1130
- end
1131
- end
1132
-
1133
- function Visuals.isVisible(): boolean
1134
- local band = runtime.band
1135
- return band ~= nil and band.Visible
1136
- end
1137
-
1138
- --[[
1139
- Swaps the cell's contents and recolours the strip's own chrome.
1140
-
1141
- The order matters. The outgoing preset is unmounted before the cell is
1142
- emptied, so a preset that keeps references cannot be handed destroyed
1143
- Instances; the cell is then cleared outright rather than trusted to be
1144
- clean, because a preset that errored midway through `mount` will have left
1145
- something behind.
1146
- ]]
1147
- function Visuals.applyTheme()
1148
- local cell = runtime.cell
1149
- local band = runtime.band
1150
- if cell == nil or band == nil then
1151
- return
1152
- end
1153
-
1154
- if runtime.mounted then
1155
- pcall(function()
1156
- Themes.active().unmount()
1157
- end)
1158
- end
1159
- for _, child in cell:GetChildren() do
1160
- child:Destroy()
1161
- end
1162
-
1163
- local palette = Themes.palette()
1164
- band.BackgroundColor3 = palette.surface
1165
- if runtime.divider then
1166
- (runtime.divider :: Frame).BackgroundColor3 = palette.dim
1167
- end
1168
- if runtime.baseline then
1169
- (runtime.baseline :: Frame).BackgroundColor3 = palette.dim
1170
- end
1171
- if runtime.highlight then
1172
- (runtime.highlight :: Frame).BackgroundColor3 = palette.text
1173
- end
1174
- if runtime.caption then
1175
- (runtime.caption :: TextLabel).TextColor3 = palette.text
1176
- end
1177
- if runtime.readout then
1178
- (runtime.readout :: TextLabel).BackgroundColor3 = palette.background
1179
- end
1180
-
1181
- local ok, err = pcall(function()
1182
- Themes.active().mount(cell)
1183
- end)
1184
- runtime.mounted = ok
1185
- if not ok then
1186
- warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
1187
- end
1188
-
1189
- relayout()
1190
- -- A preset that failed to paint disabled the band; a new one deserves the
1191
- -- chance to prove it works.
1192
- band.Visible = true
1193
- end
1194
-
1195
- --[[
1196
- Builds the strip. Everything except the cell's contents is created here and
1197
- never again, which is what keeps the per-frame path allocation-free.
1198
- ]]
1199
- function Visuals.mount(parent: Instance)
1200
- local palette = Themes.palette()
1201
-
1202
- local band = Instance.new("Frame")
1203
- band.Name = "ActivityBand"
1204
- band.BackgroundColor3 = palette.surface
1205
- band.BorderSizePixel = 0
1206
- band.Visible = false
1207
- band.ClipsDescendants = true
1208
- band.Parent = parent
1209
- runtime.band = band
1210
-
1211
- local cell = Instance.new("Frame")
1212
- cell.Name = "Cell"
1213
- cell.Position = UDim2.fromOffset(INSET, (Visuals.BAND_HEIGHT - CELL) / 2)
1214
- cell.Size = UDim2.fromOffset(CELL, CELL)
1215
- cell.BackgroundTransparency = 1
1216
- cell.ClipsDescendants = true
1217
- cell.Parent = band
1218
- runtime.cell = cell
1219
-
1220
- -- A hairline between the cell and the trace, so the strip reads as two
1221
- -- instruments rather than one busy rectangle. Centred in the gutter, which
1222
- -- is what keeps the extra margin as air on both sides rather than as a gap
1223
- -- on one.
1224
- local divider = Instance.new("Frame")
1225
- divider.Name = "Divider"
1226
- divider.Position = UDim2.fromOffset(INSET + CELL + GUTTER / 2, 10)
1227
- divider.Size = UDim2.new(0, 1, 1, -20)
1228
- divider.BackgroundColor3 = palette.dim
1229
- divider.BackgroundTransparency = 0.78
1230
- divider.BorderSizePixel = 0
1231
- divider.Parent = band
1232
- runtime.divider = divider
1233
-
1234
- local right = INSET + CELL + GUTTER
1235
-
1236
- local caption = Instance.new("TextLabel")
1237
- caption.Name = "Caption"
1238
- caption.Position = UDim2.new(0, right, 0, 6)
1239
- caption.Size = UDim2.new(1, -right - 12, 0, 14)
1240
- caption.BackgroundTransparency = 1
1241
- caption.Font = Enum.Font.Code
1242
- caption.TextSize = 11
1243
- caption.TextColor3 = palette.text
1244
- caption.TextXAlignment = Enum.TextXAlignment.Left
1245
- caption.TextTruncate = Enum.TextTruncate.AtEnd
1246
- caption.Text = "idle"
1247
- caption.Parent = band
1248
- runtime.caption = caption
1249
-
1250
- local trace = Instance.new("Frame")
1251
- trace.Name = "Trace"
1252
- trace.Position = UDim2.new(0, right, 0, 24)
1253
- trace.Size = UDim2.new(1, -right - 12, 0, TRACE_HEIGHT)
1254
- trace.BackgroundTransparency = 1
1255
- trace.ClipsDescendants = true
1256
- trace.Parent = band
1257
- runtime.trace = trace
1258
-
1259
- -- A baseline under the bars, so an empty trace still reads as an instrument
1260
- -- waiting for data rather than as a blank gap.
1261
- local baseline = Instance.new("Frame")
1262
- baseline.Name = "Baseline"
1263
- baseline.AnchorPoint = Vector2.new(0, 1)
1264
- baseline.Position = UDim2.fromScale(0, 1)
1265
- baseline.Size = UDim2.new(1, 0, 0, 1)
1266
- baseline.BackgroundColor3 = palette.dim
1267
- baseline.BackgroundTransparency = 0.75
1268
- baseline.BorderSizePixel = 0
1269
- baseline.Parent = trace
1270
- runtime.baseline = baseline
1271
-
1272
- --[[
1273
- Built once and moved, and created BEFORE the bars exist so it sits under
1274
- them in draw order -- a highlight drawn over a one-pixel bar would hide the
1275
- very thing it is pointing at.
1276
- ]]
1277
- local highlight = Instance.new("Frame")
1278
- highlight.Name = "Highlight"
1279
- highlight.AnchorPoint = Vector2.new(0.5, 1)
1280
- highlight.Position = UDim2.new(1, 0, 1, 0)
1281
- highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
1282
- highlight.BackgroundColor3 = palette.text
1283
- -- Faint enough not to hide the bar it sits behind, solid enough to be seen
1284
- -- at all.
1285
- highlight.BackgroundTransparency = 0.7
1286
- highlight.BorderSizePixel = 0
1287
- highlight.Visible = false
1288
- highlight.Parent = trace
1289
- runtime.highlight = highlight
1290
-
1291
- --[[
1292
- The reading sits at the left end of the trace rather than beside the bar it
1293
- describes. Following the pointer would put it off the right edge for the
1294
- newest bars, which are the ones most often asked about, and the left end is
1295
- empty until forty calls have accumulated.
1296
- ]]
1297
- local readout = Instance.new("TextLabel")
1298
- readout.Name = "Readout"
1299
- readout.AnchorPoint = Vector2.new(0, 0.5)
1300
- readout.Position = UDim2.new(0, 0, 0.5, 0)
1301
- -- Sized by its text now that it carries a name as well as a number. A fixed
1302
- -- 110px was enough for "18 ms" and truncates anything with a phrase in front
1303
- -- of it, which is the half worth reading.
1304
- readout.AutomaticSize = Enum.AutomaticSize.X
1305
- readout.Size = UDim2.new(0, 0, 0, 14)
1306
- readout.BackgroundColor3 = palette.background
1307
- readout.BackgroundTransparency = 0.15
1308
- readout.BorderSizePixel = 0
1309
- readout.Font = Enum.Font.Code
1310
- readout.TextSize = 11
1311
- readout.TextXAlignment = Enum.TextXAlignment.Left
1312
- readout.Text = ""
1313
- readout.Visible = false
1314
- readout.ZIndex = 3
1315
- readout.Parent = trace
1316
- runtime.readout = readout
1317
-
1318
- local readoutPadding = Instance.new("UIPadding")
1319
- readoutPadding.PaddingLeft = UDim.new(0, 4)
1320
- readoutPadding.PaddingRight = UDim.new(0, 4)
1321
- readoutPadding.Parent = readout
1322
-
1323
- local ok, err = pcall(function()
1324
- Themes.active().mount(cell)
1325
- end)
1326
- runtime.mounted = ok
1327
- if not ok then
1328
- warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
1329
- end
1330
- end
1331
-
1332
- 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
+ --[[
236
+ What the caption said before a note took it over.
237
+
238
+ Nil means no note is showing, which is why this is the flag as well as
239
+ the storage: a second `showNote` while one is up must not overwrite the
240
+ text being held, or clearing would restore a note instead of the
241
+ caption.
242
+ ]]
243
+ noted: string?,
244
+ bars: { Bar },
245
+ connection: RBXScriptConnection?,
246
+
247
+ spin: Vector3,
248
+ angle: Vector3,
249
+
250
+ energy: number,
251
+ shake: number,
252
+ --[[
253
+ The cell's size, as a spring rather than a value.
254
+
255
+ Scale was previously a small function of energy -- a 24% range that
256
+ nobody could see. Driving it as a spring means each event can push it and
257
+ let physics do the rest: a dispatch pulls it in, a reply throws it out
258
+ past its resting size, and it settles on its own.
259
+ ]]
260
+ scale: number,
261
+ scaleVelocity: number,
262
+ -- Spikes to 1 the instant a reply lands, gone in a third of a second.
263
+ flare: number,
264
+ -- Seconds since anything last ran. Drives the wind-down.
265
+ quiet: number,
266
+ tint: Color3,
267
+ targetTint: Color3,
268
+ baseTint: Color3,
269
+ activeTint: Color3,
270
+ activity: number,
271
+ lastTitle: string?,
272
+ -- Whether the active preset has been given its Instances yet.
273
+ mounted: boolean,
274
+
275
+ -- The wave's columns, pooled at mount and hidden until a connect needs them.
276
+ wave: { Frame },
277
+ -- What the transport last said: true while it is trying to reach the bridge.
278
+ waveWanted: boolean,
279
+ -- The ramp, 0 to 1. Separate from `waveWanted` so the wave fades rather than
280
+ -- appearing and vanishing with the status line.
281
+ waveLevel: number,
282
+ -- Seconds the wave is held up regardless of `waveWanted`. See WAVE_HOLD.
283
+ waveHold: number,
284
+ -- Whether the columns are currently on screen. Kept rather than derived, so
285
+ -- a settled wave hides them once instead of every frame afterwards.
286
+ waveShown: boolean,
287
+ -- The arrival flourish: 1 the instant a session connects, down to 0 over
288
+ -- CHEER_TIME. Overrides the connecting wave's shape while it runs.
289
+ cheer: number,
290
+
291
+ --[[
292
+ True while an agent the panel started is working.
293
+
294
+ Everything else here is driven by events: a tool call arrives, the cell
295
+ is struck, and the strike decays. That is the right model for an agent
296
+ being driven from a terminal, where the gaps between calls are the user
297
+ reading. It is the wrong one for an agent this panel started, because the
298
+ longest gaps are the model thinking -- which is the part that takes the
299
+ time, produces no call, and left the strip winding down to "stopped" in
300
+ the middle of the work it was supposed to be reporting.
301
+
302
+ So this is the one piece of state that is a CONDITION rather than an
303
+ event, and the only thing it does is hold a floor under the decay. A real
304
+ call still spikes above it and still falls back; it just falls back to
305
+ "working" instead of to "nothing is happening".
306
+ ]]
307
+ thinking: boolean,
308
+ }
309
+
310
+ local runtime: Runtime = {
311
+ band = nil,
312
+ cell = nil,
313
+ trace = nil,
314
+ divider = nil,
315
+ baseline = nil,
316
+ highlight = nil,
317
+ readout = nil,
318
+ caption = nil,
319
+ noted = nil,
320
+ bars = {},
321
+ connection = nil,
322
+ spin = Vector3.new(0.18, 0.27, 0.11),
323
+ angle = Vector3.zero,
324
+ energy = 0,
325
+ shake = 0,
326
+ scale = 1,
327
+ scaleVelocity = 0,
328
+ flare = 0,
329
+ quiet = 0,
330
+ tint = Color3.fromRGB(167, 139, 250),
331
+ targetTint = Color3.fromRGB(167, 139, 250),
332
+ baseTint = Color3.fromRGB(167, 139, 250),
333
+ activeTint = Color3.fromRGB(167, 139, 250),
334
+ activity = 0,
335
+ lastTitle = nil,
336
+ mounted = false,
337
+ wave = {},
338
+ waveWanted = false,
339
+ waveLevel = 0,
340
+ waveHold = 0,
341
+ waveShown = false,
342
+ cheer = 0,
343
+ thinking = false,
344
+ }
345
+
346
+ --[[
347
+ The simulation, packaged for whichever preset is drawing it.
348
+
349
+ Rebuilt every frame rather than mutated in place. A preset holding onto the
350
+ table between frames would see values change under it, and the one rule that
351
+ keeps eight renderers honest is that they read this and draw -- they do not
352
+ own any of it.
353
+ ]]
354
+ local function context(): Themes.Context
355
+ return {
356
+ palette = Themes.palette(),
357
+ tint = runtime.tint,
358
+ energy = runtime.energy,
359
+ activity = runtime.activity,
360
+ flare = runtime.flare,
361
+ shake = runtime.shake,
362
+ scale = runtime.scale,
363
+ quiet = runtime.quiet,
364
+ angle = runtime.angle,
365
+ clock = os.clock(),
366
+ size = CELL,
367
+ centre = Vector2.new(CELL / 2, CELL / 2),
368
+ }
369
+ end
370
+
371
+ -- Declared here and defined below, because it draws through the same slot
372
+ -- painter history does and that helper is defined after this function.
373
+ local stepWave: (delta: number) -> ()
374
+
375
+ local function step(delta: number)
376
+ local band = runtime.band
377
+ local cell = runtime.cell
378
+ if band == nil or cell == nil or not band.Visible then
379
+ return
380
+ end
381
+
382
+ runtime.energy = math.max(0, runtime.energy - delta * 0.9)
383
+ runtime.shake = math.max(0, runtime.shake - delta * 2.4)
384
+ --[[
385
+ Slower than it was. The cell is meant to climb as the session works, and
386
+ at the old rates a call had to arrive every three seconds just to hold the
387
+ middle of the range -- so the top of it was, in practice, unreachable. A
388
+ burst of four calls now gets there, and it unwinds over about ten seconds
389
+ afterwards.
390
+ ]]
391
+ runtime.activity = math.max(0, runtime.activity - delta * 0.28)
392
+ runtime.flare = math.max(0, runtime.flare - delta * 3)
393
+ runtime.quiet += delta
394
+
395
+ --[[
396
+ The floor an agent's thinking holds under all of that.
397
+
398
+ A pulse rather than a constant, and the difference is the whole point: a
399
+ fixed floor produces a cell frozen mid-swell, which reads as a hung
400
+ panel rather than a working one. The sine is slow -- about a three second
401
+ period -- so it is unmistakably a breath and never competes with the
402
+ sharp strike of a real call landing on top of it.
403
+
404
+ Applied after the decay and before anything reads these values, so the
405
+ ordinary event path above is untouched: it still decays exactly as it
406
+ did, it simply cannot fall below this while an agent is running. `quiet`
407
+ is pinned too, or the wind-down at QUIET_AFTER would draw the cell in
408
+ and slow its spin during the longest thinking pauses -- the precise
409
+ moment this exists to cover.
410
+ ]]
411
+ if runtime.thinking then
412
+ local pulse = 0.34 + math.sin(os.clock() * 2.1) * 0.13
413
+ runtime.energy = math.max(runtime.energy, pulse)
414
+ runtime.activity = math.max(runtime.activity, 0.4)
415
+ runtime.quiet = 0
416
+ end
417
+ --[[
418
+ Two colours, blended by how recently something happened.
419
+
420
+ `baseTint` is the connection state, which is what the panel should read
421
+ as when nothing is going on. `activeTint` is the kind of work last done
422
+ -- reading, writing, running, debugging -- and it takes over while that
423
+ work is fresh, then fades back. So the strip is not one fixed colour: it
424
+ leans toward whatever the session is actually doing and returns to
425
+ resting on its own.
426
+ ]]
427
+ local blend = math.clamp(runtime.energy, 0, 1)
428
+ runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
429
+ runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
430
+
431
+ -- Turning faster while busy makes the rate itself readable: a glance says
432
+ -- whether anything is happening without reading a word.
433
+ --
434
+ -- And nearly stopping once the session has gone quiet, for the same reason in
435
+ -- reverse: a strip that keeps moving at its working rate hours after the
436
+ -- last command claims an activity that is not happening.
437
+ local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
438
+ runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
439
+
440
+ --[[
441
+ Where the cell wants to be, before the spring gets a say.
442
+
443
+ Three things move it and they are deliberately ordered. Energy -- how
444
+ recently work happened -- swells it while a session is busy. The breathe
445
+ is a slow sine that only survives while energy is near zero, so an idle
446
+ panel is visibly alive without a resting animation competing with the
447
+ reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
448
+ in and stays there: the picture of a session that has stopped.
449
+ ]]
450
+ local calm = 1 - math.clamp(runtime.energy, 0, 1)
451
+ local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
452
+ local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
453
+ if runtime.quiet > QUIET_AFTER then
454
+ target = 0.82
455
+ end
456
+
457
+ --[[
458
+ A damped spring rather than a lerp, because a lerp cannot overshoot and
459
+ overshoot is the whole point: a reply that pushes the cell past its
460
+ resting size and lets it fall back reads as a thing being struck, where
461
+ easing toward a value reads as a slider being dragged.
462
+
463
+ Integrated semi-implicitly -- velocity first, then position from the new
464
+ velocity -- which stays stable at the frame times Studio actually hands us
465
+ rather than only at small ones.
466
+ ]]
467
+ --[[
468
+ Substepped rather than truncated.
469
+
470
+ `math.min(delta, 1/30)` kept the integration stable, but it also THREW
471
+ AWAY the rest of a long frame: at 20fps a third of every frame's motion
472
+ simply did not happen, so the spring ran slower in wall-clock time
473
+ exactly when Studio was busiest -- which is during a tool call, which is
474
+ when the spring is reacting to something. Stepping the leftover instead
475
+ keeps the reaction the same length whatever the frame rate, and the cap
476
+ on the number of steps stops a two-second hitch from paying it all back
477
+ in one frame.
478
+ ]]
479
+ local remaining = math.min(delta, 0.25)
480
+ for _ = 1, 8 do
481
+ if remaining <= 0 then
482
+ break
483
+ end
484
+ local integration = math.min(remaining, 1 / 60)
485
+ remaining -= integration
486
+
487
+ runtime.scaleVelocity += (target - runtime.scale) * SPRING * integration
488
+ runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * integration
489
+ runtime.scale += runtime.scaleVelocity * integration
490
+
491
+ --[[
492
+ Clamping the position without killing the velocity is what made a
493
+ reply read as a jolt rather than a bounce. The reply impulse is
494
+ `scaleVelocity += 9`, which crosses the whole 0.55..1.7 range in
495
+ about two frames, so the scale pinned itself against the ceiling
496
+ while still carrying all that speed -- and then sat there, motionless
497
+ but not at rest, until the spring had spent the stored velocity. The
498
+ eye reads that as a snap, a stall, and a second snap.
499
+
500
+ Zeroing the component pushing into the rail turns it into a stop.
501
+ ]]
502
+ if runtime.scale <= 0.55 then
503
+ runtime.scale = 0.55
504
+ runtime.scaleVelocity = math.max(runtime.scaleVelocity, 0)
505
+ elseif runtime.scale >= 1.7 then
506
+ runtime.scale = 1.7
507
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
508
+ end
509
+ end
510
+
511
+ -- Stepped before the cell is handed off, so a preset that errors and
512
+ -- disables the strip does not also take the connect animation with it.
513
+ stepWave(delta)
514
+
515
+ --[[
516
+ Handed off. A preset that throws must not take the console's status
517
+ display down with it, so a failure here disables the strip and says so
518
+ once rather than erroring sixty times a second.
519
+ ]]
520
+ local ok, err = pcall(Themes.active().paint, context(), delta)
521
+ if not ok then
522
+ band.Visible = false
523
+ warn(string.format("[rbx-studio] theme %q failed to paint: %s", Themes.activeId(), tostring(err)))
524
+ end
525
+ end
526
+
527
+ --[[
528
+ Puts the highlight behind one bar and prints its timing.
529
+
530
+ The trace is only legible if you already know what it plots, and nothing on
531
+ screen said so. Hovering answers it directly: the column lights up, and the
532
+ number that made that bar its height appears in words. One reading at a time,
533
+ so a single highlight and a single label are moved around rather than one of
534
+ each being built per bar.
535
+ ]]
536
+ local function showReading(entry: Bar)
537
+ local highlight = runtime.highlight
538
+ local readout = runtime.readout
539
+ if highlight == nil or readout == nil then
540
+ return
541
+ end
542
+
543
+ --[[
544
+ The Y here has to be 1, and was 0.
545
+
546
+ The highlight is anchored to its own bottom edge, so a Y scale of 0 put
547
+ that edge on the trace's top line and drew the whole 26px column above it
548
+ -- outside a frame that clips its descendants. The hover was therefore
549
+ never visible to anyone: the hit targets fired, the readout appeared, and
550
+ the highlight they were meant to explain was off screen every time.
551
+ ]]
552
+ highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
553
+ highlight.Visible = true
554
+
555
+ --[[
556
+ Named, not just timed.
557
+
558
+ A duration with nothing attached to it raises the question it cannot
559
+ answer -- forty bars, one of them tall, and no way to tell whether that
560
+ was a script edit or a screenshot. The title is the same phrase the log
561
+ row uses, so pointing at a bar and reading the log agree with each other.
562
+ ]]
563
+ local palette = Themes.palette()
564
+ readout.Text = if entry.ok
565
+ then string.format("%s: %d ms", entry.title, math.round(entry.milliseconds))
566
+ else string.format("%s: failed after %d ms", entry.title, math.round(entry.milliseconds))
567
+ readout.TextColor3 = if entry.ok then palette.text else palette.red
568
+ readout.Visible = true
569
+ end
570
+
571
+ local function clearReading()
572
+ if runtime.highlight then
573
+ runtime.highlight.Visible = false
574
+ end
575
+ if runtime.readout then
576
+ runtime.readout.Visible = false
577
+ end
578
+ end
579
+
580
+ --[[
581
+ Bars, for a preset that does not want to draw its own history.
582
+
583
+ Height is duration and colour is outcome, so a slow call and a failed one are
584
+ distinguishable at a glance -- which is the pair of questions that actually
585
+ get asked of a log this size.
586
+ ]]
587
+ local function defaultSlot(slot: Themes.Slot, ctx: Themes.Context)
588
+ local frame = slot.frame
589
+ frame.AnchorPoint = Vector2.new(0.5, 1)
590
+ frame.Size = UDim2.new(0, 3, math.max(slot.weight, 0.06), 0)
591
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
592
+ frame.BackgroundColor3 = if slot.ok then ctx.palette.violet else ctx.palette.red
593
+ frame.BackgroundTransparency = 0.15 + slot.age * 0.6
594
+ frame.Rotation = 0
595
+ end
596
+
597
+ --[[
598
+ Whether the transport is currently reaching for the bridge.
599
+
600
+ Told rather than inferred, because "connecting" is the transport's word and
601
+ the trace must never disagree with the status dot about what the session is
602
+ doing.
603
+ ]]
604
+ function Visuals.setConnecting(active: boolean)
605
+ if active and not runtime.waveWanted then
606
+ runtime.waveHold = WAVE_HOLD
607
+ end
608
+ runtime.waveWanted = active
609
+ end
610
+
611
+ --[[
612
+ Plays the arrival flourish, once.
613
+
614
+ Edge-triggered by the caller rather than latched here: only the status line
615
+ knows whether this is a session actually landing or the same `connected`
616
+ state being repeated after a theme switch, and a celebration that replays
617
+ itself every time the port is re-read stops meaning anything.
618
+ ]]
619
+ function Visuals.celebrate()
620
+ runtime.cheer = 1
621
+ -- The search is over. Nothing should be left holding the connecting wave up
622
+ -- underneath a flourish that has replaced it.
623
+ runtime.waveWanted = false
624
+ runtime.waveHold = 0
625
+ end
626
+
627
+ --[[
628
+ Re-places every bar and hands each to the active preset to draw.
629
+
630
+ Laid out right to left so the newest bar is always at the same edge and the
631
+ trace reads as scrolling rather than reshuffling. The horizontal position is
632
+ set here and the preset is expected to keep it -- everything else about the
633
+ bar is the preset's to decide.
634
+ ]]
635
+ --[[
636
+ Where slot `number` sits, counting 1 at the right edge and rising leftwards.
637
+
638
+ The one place the trace's spacing is decided. Both the history and the wave
639
+ call it, which is what guarantees they land on the same grid rather than on
640
+ two that merely look similar.
641
+ ]]
642
+ local function slotOffset(number: number): number
643
+ return -(number - 0.5) * BAR_STRIDE
644
+ end
645
+
646
+ --[[
647
+ Re-places every bar and hands each to the active preset to draw.
648
+
649
+ Laid out right to left so the newest bar is always at the same edge and the
650
+ trace reads as scrolling rather than reshuffling. The horizontal position is
651
+ set here and the preset is expected to keep it -- everything else about the
652
+ bar is the preset's to decide.
653
+
654
+ `modulate` is the wave, when one is running. It is handed a bar's slot
655
+ number and its true height and freshness, and returns what to draw instead,
656
+ so the history is lifted by the same swell that lifts the empty slots beside
657
+ it. Absent, every bar is drawn from its own recorded timing, which is what
658
+ restores the true trace the moment a wave ends.
659
+ ]]
660
+ local function relayout(modulate: ((number, number, number) -> (number, number))?)
661
+ local ctx = context()
662
+ local painter = Themes.active().paintSlot or defaultSlot
663
+ local total = #runtime.bars
664
+
665
+ for index, item in runtime.bars do
666
+ local fromRight = total - index
667
+ local number = fromRight + 1
668
+ local offset = slotOffset(number)
669
+
670
+ item.frame.Position = UDim2.new(1, offset, 1, 0)
671
+ item.hit.Position = UDim2.new(1, offset, 1, 0)
672
+
673
+ -- 0 for the newest, rising toward 1 for the oldest still shown.
674
+ local age = if TRACE > 1 then fromRight / (TRACE - 1) else 0
675
+ local weight = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1)
676
+ if modulate ~= nil then
677
+ weight, age = modulate(number, weight, age)
678
+ end
679
+
680
+ local slot: Themes.Slot = {
681
+ frame = item.frame,
682
+ milliseconds = item.milliseconds,
683
+ ok = item.ok,
684
+ title = item.title,
685
+ age = age,
686
+ weight = weight,
687
+ }
688
+ local ok, err = pcall(painter, slot, ctx)
689
+ if not ok then
690
+ -- One bad slot must not leave the other thirty-nine unplaced.
691
+ defaultSlot(slot, ctx)
692
+ --[[
693
+ Silent under a wave. This runs sixty times a second while one is
694
+ playing, and a preset that throws would fill the log faster than
695
+ anyone could read it -- the unmodulated path below still says so
696
+ once the next call lands.
697
+ ]]
698
+ if modulate == nil then
699
+ warn(string.format("[rbx-studio] theme %q failed on a slot: %s", Themes.activeId(), tostring(err)))
700
+ end
701
+ end
702
+ end
703
+ end
704
+
705
+ --[[
706
+ How many slots the trace has room for, at the shared stride.
707
+
708
+ Read from the live width rather than fixed, because the dock is resizable and
709
+ a wave that stops halfway across a widened panel is worse than no wave.
710
+ ]]
711
+ local function slotsAcross(): number
712
+ local trace = runtime.trace
713
+ if trace == nil then
714
+ return 0
715
+ end
716
+ return math.clamp(math.floor(trace.AbsoluteSize.X / BAR_STRIDE), 0, WAVE_MAX)
717
+ end
718
+
719
+ --[[
720
+ Makes sure the wave has a column for every slot up to `count`.
721
+
722
+ Grown on demand and never shrunk. The pool only ever reaches the width the
723
+ panel actually had, it is built the first time a connection is attempted
724
+ rather than at mount, and a column costs nothing while it is hidden.
725
+ ]]
726
+ local function growWave(count: number)
727
+ local trace = runtime.trace
728
+ if trace == nil then
729
+ return
730
+ end
731
+ for _ = #runtime.wave + 1, count do
732
+ local column = Instance.new("Frame")
733
+ column.Name = "Wave"
734
+ column.AnchorPoint = Vector2.new(0.5, 1)
735
+ column.Size = UDim2.fromOffset(2, 1)
736
+ column.BackgroundColor3 = Themes.palette().violet
737
+ column.BorderSizePixel = 0
738
+ column.Visible = false
739
+ -- Under the bars and the hover highlight. They never share a slot, but
740
+ -- depth should not be the thing standing between them if that changes.
741
+ column.ZIndex = 1
742
+ column.Parent = trace
743
+
744
+ -- Presets draw points as well as bars, same as the history slots.
745
+ local round = Instance.new("UICorner")
746
+ round.CornerRadius = UDim.new(0, 1)
747
+ round.Parent = column
748
+
749
+ table.insert(runtime.wave, column)
750
+ end
751
+ end
752
+
753
+ --[[
754
+ Runs the connecting wave for one frame.
755
+
756
+ Cheap when nothing is connecting: the ramp settles at zero, the columns are
757
+ hidden once, the true trace is restored once, and every later frame leaves
758
+ after two comparisons.
759
+ ]]
760
+ function stepWave(delta: number)
761
+ runtime.cheer = math.max(0, runtime.cheer - delta / CHEER_TIME)
762
+
763
+ runtime.waveHold = math.max(0, runtime.waveHold - delta)
764
+ local target = if runtime.waveWanted or runtime.waveHold > 0 then 1 else 0
765
+ local rate = if target > runtime.waveLevel then WAVE_IN else WAVE_OUT
766
+ runtime.waveLevel += (target - runtime.waveLevel) * math.min(1, delta * rate)
767
+
768
+ --[[
769
+ Two sources, one opacity.
770
+
771
+ They overlap for a moment at every connection -- the status goes green,
772
+ which drops `waveWanted`, while the flourish is only just starting -- and
773
+ taking the louder of the two is what carries the handover without a dip
774
+ between the search ending and the arrival beginning.
775
+ ]]
776
+ local celebration = math.min(1, runtime.cheer * CHEER_FADE)
777
+ local strength = math.max(runtime.waveLevel, celebration)
778
+
779
+ if strength <= 0.01 then
780
+ if runtime.waveShown then
781
+ runtime.waveShown = false
782
+ runtime.waveLevel = 0
783
+ for _, column in runtime.wave do
784
+ column.Visible = false
785
+ end
786
+ -- The bars were being drawn lifted. Put the real timings back, once,
787
+ -- rather than leaving the history frozen mid-swell.
788
+ relayout()
789
+ end
790
+ return
791
+ end
792
+ runtime.waveShown = true
793
+
794
+ local slots = slotsAcross()
795
+ if slots == 0 then
796
+ return
797
+ end
798
+ growWave(slots)
799
+
800
+ local ctx = context()
801
+ local painter = Themes.active().paintSlot or defaultSlot
802
+ local phase = ctx.clock / WAVE_PERIOD
803
+ local crest = phase % 1
804
+ local ripple = slots / WAVE_RIPPLE_STRIDE
805
+ -- The flourish runs on its own progress rather than on the clock, so it is
806
+ -- the same length however long the connection took to land.
807
+ local arriving = runtime.cheer > 0
808
+ local progress = 1 - runtime.cheer
809
+
810
+ --[[
811
+ The wave itself, as a pure function of where a slot is.
812
+
813
+ Taking a slot number rather than a column is what lets the history use it
814
+ too: a bar at slot 6 and an empty column at slot 7 are handed the same
815
+ swell, so the surface is continuous across the join instead of stopping
816
+ where the data starts.
817
+ ]]
818
+ local function shape(number: number): (number, number)
819
+ -- 0 at the left edge of the trace, 1 at the right.
820
+ local x = 1 - (number - 0.5) / slots
821
+
822
+ if arriving then
823
+ --[[
824
+ Struck, not swept. `since` is how long ago the front passed this
825
+ slot: negative means it has not arrived, and the column waits
826
+ flat and dark rather than anticipating it.
827
+ ]]
828
+ local since = progress - x * CHEER_SWEEP
829
+ if since < 0 then
830
+ return WAVE_FLOOR, 1
831
+ end
832
+ -- One envelope for both height and brightness, so a column is at its
833
+ -- tallest exactly when it is at its brightest.
834
+ local settle = math.exp(-since * CHEER_DECAY)
835
+ local bounce = 0.5 + 0.5 * math.cos(since * CHEER_RING * math.pi * 2)
836
+ return math.clamp(WAVE_FLOOR + settle * bounce * 0.95, WAVE_FLOOR, 1),
837
+ math.clamp(1 - settle, 0, 1)
838
+ end
839
+
840
+ --[[
841
+ Distance to the crest, wrapped around the ends, so the swell leaves
842
+ the right edge and re-enters at the left instead of jumping back
843
+ across a trace it has just crossed.
844
+ ]]
845
+ local distance = math.abs(x - crest)
846
+ distance = math.min(distance, 1 - distance)
847
+ local swell = math.exp(-(distance * distance) / (2 * WAVE_SPREAD * WAVE_SPREAD))
848
+ local texture = 0.5 + 0.5 * math.sin((x * ripple - phase * 2) * math.pi * 2)
849
+
850
+ --[[
851
+ A floor everywhere and a hump at the crest. Away from the swell the
852
+ columns sit just off the baseline, which keeps the trace reading as
853
+ an instrument at rest rather than as a screenful of invented data.
854
+ ]]
855
+ return math.clamp(WAVE_FLOOR + swell * (0.55 + 0.45 * texture) * 0.9, WAVE_FLOOR, 1),
856
+ -- Freshness follows the swell rather than position. Every preset
857
+ -- fades a slot by how recent it is, so handing the crest an age of
858
+ -- zero lights it and leaves the rest of the trace resting.
859
+ math.clamp(1 - swell, 0, 1)
860
+ end
861
+
862
+ --[[
863
+ The history, riding the same swell.
864
+
865
+ Lifted against its own headroom and brightened to whichever of the two is
866
+ fresher, so a bar keeps its shape and its colour and simply moves with
867
+ the wave rolling under it.
868
+ ]]
869
+ local barCount = #runtime.bars
870
+ if barCount > 0 then
871
+ relayout(function(number: number, weight: number, age: number): (number, number)
872
+ local lift, lightness = shape(number)
873
+ --[[
874
+ Both channels scale with the ramp, which is what makes the wave
875
+ let go of the history rather than drop it. At full strength a bar
876
+ is lifted and lit by the swell; as the ramp falls it slides back
877
+ onto its own timing and its own age, and the last frame of the
878
+ wave and the first frame without one are the same picture.
879
+ ]]
880
+ local raised = weight + (lift - WAVE_FLOOR) * (1 - weight) * WAVE_LIFT * strength
881
+ local lit = age + (math.min(age, lightness) - age) * strength
882
+ return math.clamp(raised, weight, 1), lit
883
+ end)
884
+ end
885
+
886
+ for number, column in runtime.wave do
887
+ if number > slots or number <= barCount then
888
+ --[[
889
+ Past the panel's edge, or a slot the history owns. Either way the
890
+ wave has no business drawing here -- this is what keeps the two
891
+ from ever landing on the same five pixels.
892
+ ]]
893
+ column.Visible = false
894
+ continue
895
+ end
896
+
897
+ local weight, age = shape(number)
898
+ local slot: Themes.Slot = {
899
+ frame = column,
900
+ -- Kept agreeing with the height, since that is the pair every
901
+ -- painter is written against, even though none of them reads it.
902
+ milliseconds = weight * SLOW_MS,
903
+ ok = true,
904
+ title = if arriving then "connected" else "connecting",
905
+ age = age,
906
+ weight = weight,
907
+ }
908
+
909
+ -- Set before the painter runs, off the same helper the bars use: every
910
+ -- painter preserves the X it is given and decides the rest.
911
+ column.Position = UDim2.new(1, slotOffset(number), 1, 0)
912
+ if not pcall(painter, slot, ctx) then
913
+ -- Silent, for the same reason the modulated history path is: a
914
+ -- preset that throws here would throw sixty times a second.
915
+ defaultSlot(slot, ctx)
916
+ end
917
+
918
+ --[[
919
+ Faded as one thing, after whoever drew it. The ramp belongs to the
920
+ wave and not to any preset, and applying it to the transparency the
921
+ painter chose keeps each theme's own weighting intact.
922
+ ]]
923
+ column.BackgroundTransparency = 1 - (1 - column.BackgroundTransparency) * strength
924
+ column.Visible = true
925
+ end
926
+ end
927
+
928
+ --[[
929
+ Records one finished call and re-draws the trace.
930
+ ]]
931
+ function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
932
+ runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
933
+ runtime.activity = math.min(1, runtime.activity + 0.34)
934
+ runtime.quiet = 0
935
+ runtime.flare = 1
936
+
937
+ --[[
938
+ A shove rather than a new target.
939
+
940
+ Setting the scale outright would snap; giving the spring velocity lets it
941
+ carry past its resting size and fall back, which is the difference between
942
+ a value changing and something being struck. A failure shoves the other
943
+ way and knocks the axis with it.
944
+ ]]
945
+ if ok then
946
+ --[[
947
+ Sized to peak just under the clamp rather than against it.
948
+
949
+ At +9 the shove carried the scale into the 1.7 ceiling within about a
950
+ tenth of a second, so what should have been an arc became a rise, a
951
+ flat spot, and a drop -- and a burst of calls, each adding another 9
952
+ to a velocity that was already railed, held it pinned there for as
953
+ long as the burst lasted. +6 tops out around 1.55, which reads as the
954
+ same strike and stays on the curve. The accumulated case is capped
955
+ for the same reason: several calls at once should look busy, not
956
+ stuck.
957
+ ]]
958
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity + 6, 8)
959
+ else
960
+ runtime.scale = 0.8
961
+ runtime.scaleVelocity = -2
962
+ runtime.shake = 1
963
+ end
964
+
965
+ local trace = runtime.trace
966
+ if trace == nil then
967
+ return
968
+ end
969
+
970
+ local bar = Instance.new("Frame")
971
+ bar.AnchorPoint = Vector2.new(0.5, 1)
972
+ bar.BorderSizePixel = 0
973
+ -- Above the wave's columns and the hover highlight. A plugin widget draws
974
+ -- with ZIndexBehavior.Global, so depth is stated rather than inherited from
975
+ -- the order things happened to be built in.
976
+ bar.ZIndex = 2
977
+ bar.Parent = trace
978
+ -- Presets draw points as well as bars, and a square point is not a point.
979
+ local corner = Instance.new("UICorner")
980
+ corner.CornerRadius = UDim.new(0, 1)
981
+ corner.Parent = bar
982
+
983
+ -- A TextButton rather than a Frame: buttons take mouse events reliably in a
984
+ -- plugin widget, and with no text and no background it is purely a target.
985
+ local hit = Instance.new("TextButton")
986
+ hit.AnchorPoint = Vector2.new(0.5, 1)
987
+ hit.BackgroundTransparency = 1
988
+ hit.Text = ""
989
+ hit.AutoButtonColor = false
990
+ hit.BorderSizePixel = 0
991
+ hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
992
+ hit.Parent = trace
993
+
994
+ local entry: Bar = {
995
+ frame = bar,
996
+ hit = hit,
997
+ milliseconds = milliseconds,
998
+ ok = ok,
999
+ title = if title ~= "" then title else "call",
1000
+ }
1001
+ table.insert(runtime.bars, entry)
1002
+
1003
+ hit.MouseEnter:Connect(function()
1004
+ showReading(entry)
1005
+ end)
1006
+ hit.MouseLeave:Connect(function()
1007
+ clearReading()
1008
+ end)
1009
+
1010
+ while #runtime.bars > TRACE do
1011
+ local oldest = runtime.bars[1]
1012
+ oldest.frame:Destroy()
1013
+ oldest.hit:Destroy()
1014
+ table.remove(runtime.bars, 1)
1015
+ end
1016
+
1017
+ relayout()
1018
+
1019
+ -- The bars have all moved, so a reading still on screen now names the wrong
1020
+ -- one. Cheaper and more honest to drop it than to work out which bar the
1021
+ -- pointer has ended up over.
1022
+ clearReading()
1023
+ end
1024
+
1025
+ --[[
1026
+ The resting colour, which is the connection state.
1027
+ ]]
1028
+ function Visuals.setTint(color: Color3)
1029
+ runtime.baseTint = color
1030
+ end
1031
+
1032
+ --[[
1033
+ The colour and pace of the work now running.
1034
+
1035
+ Both are set from the same call because they describe the same thing: what
1036
+ kind of command this is. Reads are quick and cool, writes are slower and
1037
+ warm, so the strip's colour and its rate agree with each other instead of
1038
+ moving independently.
1039
+ ]]
1040
+ function Visuals.setKind(color: Color3, urgency: number)
1041
+ runtime.activeTint = color
1042
+ runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
1043
+ runtime.quiet = 0
1044
+ --[[
1045
+ Drawn in as the command goes out, so the pair reads as one gesture: the
1046
+ cell contracts while the request is in flight and springs open when the
1047
+ answer arrives. On a fast call the two are almost one motion, which is
1048
+ itself the report -- a slow call visibly holds its breath.
1049
+ ]]
1050
+ runtime.scale = math.min(runtime.scale, 0.85)
1051
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
1052
+ -- Re-aimed per kind so successive commands of different types visibly
1053
+ -- change the axis rather than continuing the same turn.
1054
+ runtime.spin = Vector3.new(
1055
+ 0.12 + math.random() * 0.16,
1056
+ 0.18 + math.random() * 0.22,
1057
+ 0.06 + math.random() * 0.12
1058
+ )
1059
+ end
1060
+
1061
+ --[[
1062
+ What is running, in the same words the log uses. The motion says something is
1063
+ happening; this says what, and neither answers the other's question.
1064
+ ]]
1065
+ function Visuals.setCaption(title: string)
1066
+ runtime.lastTitle = title
1067
+ -- A note is holding the caption's real text for later. Update what will be
1068
+ -- restored, not what is on screen, or the note is wiped by the next command
1069
+ -- and clearing it would put back a line that is already out of date.
1070
+ if runtime.noted ~= nil then
1071
+ runtime.noted = title
1072
+ return
1073
+ end
1074
+ if runtime.caption then
1075
+ runtime.caption.Text = title
1076
+ end
1077
+ end
1078
+
1079
+ --[[
1080
+ Borrows the caption to answer something the user is pointing at.
1081
+
1082
+ The caption is the band's one line of prose, so a note goes there rather
1083
+ than into a floating panel: there is exactly one place on this widget that
1084
+ explains what you are looking at, and two would be one too many. Whatever
1085
+ the caption was saying is put back by `clearNote`, so a note never costs the
1086
+ user the line they were reading.
1087
+ ]]
1088
+ function Visuals.showNote(text: string)
1089
+ if runtime.caption == nil or text == "" then
1090
+ return
1091
+ end
1092
+ if runtime.noted == nil then
1093
+ runtime.noted = runtime.caption.Text
1094
+ end
1095
+ runtime.caption.Text = text
1096
+ end
1097
+
1098
+ function Visuals.clearNote()
1099
+ local held = runtime.noted
1100
+ if held == nil or runtime.caption == nil then
1101
+ return
1102
+ end
1103
+ runtime.noted = nil
1104
+ runtime.caption.Text = held
1105
+ end
1106
+
1107
+ --[[
1108
+ Falls back to the last thing that ran once a command finishes.
1109
+
1110
+ The footer already reports totals, so repeating "idle" here would say the
1111
+ same word twice on one screen. What has just happened is more useful and is
1112
+ not shown anywhere else.
1113
+ ]]
1114
+ function Visuals.setIdle()
1115
+ if runtime.caption == nil then
1116
+ return
1117
+ end
1118
+ local text = if runtime.lastTitle ~= nil
1119
+ then "last: " .. runtime.lastTitle
1120
+ else "waiting for a command"
1121
+ -- Same rule as setCaption: a note owns the line until it is cleared.
1122
+ if runtime.noted ~= nil then
1123
+ runtime.noted = text
1124
+ return
1125
+ end
1126
+ runtime.caption.Text = text
1127
+ end
1128
+
1129
+ --[[
1130
+ Says whether an agent started from the prompt is currently working.
1131
+
1132
+ One call on each edge rather than a stream of keepalives: the state is a
1133
+ condition with two ends, and a heartbeat that has to keep arriving is a
1134
+ heartbeat that eventually does not -- leaving the strip pulsing at a
1135
+ conversation that finished.
1136
+
1137
+ Starting re-aims the spin, so the turn visibly changes axis at the moment the
1138
+ agent picks the work up. Stopping does nothing but drop the floor: what is
1139
+ left decays on its own, which is the honest picture of work that has just
1140
+ ended rather than work that was cut off.
1141
+ ]]
1142
+ function Visuals.setThinking(thinking: boolean)
1143
+ if runtime.thinking == thinking then
1144
+ return
1145
+ end
1146
+ runtime.thinking = thinking
1147
+ if thinking then
1148
+ runtime.quiet = 0
1149
+ runtime.spin = Vector3.new(
1150
+ 0.10 + math.random() * 0.14,
1151
+ 0.16 + math.random() * 0.20,
1152
+ 0.05 + math.random() * 0.10
1153
+ )
1154
+ end
1155
+ end
1156
+
1157
+ --[[
1158
+ Winds the cell down, because the session has stopped rather than paused.
1159
+
1160
+ The strip already decays on its own, but decay bottoms out at "idle and
1161
+ moving", which looks the same after twenty seconds as after two hours. This
1162
+ is the console telling the picture what it has just told the log, so the two
1163
+ agree.
1164
+ ]]
1165
+ function Visuals.setQuiet()
1166
+ -- Not while an agent is mid-run. This is called when a client disconnects,
1167
+ -- and an agent that finishes one turn and is about to be asked another
1168
+ -- would otherwise wind the strip fully down between two halves of the same
1169
+ -- conversation.
1170
+ if runtime.thinking then
1171
+ return
1172
+ end
1173
+ runtime.quiet = QUIET_AFTER + 1
1174
+ runtime.activity = 0
1175
+ end
1176
+
1177
+ --[[
1178
+ Empties the trace.
1179
+
1180
+ Paired with the console's clear button. The bars used to survive it while the
1181
+ footer counters reset, so "clear" wiped the log, wiped the statistics, and
1182
+ left forty timings from the session it had just erased sitting on screen.
1183
+ ]]
1184
+ function Visuals.clearTrace()
1185
+ for _, entry in runtime.bars do
1186
+ entry.frame:Destroy()
1187
+ entry.hit:Destroy()
1188
+ end
1189
+ table.clear(runtime.bars)
1190
+ clearReading()
1191
+ end
1192
+
1193
+ function Visuals.setVisible(visible: boolean)
1194
+ local band = runtime.band
1195
+ if band == nil then
1196
+ return
1197
+ end
1198
+ band.Visible = visible
1199
+
1200
+ -- Connected only while on screen: a hidden strip that keeps projecting
1201
+ -- geometry every frame is a battery complaint waiting to happen.
1202
+ if visible and runtime.connection == nil then
1203
+ runtime.connection = RunService.Heartbeat:Connect(step)
1204
+ elseif not visible and runtime.connection ~= nil then
1205
+ runtime.connection:Disconnect()
1206
+ runtime.connection = nil
1207
+ end
1208
+ end
1209
+
1210
+ function Visuals.isVisible(): boolean
1211
+ local band = runtime.band
1212
+ return band ~= nil and band.Visible
1213
+ end
1214
+
1215
+ --[[
1216
+ Swaps the cell's contents and recolours the strip's own chrome.
1217
+
1218
+ The order matters. The outgoing preset is unmounted before the cell is
1219
+ emptied, so a preset that keeps references cannot be handed destroyed
1220
+ Instances; the cell is then cleared outright rather than trusted to be
1221
+ clean, because a preset that errored midway through `mount` will have left
1222
+ something behind.
1223
+ ]]
1224
+ function Visuals.applyTheme()
1225
+ local cell = runtime.cell
1226
+ local band = runtime.band
1227
+ if cell == nil or band == nil then
1228
+ return
1229
+ end
1230
+
1231
+ if runtime.mounted then
1232
+ pcall(function()
1233
+ Themes.active().unmount()
1234
+ end)
1235
+ end
1236
+ for _, child in cell:GetChildren() do
1237
+ child:Destroy()
1238
+ end
1239
+
1240
+ local palette = Themes.palette()
1241
+ band.BackgroundColor3 = palette.surface
1242
+ if runtime.divider then
1243
+ (runtime.divider :: Frame).BackgroundColor3 = palette.dim
1244
+ end
1245
+ if runtime.baseline then
1246
+ (runtime.baseline :: Frame).BackgroundColor3 = palette.dim
1247
+ end
1248
+ if runtime.highlight then
1249
+ (runtime.highlight :: Frame).BackgroundColor3 = palette.text
1250
+ end
1251
+ if runtime.caption then
1252
+ (runtime.caption :: TextLabel).TextColor3 = palette.text
1253
+ end
1254
+ if runtime.readout then
1255
+ (runtime.readout :: TextLabel).BackgroundColor3 = palette.background
1256
+ end
1257
+
1258
+ local ok, err = pcall(function()
1259
+ Themes.active().mount(cell)
1260
+ end)
1261
+ runtime.mounted = ok
1262
+ if not ok then
1263
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
1264
+ end
1265
+
1266
+ relayout()
1267
+ -- A preset that failed to paint disabled the band; a new one deserves the
1268
+ -- chance to prove it works.
1269
+ band.Visible = true
1270
+ end
1271
+
1272
+ --[[
1273
+ Builds the strip. Everything except the cell's contents is created here and
1274
+ never again, which is what keeps the per-frame path allocation-free.
1275
+ ]]
1276
+ function Visuals.mount(parent: Instance)
1277
+ local palette = Themes.palette()
1278
+
1279
+ local band = Instance.new("Frame")
1280
+ band.Name = "ActivityBand"
1281
+ band.BackgroundColor3 = palette.surface
1282
+ band.BorderSizePixel = 0
1283
+ band.Visible = false
1284
+ band.ClipsDescendants = true
1285
+ band.Parent = parent
1286
+ runtime.band = band
1287
+
1288
+ local cell = Instance.new("Frame")
1289
+ cell.Name = "Cell"
1290
+ cell.Position = UDim2.fromOffset(INSET, (Visuals.BAND_HEIGHT - CELL) / 2)
1291
+ cell.Size = UDim2.fromOffset(CELL, CELL)
1292
+ cell.BackgroundTransparency = 1
1293
+ cell.ClipsDescendants = true
1294
+ cell.Parent = band
1295
+ runtime.cell = cell
1296
+
1297
+ -- A hairline between the cell and the trace, so the strip reads as two
1298
+ -- instruments rather than one busy rectangle. Centred in the gutter, which
1299
+ -- is what keeps the extra margin as air on both sides rather than as a gap
1300
+ -- on one.
1301
+ local divider = Instance.new("Frame")
1302
+ divider.Name = "Divider"
1303
+ divider.Position = UDim2.fromOffset(INSET + CELL + GUTTER / 2, 10)
1304
+ divider.Size = UDim2.new(0, 1, 1, -20)
1305
+ divider.BackgroundColor3 = palette.dim
1306
+ divider.BackgroundTransparency = 0.78
1307
+ divider.BorderSizePixel = 0
1308
+ divider.Parent = band
1309
+ runtime.divider = divider
1310
+
1311
+ local right = INSET + CELL + GUTTER
1312
+
1313
+ local caption = Instance.new("TextLabel")
1314
+ caption.Name = "Caption"
1315
+ caption.Position = UDim2.new(0, right, 0, 6)
1316
+ caption.Size = UDim2.new(1, -right - 12, 0, 14)
1317
+ caption.BackgroundTransparency = 1
1318
+ caption.Font = Enum.Font.Code
1319
+ caption.TextSize = 11
1320
+ caption.TextColor3 = palette.text
1321
+ caption.TextXAlignment = Enum.TextXAlignment.Left
1322
+ caption.TextTruncate = Enum.TextTruncate.AtEnd
1323
+ caption.Text = "idle"
1324
+ caption.Parent = band
1325
+ runtime.caption = caption
1326
+
1327
+ local trace = Instance.new("Frame")
1328
+ trace.Name = "Trace"
1329
+ trace.Position = UDim2.new(0, right, 0, 24)
1330
+ trace.Size = UDim2.new(1, -right - 12, 0, TRACE_HEIGHT)
1331
+ trace.BackgroundTransparency = 1
1332
+ trace.ClipsDescendants = true
1333
+ trace.Parent = band
1334
+ runtime.trace = trace
1335
+
1336
+ -- A baseline under the bars, so an empty trace still reads as an instrument
1337
+ -- waiting for data rather than as a blank gap.
1338
+ local baseline = Instance.new("Frame")
1339
+ baseline.Name = "Baseline"
1340
+ baseline.AnchorPoint = Vector2.new(0, 1)
1341
+ baseline.Position = UDim2.fromScale(0, 1)
1342
+ baseline.Size = UDim2.new(1, 0, 0, 1)
1343
+ baseline.BackgroundColor3 = palette.dim
1344
+ baseline.BackgroundTransparency = 0.75
1345
+ baseline.BorderSizePixel = 0
1346
+ baseline.Parent = trace
1347
+ runtime.baseline = baseline
1348
+
1349
+ --[[
1350
+ Built once and moved, and created BEFORE the bars exist so it sits under
1351
+ them in draw order -- a highlight drawn over a one-pixel bar would hide the
1352
+ very thing it is pointing at.
1353
+ ]]
1354
+ local highlight = Instance.new("Frame")
1355
+ highlight.Name = "Highlight"
1356
+ highlight.AnchorPoint = Vector2.new(0.5, 1)
1357
+ highlight.Position = UDim2.new(1, 0, 1, 0)
1358
+ highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
1359
+ highlight.BackgroundColor3 = palette.text
1360
+ -- Faint enough not to hide the bar it sits behind, solid enough to be seen
1361
+ -- at all.
1362
+ highlight.BackgroundTransparency = 0.7
1363
+ highlight.BorderSizePixel = 0
1364
+ highlight.Visible = false
1365
+ highlight.Parent = trace
1366
+ runtime.highlight = highlight
1367
+
1368
+ --[[
1369
+ The reading sits at the left end of the trace rather than beside the bar it
1370
+ describes. Following the pointer would put it off the right edge for the
1371
+ newest bars, which are the ones most often asked about, and the left end is
1372
+ empty until forty calls have accumulated.
1373
+ ]]
1374
+ local readout = Instance.new("TextLabel")
1375
+ readout.Name = "Readout"
1376
+ readout.AnchorPoint = Vector2.new(0, 0.5)
1377
+ readout.Position = UDim2.new(0, 0, 0.5, 0)
1378
+ -- Sized by its text now that it carries a name as well as a number. A fixed
1379
+ -- 110px was enough for "18 ms" and truncates anything with a phrase in front
1380
+ -- of it, which is the half worth reading.
1381
+ readout.AutomaticSize = Enum.AutomaticSize.X
1382
+ readout.Size = UDim2.new(0, 0, 0, 14)
1383
+ readout.BackgroundColor3 = palette.background
1384
+ readout.BackgroundTransparency = 0.15
1385
+ readout.BorderSizePixel = 0
1386
+ readout.Font = Enum.Font.Code
1387
+ readout.TextSize = 11
1388
+ readout.TextXAlignment = Enum.TextXAlignment.Left
1389
+ readout.Text = ""
1390
+ readout.Visible = false
1391
+ readout.ZIndex = 3
1392
+ readout.Parent = trace
1393
+ runtime.readout = readout
1394
+
1395
+ local readoutPadding = Instance.new("UIPadding")
1396
+ readoutPadding.PaddingLeft = UDim.new(0, 4)
1397
+ readoutPadding.PaddingRight = UDim.new(0, 4)
1398
+ readoutPadding.Parent = readout
1399
+
1400
+ local ok, err = pcall(function()
1401
+ Themes.active().mount(cell)
1402
+ end)
1403
+ runtime.mounted = ok
1404
+ if not ok then
1405
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
1406
+ end
1407
+ end
1408
+
1409
+ return Visuals