@rbxts/centurion-ui 1.0.8 → 2.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +60 -15
  2. package/centurion.luau +10 -0
  3. package/package.json +13 -23
  4. package/src/analyse/cursor.luau +25 -0
  5. package/src/analyse/highlight.luau +91 -0
  6. package/src/analyse/init.luau +318 -0
  7. package/src/analyse/leventine.luau +298 -0
  8. package/src/analyse/match.luau +39 -0
  9. package/src/analyse/suggest.luau +41 -0
  10. package/src/app/header.luau +128 -0
  11. package/src/app/history.luau +258 -0
  12. package/src/app/init.luau +699 -0
  13. package/src/app/suggestions.luau +419 -0
  14. package/src/button.luau +13 -0
  15. package/src/components/background.luau +37 -0
  16. package/src/components/container.luau +33 -0
  17. package/src/components/corner.luau +11 -0
  18. package/src/components/divider.luau +28 -0
  19. package/src/components/flex.luau +108 -0
  20. package/src/components/gap.luau +25 -0
  21. package/src/components/image.luau +33 -0
  22. package/src/components/padding.luau +59 -0
  23. package/src/components/stroke.luau +20 -0
  24. package/src/components/text.luau +57 -0
  25. package/src/components/textbox.luau +186 -0
  26. package/src/fade.luau +23 -0
  27. package/src/init.luau +142 -0
  28. package/src/input.luau +16 -0
  29. package/src/px.luau +68 -0
  30. package/src/rich.luau +14 -0
  31. package/src/state.luau +33 -0
  32. package/src/theme.luau +70 -0
  33. package/src/ts/index.d.ts +60 -0
  34. package/src/ts/init.luau +5 -0
  35. package/src/types.luau +85 -0
  36. package/vide/init.luau +8 -0
  37. package/vide/src/action.luau +25 -0
  38. package/vide/src/apply.luau +162 -0
  39. package/vide/src/batch.luau +23 -0
  40. package/vide/src/branch.luau +40 -0
  41. package/vide/src/changed.luau +18 -0
  42. package/vide/src/cleanup.luau +43 -0
  43. package/vide/src/context.luau +74 -0
  44. package/vide/src/create.luau +107 -0
  45. package/vide/src/defaults.luau +113 -0
  46. package/vide/src/derive.luau +18 -0
  47. package/vide/src/effect.luau +12 -0
  48. package/vide/src/flags.luau +12 -0
  49. package/vide/src/graph.luau +321 -0
  50. package/vide/src/implicit_effect.luau +125 -0
  51. package/vide/src/indexes.luau +121 -0
  52. package/vide/src/init.luau +10 -0
  53. package/vide/src/lib.luau +120 -0
  54. package/vide/src/mount.luau +11 -0
  55. package/vide/src/read.luau +5 -0
  56. package/vide/src/root.luau +35 -0
  57. package/vide/src/show.luau +36 -0
  58. package/vide/src/source.luau +31 -0
  59. package/vide/src/spring.luau +328 -0
  60. package/vide/src/switch.luau +114 -0
  61. package/vide/src/timeout.luau +27 -0
  62. package/vide/src/untrack.luau +25 -0
  63. package/vide/src/values.luau +131 -0
  64. package/out/app/centurion-app.d.ts +0 -3
  65. package/out/app/centurion-app.luau +0 -86
  66. package/out/app/index.d.ts +0 -32
  67. package/out/app/init.luau +0 -105
  68. package/out/components/history/history-line.d.ts +0 -9
  69. package/out/components/history/history-line.luau +0 -88
  70. package/out/components/history/history-list.d.ts +0 -11
  71. package/out/components/history/history-list.luau +0 -67
  72. package/out/components/history/index.d.ts +0 -1
  73. package/out/components/history/init.luau +0 -7
  74. package/out/components/index.d.ts +0 -2
  75. package/out/components/init.luau +0 -10
  76. package/out/components/suggestions/badge.d.ts +0 -16
  77. package/out/components/suggestions/badge.luau +0 -40
  78. package/out/components/suggestions/index.d.ts +0 -1
  79. package/out/components/suggestions/init.luau +0 -7
  80. package/out/components/suggestions/main-suggestion.d.ts +0 -9
  81. package/out/components/suggestions/main-suggestion.luau +0 -232
  82. package/out/components/suggestions/suggestion-list.d.ts +0 -8
  83. package/out/components/suggestions/suggestion-list.luau +0 -96
  84. package/out/components/suggestions/suggestions.d.ts +0 -2
  85. package/out/components/suggestions/suggestions.luau +0 -64
  86. package/out/components/suggestions/types.d.ts +0 -6
  87. package/out/components/suggestions/types.luau +0 -2
  88. package/out/components/suggestions/util.d.ts +0 -1
  89. package/out/components/suggestions/util.luau +0 -22
  90. package/out/components/terminal/command.d.ts +0 -6
  91. package/out/components/terminal/command.luau +0 -74
  92. package/out/components/terminal/index.d.ts +0 -1
  93. package/out/components/terminal/init.luau +0 -7
  94. package/out/components/terminal/suggestion.d.ts +0 -26
  95. package/out/components/terminal/suggestion.luau +0 -205
  96. package/out/components/terminal/terminal-text-field/index.d.ts +0 -1
  97. package/out/components/terminal/terminal-text-field/init.luau +0 -7
  98. package/out/components/terminal/terminal-text-field/suggestion.d.ts +0 -5
  99. package/out/components/terminal/terminal-text-field/suggestion.luau +0 -124
  100. package/out/components/terminal/terminal-text-field/terminal-text-field.d.ts +0 -13
  101. package/out/components/terminal/terminal-text-field/terminal-text-field.luau +0 -278
  102. package/out/components/terminal/terminal.d.ts +0 -2
  103. package/out/components/terminal/terminal.luau +0 -304
  104. package/out/components/ui/frame.d.ts +0 -17
  105. package/out/components/ui/frame.luau +0 -32
  106. package/out/components/ui/group.d.ts +0 -15
  107. package/out/components/ui/group.luau +0 -29
  108. package/out/components/ui/layer.d.ts +0 -8
  109. package/out/components/ui/layer.luau +0 -26
  110. package/out/components/ui/outline.d.ts +0 -15
  111. package/out/components/ui/outline.luau +0 -116
  112. package/out/components/ui/padding.d.ts +0 -10
  113. package/out/components/ui/padding.luau +0 -19
  114. package/out/components/ui/scrolling-frame.d.ts +0 -14
  115. package/out/components/ui/scrolling-frame.luau +0 -44
  116. package/out/components/ui/text-field.d.ts +0 -12
  117. package/out/components/ui/text-field.luau +0 -60
  118. package/out/components/ui/text.d.ts +0 -17
  119. package/out/components/ui/text.luau +0 -61
  120. package/out/constants/options.d.ts +0 -2
  121. package/out/constants/options.luau +0 -27
  122. package/out/constants/text.d.ts +0 -3
  123. package/out/constants/text.luau +0 -9
  124. package/out/constants/util.d.ts +0 -1
  125. package/out/constants/util.luau +0 -7
  126. package/out/hooks/use-client.d.ts +0 -2
  127. package/out/hooks/use-client.luau +0 -17
  128. package/out/hooks/use-event.d.ts +0 -14
  129. package/out/hooks/use-event.luau +0 -58
  130. package/out/hooks/use-px.d.ts +0 -19
  131. package/out/hooks/use-px.luau +0 -78
  132. package/out/index.d.ts +0 -2
  133. package/out/init.luau +0 -10
  134. package/out/palette.d.ts +0 -47
  135. package/out/palette.luau +0 -42
  136. package/out/store.d.ts +0 -12
  137. package/out/store.luau +0 -26
  138. package/out/types.d.ts +0 -44
  139. package/out/types.luau +0 -2
  140. package/out/utils/string.d.ts +0 -5
  141. package/out/utils/string.luau +0 -25
@@ -0,0 +1,699 @@
1
+ local ContextActionService = game:GetService("ContextActionService")
2
+ local Players = game:GetService("Players")
3
+ local RunService = game:GetService("RunService")
4
+ local UserInputService = game:GetService("UserInputService")
5
+
6
+ local analyse = require("./analyse")
7
+ local background = require("./components/background")
8
+ local centurion = require("../centurion")
9
+ local container = require("./components/container")
10
+ local corner = require("./components/corner")
11
+ local divider = require("./components/divider")
12
+ local fade = require("./fade")
13
+ local flex = require("./components/flex")
14
+ local header = require("@self/header")
15
+ local highlight = require("./analyse/highlight")
16
+ local history = require("@self/history")
17
+ local padding = require("./components/padding")
18
+ local preferred_input = require("./input")
19
+ local px = require("./px")
20
+ local state = require("./state")
21
+ local stroke = require("./components/stroke")
22
+ local suggestions = require("@self/suggestions")
23
+ local text = require("./components/text")
24
+ local textbox = require("./components/textbox")
25
+ local theme = require("./theme")
26
+ local types = require("./types")
27
+ local vide = require("../vide")
28
+
29
+ local action = vide.action
30
+ local cleanup = vide.cleanup
31
+ local changed = vide.changed
32
+ local derive = vide.derive
33
+ local effect = vide.effect
34
+ local source = vide.source
35
+ local spring = vide.spring
36
+ local untrack = vide.untrack
37
+
38
+ local default_analysis = {
39
+ result = {
40
+ success = true,
41
+ value = nil,
42
+ },
43
+ suggestions = {},
44
+ } :: types.AnalysisResult
45
+ local max_history_lines = 10
46
+ local spinner_frames = { "|", "/", "-", "\\" }
47
+
48
+ local function app()
49
+ local raw_text = source("")
50
+ local cursor_pos = source(0)
51
+ local analysis = source(default_analysis)
52
+
53
+ -- same as analysis, but isn't reset when submitting (used for animations)
54
+ local display_analysis = source(default_analysis)
55
+
56
+ -- commands waiting on a response
57
+ local executing = source(0)
58
+ local executing_command = source("")
59
+ local busy = source(false)
60
+ local spinner_frame = source(1)
61
+
62
+ local selected_suggestion = source(1)
63
+ -- set once the textbox has mounted
64
+ local open_keyboard: () -> () = function() end
65
+ local over_interface = source(false)
66
+
67
+ -- touch devices draw a focused textbox with the native text field, which
68
+ -- ignores TextTransparency and sits at a different offset, so the
69
+ -- highlighted and suggested text overlays can't line up with it
70
+ local preferred = preferred_input()
71
+ local touch = derive(function()
72
+ return preferred() == Enum.PreferredInput.Touch
73
+ end)
74
+ local gamepad = derive(function()
75
+ return preferred() == Enum.PreferredInput.Gamepad
76
+ end)
77
+ local keyboard_visible = source(UserInputService.OnScreenKeyboardVisible)
78
+ cleanup(UserInputService:GetPropertyChangedSignal("OnScreenKeyboardVisible"):Connect(function()
79
+ keyboard_visible(UserInputService.OnScreenKeyboardVisible)
80
+ end))
81
+
82
+ local next_cursor_pos = false
83
+ local refocus_deadline = 0
84
+ local cmd_history = {}
85
+ local cmd_history_index = 0
86
+
87
+ -- shakes the input and flashes its outline when a command fails
88
+ local shake, shake_settings = spring(function()
89
+ return 0
90
+ end, 0.12, 0.25)
91
+ local failed = source(false)
92
+ local failures = 0
93
+
94
+ local function signal_failure()
95
+ failures += 1
96
+ local id = failures
97
+
98
+ shake_settings({ impulse = 300 })
99
+ failed(true)
100
+ task.delay(0.4, function()
101
+ if failures == id then
102
+ failed(false)
103
+ end
104
+ end)
105
+ end
106
+
107
+ local function catch_hover()
108
+ return action(function(instance: Frame)
109
+ cleanup(instance.MouseEnter:Connect(function()
110
+ over_interface(true)
111
+ end))
112
+ cleanup(instance.MouseLeave:Connect(function()
113
+ over_interface(false)
114
+ end))
115
+ end)
116
+ end
117
+
118
+ local function log(command: string, response: centurion.Response)
119
+ local logs = state.logs()
120
+ table.insert(logs, 1, {
121
+ command = command,
122
+ response = response,
123
+ })
124
+ table.remove(logs, 100)
125
+ state.logs(logs)
126
+
127
+ if not response.success then
128
+ signal_failure()
129
+ end
130
+ end
131
+
132
+ -- whether a space should follow the completed suggestion, so that the next
133
+ -- token can be typed straight away
134
+ local function needs_space(suggestion: types.AnalysisSuggestion): boolean
135
+ if suggestion.kind == "command" then
136
+ local cmd = centurion.registry.commands[suggestion.name]
137
+ if cmd and cmd.arguments and select("#", cmd.arguments()) > 0 then
138
+ return true
139
+ end
140
+
141
+ -- subcommands still have to be typed after it
142
+ local prefix = suggestion.name .. " "
143
+ for name in centurion.registry.commands do
144
+ if name:sub(1, #prefix) == prefix then
145
+ return true
146
+ end
147
+ end
148
+ return false
149
+ end
150
+
151
+ local arg = analysis().argument
152
+ if arg ~= nil then
153
+ local cmd = centurion.registry.commands[analysis().command]
154
+ local args = { cmd.arguments() }
155
+ if arg.index == #args or arg.kind == "list" then
156
+ return false
157
+ end
158
+ end
159
+ return true
160
+ end
161
+
162
+ local function autofill(value: string, suggestion: types.AnalysisSuggestion, update_cursor_pos: boolean)
163
+ local token, with = suggestion.span, suggestion.replace
164
+ local span_start = token.x - 1
165
+ local before = string.sub(value, 1, span_start)
166
+ local after = string.sub(value, token.y + 1, -1)
167
+ local space = if needs_space(suggestion) then " " else ""
168
+
169
+ if update_cursor_pos then
170
+ -- moving the cursor shows it on consoles even with the keyboard
171
+ -- closed, and suggestions there are always for the end of the input
172
+ if not gamepad() then
173
+ task.defer(cursor_pos, span_start + #with + #space + 1)
174
+ end
175
+ selected_suggestion(1)
176
+ end
177
+
178
+ return before .. with .. space .. after
179
+ end
180
+
181
+ -- tapping a history entry or suggestion releases the textbox, but on touch
182
+ -- devices FocusLost can fire after the button activates, which would undo
183
+ -- an immediate refocus. so also recapture focus if it's lost shortly after
184
+ local function refocus()
185
+ refocus_deadline = os.clock() + 0.5
186
+ state.focused(true)
187
+ end
188
+
189
+ local activation_keys = derive(function()
190
+ local keys = {}
191
+ for _, key in ipairs(state.activation_keys()) do
192
+ keys[key] = true
193
+ end
194
+ return keys
195
+ end)
196
+
197
+ local function handle_navigation(up: boolean)
198
+ local suggested = analysis().suggestions
199
+
200
+ if
201
+ #suggested == 0
202
+ or UserInputService:IsKeyDown(Enum.KeyCode.LeftShift)
203
+ or (selected_suggestion() == 1 and up and #cmd_history > 0)
204
+ then
205
+ cmd_history_index = math.clamp(cmd_history_index + (up and 1 or -1), 0, #cmd_history)
206
+ raw_text(cmd_history[cmd_history_index] or "")
207
+ cursor_pos(#raw_text() + 1)
208
+
209
+ return
210
+ end
211
+
212
+ selected_suggestion(math.clamp(selected_suggestion() + (up and -1 or 1), 1, #suggested))
213
+ end
214
+
215
+ effect(function()
216
+ if raw_text() == "" then
217
+ return
218
+ end
219
+ display_analysis(analysis())
220
+ end)
221
+
222
+ cleanup(UserInputService.InputBegan:Connect(function(input)
223
+ if input.UserInputState ~= Enum.UserInputState.Begin then
224
+ return
225
+ end
226
+
227
+ if
228
+ state.hide_on_lost_focus()
229
+ and not over_interface()
230
+ and (
231
+ input.UserInputType == Enum.UserInputType.MouseButton1
232
+ or input.UserInputType == Enum.UserInputType.Touch
233
+ )
234
+ then
235
+ state.opened(false)
236
+ return
237
+ end
238
+
239
+ if state.opened() then
240
+ if input.KeyCode == Enum.KeyCode.Up then
241
+ handle_navigation(true)
242
+ elseif input.KeyCode == Enum.KeyCode.Down then
243
+ handle_navigation(false)
244
+ end
245
+ end
246
+
247
+ if not activation_keys()[input.KeyCode] then
248
+ return
249
+ end
250
+
251
+ local opened = not state.opened()
252
+ state.opened(opened)
253
+ state.focused(opened)
254
+ end))
255
+
256
+ -- refresh suggestions for leftover input whenever the console opens, which
257
+ -- can also happen through the public open/toggle functions
258
+ effect(function()
259
+ if not state.opened() then
260
+ return
261
+ end
262
+
263
+ untrack(function()
264
+ local input_text = raw_text()
265
+ if #input_text > 0 then
266
+ analysis(analyse(input_text, #input_text + 1, state.max_suggestions()))
267
+ end
268
+ end)
269
+ end)
270
+
271
+ effect(function()
272
+ if executing() == 0 then
273
+ busy(false)
274
+ return
275
+ end
276
+
277
+ local thread = task.delay(0.1, busy, true)
278
+ cleanup(function()
279
+ task.cancel(thread)
280
+ end)
281
+ end)
282
+
283
+ effect(function()
284
+ if not busy() then
285
+ return
286
+ end
287
+
288
+ cleanup(RunService.Heartbeat:Connect(function()
289
+ spinner_frame(math.floor(os.clock() * 10) % #spinner_frames + 1)
290
+ end))
291
+ end)
292
+
293
+ local analysing = false
294
+ effect(function()
295
+ raw_text()
296
+ cursor_pos()
297
+
298
+ if analysing then
299
+ return
300
+ end
301
+
302
+ analysing = true
303
+ task.defer(function()
304
+ analysing = false
305
+
306
+ -- the textbox reports -1 while unfocused, which is the only time
307
+ -- consoles see the input, since their keyboard covers the screen
308
+ local pos = cursor_pos()
309
+ if pos == -1 then
310
+ pos = #raw_text() + 1
311
+ end
312
+
313
+ analysis(analyse(raw_text(), pos, state.max_suggestions()))
314
+ end)
315
+ end)
316
+
317
+ local function submit(value: string)
318
+ -- resolve from the submitted text rather than the analysis, which only
319
+ -- carries arguments when the cursor happens to sit on one
320
+ local submitted = centurion.resolve_command(value)
321
+ raw_text("")
322
+ analysis(default_analysis)
323
+
324
+ if #value:gsub("%s", "") == 0 then
325
+ state.opened(false)
326
+ return
327
+ end
328
+
329
+ -- on consoles the keyboard would cover the result, so leave it closed
330
+ if not gamepad() then
331
+ task.delay(0.1, function()
332
+ state.focused(true)
333
+ end)
334
+ end
335
+
336
+ cmd_history_index = 0
337
+ table.insert(cmd_history, 1, value)
338
+
339
+ local cmd = submitted.name
340
+ if not cmd then
341
+ log(value, {
342
+ success = false,
343
+ message = "Unknown command",
344
+ timestamp = os.time(),
345
+ })
346
+ return
347
+ end
348
+
349
+ executing_command(cmd)
350
+ executing(executing() + 1)
351
+
352
+ -- pass the submitted text so that ctx.input keeps its original quoting
353
+ local ok, result = pcall(centurion.execute_command, Players.LocalPlayer, cmd, submitted.args, value)
354
+ executing(executing() - 1)
355
+
356
+ if not ok then
357
+ warn(`Failed to execute '{value}': {result}`)
358
+ log(value, {
359
+ success = false,
360
+ message = "Failed to execute command",
361
+ timestamp = os.time(),
362
+ })
363
+ return
364
+ end
365
+
366
+ if result.response then
367
+ if result.response.message ~= "" then
368
+ log(value, result.response)
369
+ end
370
+ else
371
+ log(value, {
372
+ success = true,
373
+ message = "Command executed",
374
+ timestamp = os.time(),
375
+ })
376
+ end
377
+ end
378
+
379
+ -- consoles can only browse suggestions while their keyboard is closed, so
380
+ -- bind the gamepad to them until the textbox is focused again. this is
381
+ -- also the only way to run a command, since confirming the keyboard doesn't
382
+ effect(function()
383
+ -- dismissing the keyboard can leave the textbox focused, so only treat it
384
+ -- as busy while the keyboard is actually showing
385
+ if not state.opened() or not gamepad() or (state.focused() and keyboard_visible()) then
386
+ return
387
+ end
388
+
389
+ ContextActionService:BindActionAtPriority(
390
+ "centurion_gamepad",
391
+ function(_, input_state, input)
392
+ if input_state ~= Enum.UserInputState.Begin then
393
+ return Enum.ContextActionResult.Sink
394
+ end
395
+
396
+ if input.KeyCode == Enum.KeyCode.DPadUp then
397
+ handle_navigation(true)
398
+ elseif input.KeyCode == Enum.KeyCode.DPadDown then
399
+ handle_navigation(false)
400
+ elseif input.KeyCode == Enum.KeyCode.ButtonA then
401
+ local selected = analysis().suggestions[selected_suggestion()]
402
+ if selected then
403
+ raw_text(autofill(raw_text(), selected, true))
404
+ end
405
+ elseif input.KeyCode == Enum.KeyCode.ButtonX then
406
+ open_keyboard()
407
+ elseif input.KeyCode == Enum.KeyCode.ButtonY then
408
+ submit(raw_text())
409
+ elseif input.KeyCode == Enum.KeyCode.ButtonB then
410
+ state.opened(false)
411
+ end
412
+
413
+ return Enum.ContextActionResult.Sink
414
+ end,
415
+ false,
416
+ Enum.ContextActionPriority.High.Value,
417
+ Enum.KeyCode.DPadUp,
418
+ Enum.KeyCode.DPadDown,
419
+ Enum.KeyCode.ButtonA,
420
+ Enum.KeyCode.ButtonX,
421
+ Enum.KeyCode.ButtonY,
422
+ Enum.KeyCode.ButtonB
423
+ )
424
+
425
+ cleanup(function()
426
+ ContextActionService:UnbindAction("centurion_gamepad")
427
+ end)
428
+ end)
429
+
430
+ -- 0 when closed, 1 when open. The console fades and slides with it, and
431
+ -- stays visible until it has finished closing
432
+ local reveal = spring(function()
433
+ return if state.opened() then 1 else 0
434
+ end, 0.15)
435
+
436
+ -- the popover only shows when there's something to say about the input
437
+ local has_popover = derive(function()
438
+ local value = analysis()
439
+ return not value.result.success or value.argument ~= nil or #value.suggestions > 0
440
+ end)
441
+ local pop = spring(function()
442
+ return if has_popover() then 1 else 0
443
+ end, 0.12)
444
+
445
+ -- created inside the console so that it fades with it
446
+ local function input_field()
447
+ return background {
448
+ auto = "XY",
449
+ size = UDim2.fromScale(1, 0),
450
+ color = function()
451
+ return state.theme().surface
452
+ end,
453
+ transparency = state.transparency,
454
+
455
+ corner(px(6)),
456
+ padding {
457
+ y = px(8),
458
+ left = px(function()
459
+ return 34 + shake()
460
+ end),
461
+ right = px(function()
462
+ return 10 - shake()
463
+ end),
464
+ },
465
+ stroke {
466
+ color = spring(function()
467
+ local current = state.theme()
468
+ if failed() or not analysis().result.success then
469
+ return current.error
470
+ elseif busy() or state.focused() then
471
+ return current.highlight
472
+ end
473
+ return theme.border(current)
474
+ end, 0.15),
475
+ },
476
+
477
+ text {
478
+ text = function()
479
+ return if busy() then spinner_frames[spinner_frame()] else ">"
480
+ end,
481
+ text_style = function()
482
+ return (busy() or analysis().result.success) and "highlight" or "error"
483
+ end,
484
+ weight = Enum.FontWeight.Bold,
485
+ auto = "XY",
486
+
487
+ {
488
+ AnchorPoint = Vector2.new(0, 0.5),
489
+ Position = px(UDim2.new(0, -22, 0.5, 0)),
490
+ },
491
+ },
492
+
493
+ -- the rest of the selected suggestion, shown after the input
494
+ text {
495
+ size = UDim2.fromScale(1, 1),
496
+ text = function()
497
+ local analysis_suggestions = analysis().suggestions
498
+ if touch() or #analysis_suggestions == 0 then
499
+ return ""
500
+ end
501
+
502
+ local selected = analysis_suggestions[selected_suggestion()]
503
+ local suggested = autofill(raw_text(), selected, false)
504
+ if suggested:sub(1, #raw_text()) ~= raw_text() then
505
+ return ""
506
+ end
507
+ return suggested
508
+ end,
509
+ text_style = "subtext",
510
+ transparency = 0.3,
511
+ xalignment = Enum.TextXAlignment.Left,
512
+ zindex = -1,
513
+ },
514
+
515
+ -- syntax highlighted copy of the input, drawn over the textbox
516
+ text {
517
+ size = UDim2.fromScale(1, 1),
518
+ text = function()
519
+ return if touch() then "" else highlight(raw_text(), state.theme())
520
+ end,
521
+ rich_text = true,
522
+ xalignment = Enum.TextXAlignment.Left,
523
+ zindex = 2,
524
+
525
+ { Interactable = false },
526
+ },
527
+
528
+ textbox {
529
+ placeholder = function()
530
+ return if busy() then `Running {executing_command()}...` else "Type a command"
531
+ end,
532
+ xalignment = Enum.TextXAlignment.Left,
533
+ -- the highlighted overlay draws the text, but hiding it completely would also hide the cursor
534
+ transparency = function()
535
+ return if raw_text() == "" or touch() then 0 else 0.5
536
+ end,
537
+
538
+ text = raw_text,
539
+ update_text = function(value)
540
+ local pos = next_cursor_pos or cursor_pos()
541
+ local last_char = string.sub(value, pos - 1, pos - 1)
542
+
543
+ local suggested = analysis().suggestions
544
+ if last_char == "\t" and suggested[1] then
545
+ local selected = suggested[selected_suggestion()]
546
+ value = autofill(raw_text(), selected, true)
547
+ end
548
+ raw_text(value)
549
+ end,
550
+
551
+ focused = state.focused,
552
+ update_focused = function(focused)
553
+ if focused then
554
+ refocus_deadline = 0
555
+ elseif os.clock() < refocus_deadline then
556
+ -- the tap that selected an entry released the textbox, so take it back
557
+ refocus_deadline = 0
558
+ task.defer(state.focused, true)
559
+ end
560
+ state.focused(focused)
561
+ end,
562
+
563
+ enter = submit,
564
+ -- the console keyboard only writes its text back when confirmed, so
565
+ -- confirming has to show suggestions instead of running the command
566
+ submit_on_keyboard_return = false,
567
+ bind_open_keyboard = function(open)
568
+ open_keyboard = open
569
+ end,
570
+
571
+ {
572
+ AutomaticSize = Enum.AutomaticSize.None,
573
+ Size = UDim2.fromScale(1, 1),
574
+ CursorPosition = cursor_pos,
575
+
576
+ changed("CursorPosition", function(new)
577
+ if next_cursor_pos then
578
+ next_cursor_pos = new
579
+ return
580
+ end
581
+
582
+ next_cursor_pos = new
583
+ task.defer(function()
584
+ cursor_pos(next_cursor_pos)
585
+ next_cursor_pos = false
586
+ end)
587
+ end),
588
+ },
589
+ },
590
+ }
591
+ end
592
+
593
+ return fade.opacity(reveal, function()
594
+ return container {
595
+ auto = "Y",
596
+ anchor = state.anchor,
597
+ position = function()
598
+ local offset = px.convert((reveal() - 1) * 12)
599
+ return px.get(state.position) + UDim2.fromOffset(0, offset)
600
+ end,
601
+ size = px(function()
602
+ local w = state.width()
603
+ return UDim2.new(w.Scale, w.Offset, 0, 0)
604
+ end),
605
+
606
+ padding(px(12)),
607
+ flex():row():gap(2),
608
+
609
+ {
610
+ Visible = function()
611
+ return state.opened() or reveal() > 0.01
612
+ end,
613
+ },
614
+
615
+ -- the console itself: header, history and input
616
+ background {
617
+ auto = "Y",
618
+ size = UDim2.fromScale(1, 0),
619
+ order = 1,
620
+ color = function()
621
+ return state.theme().background
622
+ end,
623
+ transparency = state.transparency,
624
+
625
+ corner(px(10)),
626
+ stroke {
627
+ color = function()
628
+ return theme.border(state.theme())
629
+ end,
630
+ },
631
+ flex():row():fill("horizontal"),
632
+ catch_hover(),
633
+
634
+ header { order = 1 },
635
+ divider { order = 2 },
636
+
637
+ history {
638
+ order = 3,
639
+ max_lines = max_history_lines,
640
+ on_select = function(command)
641
+ cmd_history_index = 0
642
+ raw_text(command)
643
+ refocus()
644
+ -- after the textbox has recaptured focus, move the cursor to the end
645
+ task.defer(cursor_pos, #command + 1)
646
+ end,
647
+ },
648
+
649
+ container {
650
+ auto = "Y",
651
+ size = UDim2.fromScale(1, 0),
652
+ order = 4,
653
+
654
+ padding(px(8)),
655
+
656
+ input_field(),
657
+ },
658
+ },
659
+
660
+ -- completion popover, dropping down from the input
661
+ fade.opacity(function()
662
+ return reveal() * pop()
663
+ end, function()
664
+ return container {
665
+ auto = "XY",
666
+ order = 2,
667
+
668
+ padding {
669
+ left = px(8),
670
+ top = px(function()
671
+ return 6 * pop()
672
+ end),
673
+ },
674
+
675
+ {
676
+ Visible = function()
677
+ return pop() > 0.01
678
+ end,
679
+ },
680
+
681
+ suggestions {
682
+ analysis = display_analysis,
683
+ selected_suggestion = selected_suggestion,
684
+ on_select = function(suggestion: types.AnalysisSuggestion)
685
+ raw_text(autofill(raw_text(), suggestion, true))
686
+ if not gamepad() then
687
+ refocus()
688
+ end
689
+ end,
690
+
691
+ catch_hover(),
692
+ },
693
+ }
694
+ end),
695
+ }
696
+ end)
697
+ end
698
+
699
+ return app