@el4cteo/rbx-studio-mcp 0.5.5 → 0.5.8
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.
- package/config/dsh.cordis.yml +45 -0
- package/dist/bridge/server.js +6 -0
- package/dist/bridge/server.js.map +1 -1
- package/package.json +74 -68
- package/plugin/src/Commands.luau +562 -524
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1843 -1831
- package/plugin/src/Prompt.luau +961 -436
- package/plugin/src/init.server.luau +832 -829
package/plugin/src/Prompt.luau
CHANGED
|
@@ -1,436 +1,961 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
The console's command line.
|
|
4
|
-
|
|
5
|
-
The panel was read-only for its whole life, and the footer even carried a
|
|
6
|
-
note explaining why it had no cursor: a blinking block promises that
|
|
7
|
-
something accepts input, and nothing did. That is what this changes. One row
|
|
8
|
-
under the header, always visible -- it does not ride the `visuals` toggle,
|
|
9
|
-
because a control that disappears with a decoration is a control nobody
|
|
10
|
-
trusts -- holding a caret and a field.
|
|
11
|
-
|
|
12
|
-
Two kinds of thing go in it and the row does not distinguish them, on
|
|
13
|
-
purpose. A word it knows is a command and runs here in milliseconds; anything
|
|
14
|
-
else is a sentence, and sentences go to an agent. Making the user mark which
|
|
15
|
-
is which would be asking them to know our implementation before they can
|
|
16
|
-
type.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
]]
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
field
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
--
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
runtime.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
local
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
--
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
--
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The console's command line.
|
|
4
|
+
|
|
5
|
+
The panel was read-only for its whole life, and the footer even carried a
|
|
6
|
+
note explaining why it had no cursor: a blinking block promises that
|
|
7
|
+
something accepts input, and nothing did. That is what this changes. One row
|
|
8
|
+
under the header, always visible -- it does not ride the `visuals` toggle,
|
|
9
|
+
because a control that disappears with a decoration is a control nobody
|
|
10
|
+
trusts -- holding a caret and a field.
|
|
11
|
+
|
|
12
|
+
Two kinds of thing go in it and the row does not distinguish them, on
|
|
13
|
+
purpose. A word it knows is a command and runs here in milliseconds; anything
|
|
14
|
+
else is a sentence, and sentences go to an agent. Making the user mark which
|
|
15
|
+
is which would be asking them to know our implementation before they can
|
|
16
|
+
type.
|
|
17
|
+
|
|
18
|
+
Above the row sits a scrolling menu of the commands matching what is being
|
|
19
|
+
typed -- all of them, while the field is empty. The placeholder used to say
|
|
20
|
+
"type a command" and then leave the reader to work out which commands
|
|
21
|
+
existed, which is a prompt keeping a secret. The list is the answer, and it
|
|
22
|
+
is shown for the same reason a shell shows you its completions rather than
|
|
23
|
+
describing them.
|
|
24
|
+
|
|
25
|
+
WHY THE MENU IS DRIVEN BY THE MOUSE AND BY TYPING, AND NOT BY ARROW KEYS.
|
|
26
|
+
|
|
27
|
+
Because Studio does not deliver keyboard input to plugin widgets at all.
|
|
28
|
+
UserInputService and ContextActionService are documented as not working
|
|
29
|
+
inside a PluginGui, and a GuiObject's own InputBegan never sees a key either
|
|
30
|
+
-- the request asking Roblox for a way to read keys in a plugin window has
|
|
31
|
+
been open since 2020 and was bumped again a month ago:
|
|
32
|
+
|
|
33
|
+
https://devforum.roblox.com/t/plugins-need-a-way-to-listen-to-app-input/479597
|
|
34
|
+
https://devforum.roblox.com/t/text-editor-make-editing-text-fields-much-easier/684771
|
|
35
|
+
|
|
36
|
+
This was learned the expensive way: an arrow-key highlight was written, then
|
|
37
|
+
rewritten against UserInputService, then wired to both at once, and all three
|
|
38
|
+
were dead on arrival. Mouse input works -- that is what the theme drawer runs
|
|
39
|
+
on -- and the TextBox reports its own text and its own Enter. Between them
|
|
40
|
+
they are enough, so the menu offers exactly those and the caption promises
|
|
41
|
+
nothing else. A key that a caption advertises and the platform cannot deliver
|
|
42
|
+
is worse than no key at all.
|
|
43
|
+
]]
|
|
44
|
+
|
|
45
|
+
local Themes = require(script.Parent.Themes)
|
|
46
|
+
|
|
47
|
+
local Prompt = {}
|
|
48
|
+
|
|
49
|
+
-- Tall enough for a 12pt line with air around it, short enough that it does not
|
|
50
|
+
-- read as a second header. The log gives up exactly this much.
|
|
51
|
+
Prompt.HEIGHT = 30
|
|
52
|
+
|
|
53
|
+
-- Matches the log's own left inset, so the caret sits in the same column as the
|
|
54
|
+
-- sigils below it and the whole panel reads as one grid.
|
|
55
|
+
local INSET = 12
|
|
56
|
+
local CARET_WIDTH = 14
|
|
57
|
+
|
|
58
|
+
--[[
|
|
59
|
+
How many past lines are kept.
|
|
60
|
+
|
|
61
|
+
A ring rather than a session log: this is for getting back to the thing you
|
|
62
|
+
typed a moment ago, and anyone scrolling past fifty entries wanted the log,
|
|
63
|
+
not the history.
|
|
64
|
+
]]
|
|
65
|
+
local MAX_HISTORY = 50
|
|
66
|
+
|
|
67
|
+
--[[
|
|
68
|
+
The command menu's shape.
|
|
69
|
+
|
|
70
|
+
Seven rows is about as tall as this can be before it stops being a hint over
|
|
71
|
+
the log and starts being a page covering it. Everything past that is reached
|
|
72
|
+
by scrolling, which is the mouse's job here rather than a key's.
|
|
73
|
+
]]
|
|
74
|
+
local MENU_ROW_HEIGHT = 20
|
|
75
|
+
local MENU_MAX_ROWS = 7
|
|
76
|
+
local MENU_PAD = 6
|
|
77
|
+
local MENU_CAPTION_HEIGHT = 16
|
|
78
|
+
local MENU_SCROLLBAR = 4
|
|
79
|
+
-- Gap between the menu's bottom edge and the top of the prompt row.
|
|
80
|
+
local MENU_LIFT = 2
|
|
81
|
+
-- Where the summary column starts, measured from the menu's left edge. Wide
|
|
82
|
+
-- enough for `agent [use <id>|new]`, the longest usage line there is.
|
|
83
|
+
local MENU_SUMMARY_X = 140
|
|
84
|
+
|
|
85
|
+
--[[
|
|
86
|
+
Explicit depths, because a plugin widget draws with ZIndexBehavior.Global
|
|
87
|
+
and a child does not inherit its parent's. Above the log and the CRT flourish
|
|
88
|
+
that sit at 20-22; below the preset drawer at 30, which is modal and must
|
|
89
|
+
stay clickable when it is open.
|
|
90
|
+
]]
|
|
91
|
+
local Z_MENU = 24
|
|
92
|
+
local Z_MENU_SCROLL = 25
|
|
93
|
+
local Z_MENU_ROW = 26
|
|
94
|
+
local Z_MENU_TEXT = 27
|
|
95
|
+
|
|
96
|
+
type Suggestion = {
|
|
97
|
+
name: string,
|
|
98
|
+
usage: string,
|
|
99
|
+
summary: string,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type MenuRow = {
|
|
103
|
+
button: TextButton,
|
|
104
|
+
usage: TextLabel,
|
|
105
|
+
summary: TextLabel,
|
|
106
|
+
--[[
|
|
107
|
+
The command this row is currently showing, and whether its usage line
|
|
108
|
+
mentions an argument.
|
|
109
|
+
|
|
110
|
+
Held on the row rather than looked up when it is clicked. The lookup was
|
|
111
|
+
a real bug: clicking takes focus off the field, the focus handler emptied
|
|
112
|
+
the match list, and by the time the click arrived there was nothing left
|
|
113
|
+
to resolve -- so every click did nothing at all.
|
|
114
|
+
]]
|
|
115
|
+
entry: string,
|
|
116
|
+
takesArgument: boolean,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type Runtime = {
|
|
120
|
+
row: Frame?,
|
|
121
|
+
caret: TextLabel?,
|
|
122
|
+
field: TextBox?,
|
|
123
|
+
hint: TextLabel?,
|
|
124
|
+
rule: Frame?,
|
|
125
|
+
menu: Frame?,
|
|
126
|
+
menuScroll: ScrollingFrame?,
|
|
127
|
+
menuCaption: TextLabel?,
|
|
128
|
+
menuRows: { MenuRow },
|
|
129
|
+
--[[
|
|
130
|
+
What the menu is currently offering. Empty means it is closed; there is
|
|
131
|
+
no separate flag, because two ways to ask whether a list is showing is
|
|
132
|
+
one way too many.
|
|
133
|
+
]]
|
|
134
|
+
matches: { Suggestion },
|
|
135
|
+
-- Which row the pointer is over, 1-based into `matches`; 0 for none.
|
|
136
|
+
hovered: number,
|
|
137
|
+
-- Whether the field currently holds the keyboard. The menu is a thing you
|
|
138
|
+
-- type at, so it has no business being on screen when nobody is typing.
|
|
139
|
+
focused: boolean,
|
|
140
|
+
--[[
|
|
141
|
+
Whether the pointer is over the menu.
|
|
142
|
+
|
|
143
|
+
Checked before the menu is taken down on focus loss, and that ordering is
|
|
144
|
+
the whole reason it exists: pressing the mouse on a row releases the
|
|
145
|
+
field's focus BEFORE the click completes, so a menu that closes on focus
|
|
146
|
+
loss deletes the button mid-press and the click never happens.
|
|
147
|
+
]]
|
|
148
|
+
hovering: boolean,
|
|
149
|
+
--[[
|
|
150
|
+
Newest last. The entry being edited is NOT in here -- it is in the field
|
|
151
|
+
-- so the list holds only lines that were actually submitted.
|
|
152
|
+
]]
|
|
153
|
+
history: { string },
|
|
154
|
+
--[[
|
|
155
|
+
Where in the history the user is, counted from the end. Zero means "at
|
|
156
|
+
the live line", which is the only state in which the field's contents
|
|
157
|
+
belong to the user rather than to a recalled entry.
|
|
158
|
+
]]
|
|
159
|
+
offset: number,
|
|
160
|
+
-- What was being typed before the history took the field over, so coming
|
|
161
|
+
-- back returns it rather than an empty line.
|
|
162
|
+
draft: string,
|
|
163
|
+
busy: boolean,
|
|
164
|
+
--[[
|
|
165
|
+
What is selected in Studio right now, already worded for display.
|
|
166
|
+
|
|
167
|
+
Kept here because the prompt is where it earns its place: half of what
|
|
168
|
+
anyone types into this box is deictic -- "make this bigger", "delete
|
|
169
|
+
that" -- and the word only means something if you can see what it points
|
|
170
|
+
at while you are writing it. Studio's own Explorer is often scrolled
|
|
171
|
+
somewhere else, or behind the panel.
|
|
172
|
+
]]
|
|
173
|
+
selection: string,
|
|
174
|
+
complete: ((string) -> { string })?,
|
|
175
|
+
suggest: ((string) -> { Suggestion })?,
|
|
176
|
+
submit: ((string) -> ())?,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
local runtime: Runtime = {
|
|
180
|
+
row = nil,
|
|
181
|
+
caret = nil,
|
|
182
|
+
field = nil,
|
|
183
|
+
hint = nil,
|
|
184
|
+
rule = nil,
|
|
185
|
+
menu = nil,
|
|
186
|
+
menuScroll = nil,
|
|
187
|
+
menuCaption = nil,
|
|
188
|
+
menuRows = {},
|
|
189
|
+
matches = {},
|
|
190
|
+
hovered = 0,
|
|
191
|
+
focused = false,
|
|
192
|
+
hovering = false,
|
|
193
|
+
history = {},
|
|
194
|
+
offset = 0,
|
|
195
|
+
draft = "",
|
|
196
|
+
busy = false,
|
|
197
|
+
selection = "",
|
|
198
|
+
complete = nil,
|
|
199
|
+
suggest = nil,
|
|
200
|
+
submit = nil,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
--[[
|
|
204
|
+
The caret's colour, which is the row's only status light.
|
|
205
|
+
|
|
206
|
+
Violet at rest is the project's accent and says "this is ours". Amber while
|
|
207
|
+
an agent runs says something is happening without stealing the caption's
|
|
208
|
+
line, which is already reporting what.
|
|
209
|
+
]]
|
|
210
|
+
local function paintCaret()
|
|
211
|
+
local caret = runtime.caret
|
|
212
|
+
if caret == nil then
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
local palette = Themes.palette()
|
|
216
|
+
caret.TextColor3 = if runtime.busy then palette.amber else palette.violet
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
local function placeholder(): string
|
|
220
|
+
return if runtime.busy
|
|
221
|
+
then "running -- type stop to cancel"
|
|
222
|
+
else "type a command, or ask for something"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
--[[
|
|
226
|
+
Whether the command menu is on screen.
|
|
227
|
+
|
|
228
|
+
Derived from the match list rather than from a flag of its own: the menu is
|
|
229
|
+
the list, and a second source of truth for "is it showing" is a second thing
|
|
230
|
+
that can be wrong.
|
|
231
|
+
]]
|
|
232
|
+
local function isMenuOpen(): boolean
|
|
233
|
+
local menu = runtime.menu
|
|
234
|
+
return menu ~= nil and menu.Visible and #runtime.matches > 0
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
--[[
|
|
238
|
+
Draws the current matches into the pre-built rows.
|
|
239
|
+
|
|
240
|
+
The rows exist from `mount` -- one per command, since that number only
|
|
241
|
+
changes when the source does -- and are only ever shown, hidden and
|
|
242
|
+
relabelled. Building them per keystroke would mean four `Instance.new` calls
|
|
243
|
+
per visible command on every letter typed, and a menu that flickers as it is
|
|
244
|
+
rebuilt is worse than no menu.
|
|
245
|
+
]]
|
|
246
|
+
local function renderMenu()
|
|
247
|
+
local menu = runtime.menu
|
|
248
|
+
local scroll = runtime.menuScroll
|
|
249
|
+
local caption = runtime.menuCaption
|
|
250
|
+
if menu == nil or scroll == nil or caption == nil then
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
local total = #runtime.matches
|
|
255
|
+
if total == 0 then
|
|
256
|
+
menu.Visible = false
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
local palette = Themes.palette()
|
|
261
|
+
|
|
262
|
+
for position, row in runtime.menuRows do
|
|
263
|
+
local entry = runtime.matches[position]
|
|
264
|
+
if entry == nil then
|
|
265
|
+
row.button.Visible = false
|
|
266
|
+
row.entry = ""
|
|
267
|
+
continue
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
row.button.Visible = true
|
|
271
|
+
row.button.LayoutOrder = position
|
|
272
|
+
row.button.BackgroundColor3 = palette.surface
|
|
273
|
+
row.button.BackgroundTransparency = if position == runtime.hovered then 0.25 else 1
|
|
274
|
+
row.usage.Text = entry.usage
|
|
275
|
+
row.usage.TextColor3 = if position == runtime.hovered then palette.violet else palette.text
|
|
276
|
+
row.summary.Text = entry.summary
|
|
277
|
+
row.summary.TextColor3 = palette.dim
|
|
278
|
+
row.entry = entry.name
|
|
279
|
+
-- A usage line that is more than the bare name -- `theme [name]`,
|
|
280
|
+
-- `use <number|id>` -- is a command that wants an argument, and those
|
|
281
|
+
-- are the only ones worth leaving a space after.
|
|
282
|
+
row.takesArgument = entry.usage ~= entry.name
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
--[[
|
|
286
|
+
Only as tall as it needs to be, up to seven rows. The rest is reached by
|
|
287
|
+
scrolling, and the scrollbar appearing is what says there is more.
|
|
288
|
+
]]
|
|
289
|
+
local visible = math.min(total, MENU_MAX_ROWS)
|
|
290
|
+
scroll.Size = UDim2.new(1, 0, 0, visible * MENU_ROW_HEIGHT)
|
|
291
|
+
scroll.ScrollBarImageColor3 = palette.dim
|
|
292
|
+
|
|
293
|
+
caption.Text = if total == 1
|
|
294
|
+
then "1 command click it, or press enter to run"
|
|
295
|
+
else string.format("%d commands click one, or keep typing", total)
|
|
296
|
+
caption.TextColor3 = palette.dim
|
|
297
|
+
-- Indented to the same column as the rows above it. This used to be flush
|
|
298
|
+
-- against the frame's edge, which made it read as something that had fallen
|
|
299
|
+
-- out of the menu rather than as part of it.
|
|
300
|
+
caption.Position = UDim2.fromOffset(MENU_PAD, MENU_PAD + visible * MENU_ROW_HEIGHT)
|
|
301
|
+
|
|
302
|
+
menu.BackgroundColor3 = palette.background
|
|
303
|
+
menu.Size =
|
|
304
|
+
UDim2.new(1, -(INSET * 2), 0, MENU_PAD * 2 + visible * MENU_ROW_HEIGHT + MENU_CAPTION_HEIGHT)
|
|
305
|
+
menu.Visible = true
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
local function closeMenu()
|
|
309
|
+
runtime.matches = {}
|
|
310
|
+
runtime.hovered = 0
|
|
311
|
+
local menu = runtime.menu
|
|
312
|
+
if menu then
|
|
313
|
+
menu.Visible = false
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
--[[
|
|
318
|
+
The greyed-out remainder of the command being typed.
|
|
319
|
+
|
|
320
|
+
Drawn as a separate label positioned after the text rather than inside the
|
|
321
|
+
field, because a TextBox that writes its own guess into itself has to undo
|
|
322
|
+
that on every keystroke and gets it wrong the moment the user types fast.
|
|
323
|
+
]]
|
|
324
|
+
local function showHint()
|
|
325
|
+
local field = runtime.field
|
|
326
|
+
local hint = runtime.hint
|
|
327
|
+
local complete = runtime.complete
|
|
328
|
+
if field == nil or hint == nil or complete == nil then
|
|
329
|
+
return
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
--[[
|
|
333
|
+
The menu says it better, when the menu is up.
|
|
334
|
+
|
|
335
|
+
This row used to carry the whole match list crammed into half a line of
|
|
336
|
+
11pt text. Now that the same matches are listed properly above the field,
|
|
337
|
+
repeating them here would be noise competing with the thing it duplicates
|
|
338
|
+
-- so the slot goes back to the selection, which has nowhere else to be.
|
|
339
|
+
]]
|
|
340
|
+
if isMenuOpen() then
|
|
341
|
+
hint.Text = runtime.selection
|
|
342
|
+
return
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
local typed = field.Text
|
|
346
|
+
-- Only the first word completes. Everything after it is an argument, and
|
|
347
|
+
-- guessing at arguments is how a prompt starts fighting the person using it.
|
|
348
|
+
if typed ~= "" and string.find(typed, "%s") == nil then
|
|
349
|
+
local matches = complete(typed)
|
|
350
|
+
if #matches == 1 and matches[1] ~= typed then
|
|
351
|
+
hint.Text = matches[1]
|
|
352
|
+
return
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
hint.Text = runtime.selection
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
--[[
|
|
360
|
+
Recomputes what the menu offers, from what is in the field right now.
|
|
361
|
+
|
|
362
|
+
One rule, and only one: the list is up whenever the bar has the cursor and
|
|
363
|
+
holds at most one word. There used to be two more -- a "dismissed" flag and a
|
|
364
|
+
"resuming" flag -- and between them they were the bug that would not die:
|
|
365
|
+
clicking the empty bar after running a command showed nothing, because a flag
|
|
366
|
+
set on the way out of the last command was still holding the list shut and
|
|
367
|
+
only a keystroke cleared it. Flags that guess at intent are how that happens.
|
|
368
|
+
State this small should be derivable from what is on screen, so now it is.
|
|
369
|
+
|
|
370
|
+
The text is trimmed before it is judged, because whitespace is not content. A
|
|
371
|
+
bar holding nothing but a space is an empty bar, and it was being treated as a
|
|
372
|
+
line with an argument in it -- which closed the menu and left no way to tell
|
|
373
|
+
why.
|
|
374
|
+
]]
|
|
375
|
+
local function refreshMenu()
|
|
376
|
+
local field = runtime.field
|
|
377
|
+
local suggest = runtime.suggest
|
|
378
|
+
if field == nil or suggest == nil then
|
|
379
|
+
return
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
local trimmed = string.match(field.Text, "^%s*(.-)%s*$") or ""
|
|
383
|
+
|
|
384
|
+
-- A space INSIDE the line means the first word is settled and an argument is
|
|
385
|
+
-- being written, which is the point at which a list of commands stops being
|
|
386
|
+
-- what the person typing needs to see.
|
|
387
|
+
if not runtime.focused or string.find(trimmed, "%s") ~= nil then
|
|
388
|
+
closeMenu()
|
|
389
|
+
showHint()
|
|
390
|
+
return
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
runtime.matches = suggest(trimmed)
|
|
394
|
+
-- Nothing is highlighted until the pointer picks something. A highlight that
|
|
395
|
+
-- appears on its own is a selection the user did not make, and clicking is
|
|
396
|
+
-- the only way to act on it anyway.
|
|
397
|
+
runtime.hovered = 0
|
|
398
|
+
|
|
399
|
+
--[[
|
|
400
|
+
Back to the top, but only here.
|
|
401
|
+
|
|
402
|
+
This used to live in `renderMenu`, which also runs on every hover -- so
|
|
403
|
+
scrolling to the bottom of the list and then moving the mouse a pixel
|
|
404
|
+
snapped it straight back to the first row. The scroll position is only
|
|
405
|
+
meaningless when the list underneath it has changed, and that is exactly
|
|
406
|
+
what this function is for.
|
|
407
|
+
]]
|
|
408
|
+
local scroll = runtime.menuScroll
|
|
409
|
+
if scroll then
|
|
410
|
+
scroll.CanvasPosition = Vector2.new(0, 0)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
renderMenu()
|
|
414
|
+
showHint()
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
--[[
|
|
418
|
+
Puts a command in the field, without running it.
|
|
419
|
+
|
|
420
|
+
A trailing space only where the command takes an argument. It used to be
|
|
421
|
+
added unconditionally, which left `clear ` sitting in the bar with a space
|
|
422
|
+
the user then had to notice and delete -- reported as exactly that. A command
|
|
423
|
+
that takes nothing is complete the moment its name is there, and Enter runs
|
|
424
|
+
it.
|
|
425
|
+
]]
|
|
426
|
+
local function fillWith(name: string, takesArgument: boolean)
|
|
427
|
+
local field = runtime.field
|
|
428
|
+
if field == nil or name == "" then
|
|
429
|
+
return
|
|
430
|
+
end
|
|
431
|
+
closeMenu()
|
|
432
|
+
field.Text = if takesArgument then name .. " " else name
|
|
433
|
+
field.CursorPosition = #field.Text + 1
|
|
434
|
+
showHint()
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
--[[
|
|
438
|
+
Puts a past line back in the field.
|
|
439
|
+
|
|
440
|
+
`offset` counts backwards from the newest entry, so 1 is the last thing
|
|
441
|
+
typed. Zero is the live line, which is the draft rather than any entry.
|
|
442
|
+
]]
|
|
443
|
+
local function recall(offset: number)
|
|
444
|
+
local field = runtime.field
|
|
445
|
+
if field == nil then
|
|
446
|
+
return
|
|
447
|
+
end
|
|
448
|
+
local total = #runtime.history
|
|
449
|
+
local wanted = math.clamp(offset, 0, total)
|
|
450
|
+
|
|
451
|
+
-- Stepping off the live line for the first time: keep what was there, or
|
|
452
|
+
-- walking up and back down silently eats a half-typed command.
|
|
453
|
+
if runtime.offset == 0 and wanted > 0 then
|
|
454
|
+
runtime.draft = field.Text
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
runtime.offset = wanted
|
|
458
|
+
field.Text = if wanted == 0 then runtime.draft else runtime.history[total - wanted + 1]
|
|
459
|
+
field.CursorPosition = #field.Text + 1
|
|
460
|
+
showHint()
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
local function remember(line: string)
|
|
464
|
+
local history = runtime.history
|
|
465
|
+
-- A line repeated back to back is one entry. Pressing up should walk through
|
|
466
|
+
-- what was done, not through how many times it was retried.
|
|
467
|
+
if history[#history] ~= line then
|
|
468
|
+
table.insert(history, line)
|
|
469
|
+
end
|
|
470
|
+
while #history > MAX_HISTORY do
|
|
471
|
+
table.remove(history, 1)
|
|
472
|
+
end
|
|
473
|
+
runtime.offset = 0
|
|
474
|
+
runtime.draft = ""
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
--[[
|
|
478
|
+
Builds the menu's rows, one per command.
|
|
479
|
+
|
|
480
|
+
Sized from the full command list rather than from the seven that fit, so
|
|
481
|
+
filtering is a matter of hiding rows instead of rebuilding them. A
|
|
482
|
+
UIListLayout stacks whatever is left and `AutomaticCanvasSize` turns that
|
|
483
|
+
into a scrollable canvas, which is what makes the eighth command reachable.
|
|
484
|
+
]]
|
|
485
|
+
local function buildRows(scroll: ScrollingFrame, count: number)
|
|
486
|
+
local palette = Themes.palette()
|
|
487
|
+
|
|
488
|
+
local layout = Instance.new("UIListLayout")
|
|
489
|
+
layout.FillDirection = Enum.FillDirection.Vertical
|
|
490
|
+
layout.SortOrder = Enum.SortOrder.LayoutOrder
|
|
491
|
+
layout.Parent = scroll
|
|
492
|
+
|
|
493
|
+
for slot = 1, count do
|
|
494
|
+
--[[
|
|
495
|
+
A TextButton rather than a Frame with a button over it. The mouse is
|
|
496
|
+
the only way into this list, so the row IS the control, and one
|
|
497
|
+
instance answering for the hover and the click beats two that can
|
|
498
|
+
fall out of step.
|
|
499
|
+
]]
|
|
500
|
+
local button = Instance.new("TextButton")
|
|
501
|
+
button.Name = string.format("Row%d", slot)
|
|
502
|
+
button.Text = ""
|
|
503
|
+
button.AutoButtonColor = false
|
|
504
|
+
button.BackgroundColor3 = palette.surface
|
|
505
|
+
button.BackgroundTransparency = 1
|
|
506
|
+
button.BorderSizePixel = 0
|
|
507
|
+
button.Visible = false
|
|
508
|
+
button.Size = UDim2.new(1, -MENU_SCROLLBAR, 0, MENU_ROW_HEIGHT)
|
|
509
|
+
button.ZIndex = Z_MENU_ROW
|
|
510
|
+
button.Parent = scroll
|
|
511
|
+
|
|
512
|
+
local usage = Instance.new("TextLabel")
|
|
513
|
+
usage.Name = "Usage"
|
|
514
|
+
usage.Font = Enum.Font.Code
|
|
515
|
+
usage.TextSize = 12
|
|
516
|
+
usage.TextColor3 = palette.text
|
|
517
|
+
usage.TextXAlignment = Enum.TextXAlignment.Left
|
|
518
|
+
usage.TextTruncate = Enum.TextTruncate.AtEnd
|
|
519
|
+
usage.BackgroundTransparency = 1
|
|
520
|
+
usage.Position = UDim2.fromOffset(MENU_PAD, 0)
|
|
521
|
+
usage.Size = UDim2.new(0, MENU_SUMMARY_X - MENU_PAD * 2, 1, 0)
|
|
522
|
+
usage.ZIndex = Z_MENU_TEXT
|
|
523
|
+
usage.Parent = button
|
|
524
|
+
|
|
525
|
+
local summary = Instance.new("TextLabel")
|
|
526
|
+
summary.Name = "Summary"
|
|
527
|
+
summary.Font = Enum.Font.Code
|
|
528
|
+
summary.TextSize = 11
|
|
529
|
+
summary.TextColor3 = palette.dim
|
|
530
|
+
summary.TextXAlignment = Enum.TextXAlignment.Left
|
|
531
|
+
summary.TextTruncate = Enum.TextTruncate.AtEnd
|
|
532
|
+
summary.BackgroundTransparency = 1
|
|
533
|
+
summary.Position = UDim2.fromOffset(MENU_SUMMARY_X, 0)
|
|
534
|
+
summary.Size = UDim2.new(1, -(MENU_SUMMARY_X + MENU_PAD), 1, 0)
|
|
535
|
+
summary.ZIndex = Z_MENU_TEXT
|
|
536
|
+
summary.Parent = button
|
|
537
|
+
|
|
538
|
+
local record: MenuRow = {
|
|
539
|
+
button = button,
|
|
540
|
+
usage = usage,
|
|
541
|
+
summary = summary,
|
|
542
|
+
entry = "",
|
|
543
|
+
takesArgument = false,
|
|
544
|
+
}
|
|
545
|
+
table.insert(runtime.menuRows, record)
|
|
546
|
+
|
|
547
|
+
-- The highlight follows the pointer. With no keyboard to drive a
|
|
548
|
+
-- selection, hover IS the selection.
|
|
549
|
+
button.MouseEnter:Connect(function()
|
|
550
|
+
if runtime.hovered == slot then
|
|
551
|
+
return
|
|
552
|
+
end
|
|
553
|
+
runtime.hovered = slot
|
|
554
|
+
renderMenu()
|
|
555
|
+
end)
|
|
556
|
+
button.MouseLeave:Connect(function()
|
|
557
|
+
if runtime.hovered ~= slot then
|
|
558
|
+
return
|
|
559
|
+
end
|
|
560
|
+
runtime.hovered = 0
|
|
561
|
+
renderMenu()
|
|
562
|
+
end)
|
|
563
|
+
|
|
564
|
+
--[[
|
|
565
|
+
Fills the bar from what this row is showing, not from the match list,
|
|
566
|
+
which the focus loss that comes with the click may already have
|
|
567
|
+
emptied. The row knows its own command; that is enough.
|
|
568
|
+
]]
|
|
569
|
+
button.MouseButton1Click:Connect(function()
|
|
570
|
+
fillWith(record.entry, record.takesArgument)
|
|
571
|
+
Prompt.focus()
|
|
572
|
+
end)
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
--[[
|
|
577
|
+
Builds the row. Called once from `Console.mount`, positioned by it.
|
|
578
|
+
]]
|
|
579
|
+
function Prompt.mount(
|
|
580
|
+
parent: Instance,
|
|
581
|
+
handlers: {
|
|
582
|
+
submit: (string) -> (),
|
|
583
|
+
complete: (string) -> { string },
|
|
584
|
+
suggest: (string) -> { Suggestion },
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
local palette = Themes.palette()
|
|
588
|
+
runtime.submit = handlers.submit
|
|
589
|
+
runtime.complete = handlers.complete
|
|
590
|
+
runtime.suggest = handlers.suggest
|
|
591
|
+
|
|
592
|
+
local row = Instance.new("Frame")
|
|
593
|
+
row.Name = "Prompt"
|
|
594
|
+
row.BackgroundColor3 = palette.background
|
|
595
|
+
row.BorderSizePixel = 0
|
|
596
|
+
row.Parent = parent
|
|
597
|
+
runtime.row = row
|
|
598
|
+
|
|
599
|
+
local caret = Instance.new("TextLabel")
|
|
600
|
+
caret.Name = "Caret"
|
|
601
|
+
caret.Text = "\u{203A}"
|
|
602
|
+
caret.Font = Enum.Font.Code
|
|
603
|
+
caret.TextSize = 14
|
|
604
|
+
caret.TextColor3 = palette.violet
|
|
605
|
+
caret.TextXAlignment = Enum.TextXAlignment.Left
|
|
606
|
+
caret.BackgroundTransparency = 1
|
|
607
|
+
caret.Position = UDim2.fromOffset(INSET, 0)
|
|
608
|
+
caret.Size = UDim2.new(0, CARET_WIDTH, 1, 0)
|
|
609
|
+
caret.Parent = row
|
|
610
|
+
runtime.caret = caret
|
|
611
|
+
|
|
612
|
+
local left = INSET + CARET_WIDTH
|
|
613
|
+
|
|
614
|
+
--[[
|
|
615
|
+
The hint sits at the right end rather than immediately after the text.
|
|
616
|
+
|
|
617
|
+
Following the cursor means measuring the text every keystroke, and a
|
|
618
|
+
suggestion that jitters horizontally while you type is worse than one
|
|
619
|
+
parked somewhere predictable.
|
|
620
|
+
]]
|
|
621
|
+
local hint = Instance.new("TextLabel")
|
|
622
|
+
hint.Name = "Hint"
|
|
623
|
+
hint.Text = ""
|
|
624
|
+
hint.Font = Enum.Font.Code
|
|
625
|
+
hint.TextSize = 11
|
|
626
|
+
hint.TextColor3 = palette.dim
|
|
627
|
+
hint.TextXAlignment = Enum.TextXAlignment.Right
|
|
628
|
+
hint.TextTruncate = Enum.TextTruncate.AtEnd
|
|
629
|
+
hint.BackgroundTransparency = 1
|
|
630
|
+
hint.AnchorPoint = Vector2.new(1, 0)
|
|
631
|
+
hint.Position = UDim2.new(1, -INSET, 0, 0)
|
|
632
|
+
hint.Size = UDim2.new(0.5, 0, 1, 0)
|
|
633
|
+
hint.Parent = row
|
|
634
|
+
runtime.hint = hint
|
|
635
|
+
|
|
636
|
+
local field = Instance.new("TextBox")
|
|
637
|
+
field.Name = "Field"
|
|
638
|
+
field.Text = ""
|
|
639
|
+
field.PlaceholderText = placeholder()
|
|
640
|
+
field.PlaceholderColor3 = palette.dim
|
|
641
|
+
field.Font = Enum.Font.Code
|
|
642
|
+
field.TextSize = 12
|
|
643
|
+
field.TextColor3 = palette.text
|
|
644
|
+
field.TextXAlignment = Enum.TextXAlignment.Left
|
|
645
|
+
field.BackgroundTransparency = 1
|
|
646
|
+
field.BorderSizePixel = 0
|
|
647
|
+
-- Cleared on submit, not on focus: clicking away to read a line above and
|
|
648
|
+
-- clicking back must not cost what was typed.
|
|
649
|
+
field.ClearTextOnFocus = false
|
|
650
|
+
field.ClipsDescendants = true
|
|
651
|
+
field.Position = UDim2.fromOffset(left, 0)
|
|
652
|
+
field.Size = UDim2.new(1, -(left + INSET), 1, 0)
|
|
653
|
+
field.Parent = row
|
|
654
|
+
runtime.field = field
|
|
655
|
+
|
|
656
|
+
-- A hairline ABOVE the row, because the row sits at the foot of the panel:
|
|
657
|
+
-- the edge worth drawing is the one between the log and the input, and a
|
|
658
|
+
-- second line just above the status bar would only crowd it.
|
|
659
|
+
local rule = Instance.new("Frame")
|
|
660
|
+
rule.Name = "Rule"
|
|
661
|
+
rule.AnchorPoint = Vector2.new(0, 0)
|
|
662
|
+
rule.Position = UDim2.fromScale(0, 0)
|
|
663
|
+
rule.Size = UDim2.new(1, 0, 0, 1)
|
|
664
|
+
rule.BackgroundColor3 = palette.rule
|
|
665
|
+
rule.BackgroundTransparency = 0.4
|
|
666
|
+
rule.BorderSizePixel = 0
|
|
667
|
+
rule.Parent = row
|
|
668
|
+
runtime.rule = rule
|
|
669
|
+
|
|
670
|
+
--[[
|
|
671
|
+
The menu, parented to the row rather than to the panel.
|
|
672
|
+
|
|
673
|
+
Anchored to its own bottom edge with a negative Y offset, so it grows
|
|
674
|
+
upward out of a row that is already sitting on the status bar. The row
|
|
675
|
+
does not clip, so drawing outside it is allowed; the explicit ZIndex is
|
|
676
|
+
what puts it over the log, since a plugin widget draws with
|
|
677
|
+
ZIndexBehavior.Global and a child does not inherit its parent's depth.
|
|
678
|
+
]]
|
|
679
|
+
local menu = Instance.new("Frame")
|
|
680
|
+
menu.Name = "Menu"
|
|
681
|
+
menu.BackgroundColor3 = palette.background
|
|
682
|
+
menu.BorderSizePixel = 0
|
|
683
|
+
menu.Visible = false
|
|
684
|
+
menu.AnchorPoint = Vector2.new(0, 1)
|
|
685
|
+
menu.Position = UDim2.new(0, INSET, 0, -MENU_LIFT)
|
|
686
|
+
menu.Size = UDim2.new(1, -(INSET * 2), 0, 0)
|
|
687
|
+
menu.ZIndex = Z_MENU
|
|
688
|
+
menu.Parent = row
|
|
689
|
+
runtime.menu = menu
|
|
690
|
+
|
|
691
|
+
--[[
|
|
692
|
+
Whether the pointer is inside. Read by FocusLost, which must not take the
|
|
693
|
+
menu down while someone is in the middle of clicking a row.
|
|
694
|
+
]]
|
|
695
|
+
menu.MouseEnter:Connect(function()
|
|
696
|
+
runtime.hovering = true
|
|
697
|
+
end)
|
|
698
|
+
menu.MouseLeave:Connect(function()
|
|
699
|
+
runtime.hovering = false
|
|
700
|
+
-- Leaving the menu with the field no longer focused means the click that
|
|
701
|
+
-- was coming never came, so there is nothing left holding it open.
|
|
702
|
+
if not runtime.focused then
|
|
703
|
+
closeMenu()
|
|
704
|
+
end
|
|
705
|
+
end)
|
|
706
|
+
|
|
707
|
+
local menuStroke = Instance.new("UIStroke")
|
|
708
|
+
menuStroke.Color = palette.rule
|
|
709
|
+
menuStroke.Transparency = 0.4
|
|
710
|
+
menuStroke.Thickness = 1
|
|
711
|
+
menuStroke.Parent = menu
|
|
712
|
+
|
|
713
|
+
local menuCorner = Instance.new("UICorner")
|
|
714
|
+
menuCorner.CornerRadius = UDim.new(0, 4)
|
|
715
|
+
menuCorner.Parent = menu
|
|
716
|
+
|
|
717
|
+
--[[
|
|
718
|
+
The scrolling part. Seventeen commands do not fit above a 30px row and
|
|
719
|
+
there is no key that could page through them here, so the wheel is how
|
|
720
|
+
the rest is reached -- and the scrollbar is what says there IS a rest.
|
|
721
|
+
]]
|
|
722
|
+
local scroll = Instance.new("ScrollingFrame")
|
|
723
|
+
scroll.Name = "Rows"
|
|
724
|
+
scroll.BackgroundTransparency = 1
|
|
725
|
+
scroll.BorderSizePixel = 0
|
|
726
|
+
scroll.Position = UDim2.fromOffset(0, MENU_PAD)
|
|
727
|
+
scroll.Size = UDim2.new(1, 0, 0, MENU_MAX_ROWS * MENU_ROW_HEIGHT)
|
|
728
|
+
scroll.CanvasSize = UDim2.new()
|
|
729
|
+
scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
|
|
730
|
+
scroll.ScrollingDirection = Enum.ScrollingDirection.Y
|
|
731
|
+
scroll.ScrollBarThickness = MENU_SCROLLBAR
|
|
732
|
+
scroll.ScrollBarImageColor3 = palette.dim
|
|
733
|
+
scroll.ZIndex = Z_MENU_SCROLL
|
|
734
|
+
scroll.Parent = menu
|
|
735
|
+
runtime.menuScroll = scroll
|
|
736
|
+
|
|
737
|
+
-- One row per command, asked of the same function that will later filter
|
|
738
|
+
-- them, so the pool can never be smaller than the list it has to show.
|
|
739
|
+
runtime.menuRows = {}
|
|
740
|
+
buildRows(scroll, #handlers.suggest(""))
|
|
741
|
+
|
|
742
|
+
local caption = Instance.new("TextLabel")
|
|
743
|
+
caption.Name = "Caption"
|
|
744
|
+
caption.Font = Enum.Font.Code
|
|
745
|
+
caption.TextSize = 10
|
|
746
|
+
caption.TextColor3 = palette.dim
|
|
747
|
+
caption.TextXAlignment = Enum.TextXAlignment.Left
|
|
748
|
+
caption.TextTruncate = Enum.TextTruncate.AtEnd
|
|
749
|
+
caption.BackgroundTransparency = 1
|
|
750
|
+
caption.Position = UDim2.fromOffset(MENU_PAD, 0)
|
|
751
|
+
caption.Size = UDim2.new(1, -(MENU_PAD * 2), 0, MENU_CAPTION_HEIGHT)
|
|
752
|
+
caption.ZIndex = Z_MENU_TEXT
|
|
753
|
+
caption.Parent = menu
|
|
754
|
+
runtime.menuCaption = caption
|
|
755
|
+
|
|
756
|
+
field.Focused:Connect(function()
|
|
757
|
+
runtime.focused = true
|
|
758
|
+
refreshMenu()
|
|
759
|
+
end)
|
|
760
|
+
|
|
761
|
+
--[[
|
|
762
|
+
A click on the bar asks for the list, even when the bar already has the
|
|
763
|
+
cursor.
|
|
764
|
+
|
|
765
|
+
Belt and braces next to `Focused`, which is what normally opens the menu.
|
|
766
|
+
This covers the case where the field never lost focus in the first place,
|
|
767
|
+
and `Focused` therefore has nothing to fire about. Mouse input is the one
|
|
768
|
+
kind a plugin widget does deliver, which is why this can be read at all.
|
|
769
|
+
]]
|
|
770
|
+
field.InputBegan:Connect(function(input)
|
|
771
|
+
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
|
|
772
|
+
return
|
|
773
|
+
end
|
|
774
|
+
runtime.focused = true
|
|
775
|
+
refreshMenu()
|
|
776
|
+
end)
|
|
777
|
+
|
|
778
|
+
field:GetPropertyChangedSignal("Text"):Connect(function()
|
|
779
|
+
-- Typing puts the user back on the live line. Without this, editing a
|
|
780
|
+
-- recalled entry and pressing down would throw the edit away.
|
|
781
|
+
if runtime.offset ~= 0 then
|
|
782
|
+
runtime.offset = 0
|
|
783
|
+
runtime.draft = ""
|
|
784
|
+
end
|
|
785
|
+
--[[
|
|
786
|
+
Tab never belongs in this field.
|
|
787
|
+
|
|
788
|
+
Studio delivers no key events to a plugin widget, so Tab cannot be
|
|
789
|
+
intercepted -- but the TextBox still inserts one, and an invisible
|
|
790
|
+
character on the end of `help` stops it matching anything. There is
|
|
791
|
+
no refusing the keystroke, so it is taken back out here.
|
|
792
|
+
]]
|
|
793
|
+
if string.find(field.Text, "\t") ~= nil then
|
|
794
|
+
local cleaned = (string.gsub(field.Text, "\t", ""))
|
|
795
|
+
field.Text = cleaned
|
|
796
|
+
field.CursorPosition = #cleaned + 1
|
|
797
|
+
-- The assignment re-enters this handler with the clean text, which
|
|
798
|
+
-- does the rest of the work.
|
|
799
|
+
return
|
|
800
|
+
end
|
|
801
|
+
-- Refreshes the hint itself, so there is no second call here.
|
|
802
|
+
refreshMenu()
|
|
803
|
+
end)
|
|
804
|
+
|
|
805
|
+
field.FocusLost:Connect(function(enterPressed)
|
|
806
|
+
runtime.focused = false
|
|
807
|
+
|
|
808
|
+
--[[
|
|
809
|
+
The menu stays up while the pointer is on it.
|
|
810
|
+
|
|
811
|
+
Focus is released on mouse-DOWN and the click only completes on
|
|
812
|
+
mouse-up. Closing here regardless deleted the row being clicked in
|
|
813
|
+
between the two, which is why clicking a command used to do nothing.
|
|
814
|
+
]]
|
|
815
|
+
-- Enter always takes it down, hover or not: the line has been submitted,
|
|
816
|
+
-- so a list still standing over the reply is a leftover.
|
|
817
|
+
if enterPressed or not runtime.hovering then
|
|
818
|
+
closeMenu()
|
|
819
|
+
end
|
|
820
|
+
showHint()
|
|
821
|
+
|
|
822
|
+
if not enterPressed then
|
|
823
|
+
return
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
local line = string.match(field.Text, "^%s*(.-)%s*$") or ""
|
|
827
|
+
field.Text = ""
|
|
828
|
+
if runtime.hint then
|
|
829
|
+
(runtime.hint :: TextLabel).Text = ""
|
|
830
|
+
end
|
|
831
|
+
if line == "" then
|
|
832
|
+
return
|
|
833
|
+
end
|
|
834
|
+
remember(line)
|
|
835
|
+
local run = runtime.submit
|
|
836
|
+
if run ~= nil then
|
|
837
|
+
-- Spawned so a slow command -- one that waits on the bridge -- cannot
|
|
838
|
+
-- hold the field unresponsive while it runs.
|
|
839
|
+
task.spawn(run, line)
|
|
840
|
+
end
|
|
841
|
+
--[[
|
|
842
|
+
The cursor is NOT taken back.
|
|
843
|
+
|
|
844
|
+
It used to be, on the next frame, so a burst of commands could be
|
|
845
|
+
typed without reaching for the mouse. Two things were wrong with
|
|
846
|
+
that. The answer to the command arrives at the bottom of the log,
|
|
847
|
+
exactly where a re-opened menu would sit, so the panel covered the
|
|
848
|
+
thing it had just been asked for -- and suppressing the menu to avoid
|
|
849
|
+
that is what made clicking the bar afterwards do nothing, since a
|
|
850
|
+
field that never lost focus cannot be focused again.
|
|
851
|
+
|
|
852
|
+
Letting go costs one click before the next command and buys a log you
|
|
853
|
+
can actually read, and a bar that behaves the same way every time it
|
|
854
|
+
is clicked.
|
|
855
|
+
]]
|
|
856
|
+
end)
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
--[[
|
|
860
|
+
Puts the cursor in the field, for the toolbar button, for `help`, and for a
|
|
861
|
+
click on the menu.
|
|
862
|
+
|
|
863
|
+
Guarded: the widget can be closed while a command is still finishing, and
|
|
864
|
+
capturing focus on a field whose window is gone throws.
|
|
865
|
+
]]
|
|
866
|
+
function Prompt.focus()
|
|
867
|
+
local field = runtime.field
|
|
868
|
+
if field ~= nil and field.Parent ~= nil then
|
|
869
|
+
pcall(function()
|
|
870
|
+
field:CaptureFocus()
|
|
871
|
+
end)
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
--[[
|
|
876
|
+
Steps through the history, for whatever can reach a key.
|
|
877
|
+
|
|
878
|
+
Kept because the ring is still recorded and still worth having the moment
|
|
879
|
+
there is a way to drive it; nothing in a plugin widget can today. See the
|
|
880
|
+
note at the top of this file.
|
|
881
|
+
]]
|
|
882
|
+
function Prompt.step(direction: number)
|
|
883
|
+
recall(runtime.offset + direction)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
--[[
|
|
887
|
+
Reports that an agent is running, in the one place the user is looking.
|
|
888
|
+
|
|
889
|
+
The log says what the agent is doing and the band says how long it is
|
|
890
|
+
taking; this says only that the line they are about to type will queue behind
|
|
891
|
+
something. That is a fact about the input, so it belongs to the input.
|
|
892
|
+
]]
|
|
893
|
+
function Prompt.setBusy(busy: boolean)
|
|
894
|
+
runtime.busy = busy
|
|
895
|
+
paintCaret()
|
|
896
|
+
local field = runtime.field
|
|
897
|
+
if field ~= nil then
|
|
898
|
+
field.PlaceholderText = placeholder()
|
|
899
|
+
end
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
--[[
|
|
903
|
+
Reports what Studio has selected, as a phrase rather than a list.
|
|
904
|
+
|
|
905
|
+
Called from the selection watcher, which has already debounced and worded
|
|
906
|
+
it. This only decides whether it is on screen, which is `showHint`'s job
|
|
907
|
+
anyway -- so the value is stored and the one renderer is asked to run again.
|
|
908
|
+
]]
|
|
909
|
+
function Prompt.setSelection(text: string)
|
|
910
|
+
runtime.selection = text
|
|
911
|
+
showHint()
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
--[[
|
|
915
|
+
Whether an agent started from this prompt is still working.
|
|
916
|
+
|
|
917
|
+
Read by the console before it announces a silence. The flag lives here
|
|
918
|
+
because the caret is what displays it, and one owner beats two that have to
|
|
919
|
+
be kept in step.
|
|
920
|
+
]]
|
|
921
|
+
function Prompt.isBusy(): boolean
|
|
922
|
+
return runtime.busy
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
function Prompt.applyTheme()
|
|
926
|
+
local palette = Themes.palette()
|
|
927
|
+
local row = runtime.row
|
|
928
|
+
if row then
|
|
929
|
+
row.BackgroundColor3 = palette.background
|
|
930
|
+
end
|
|
931
|
+
local field = runtime.field
|
|
932
|
+
if field then
|
|
933
|
+
field.TextColor3 = palette.text
|
|
934
|
+
field.PlaceholderColor3 = palette.dim
|
|
935
|
+
end
|
|
936
|
+
local hint = runtime.hint
|
|
937
|
+
if hint then
|
|
938
|
+
hint.TextColor3 = palette.dim
|
|
939
|
+
end
|
|
940
|
+
local rule = runtime.rule
|
|
941
|
+
if rule then
|
|
942
|
+
rule.BackgroundColor3 = palette.rule
|
|
943
|
+
end
|
|
944
|
+
local menu = runtime.menu
|
|
945
|
+
if menu then
|
|
946
|
+
menu.BackgroundColor3 = palette.background
|
|
947
|
+
local stroke = menu:FindFirstChildOfClass("UIStroke")
|
|
948
|
+
if stroke then
|
|
949
|
+
stroke.Color = palette.rule
|
|
950
|
+
end
|
|
951
|
+
end
|
|
952
|
+
-- Repainted through the renderer rather than field by field: the hover
|
|
953
|
+
-- decides half of these colours, and only one function knows which row has
|
|
954
|
+
-- it.
|
|
955
|
+
if isMenuOpen() then
|
|
956
|
+
renderMenu()
|
|
957
|
+
end
|
|
958
|
+
paintCaret()
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
return Prompt
|