-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathlimbo_console.gd
More file actions
1119 lines (911 loc) · 38 KB
/
limbo_console.gd
File metadata and controls
1119 lines (911 loc) · 38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
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
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
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
extends CanvasLayer
## LimboConsole
signal toggled(is_shown)
const THEME_DEFAULT := "res://addons/limbo_console/res/default_theme.tres"
const AsciiArt := preload("res://addons/limbo_console/ascii_art.gd")
const BuiltinCommands := preload("res://addons/limbo_console/builtin_commands.gd")
const CommandEntry := preload("res://addons/limbo_console/command_entry.gd")
const ConfigMapper := preload("res://addons/limbo_console/config_mapper.gd")
const ConsoleOptions := preload("res://addons/limbo_console/console_options.gd")
const Util := preload("res://addons/limbo_console/util.gd")
const CommandHistory := preload("res://addons/limbo_console/command_history.gd")
const HistoryGui := preload("res://addons/limbo_console/history_gui.gd")
const MAX_SUBCOMMANDS: int = 4
## If false, prevents console from being shown. Commands can still be executed from code.
var enabled: bool = true:
set(value):
enabled = value
set_process_input(enabled)
if not enabled and _control.visible:
_is_open = false
set_process(false)
_hide_console()
var _control: Control
var _history_gui: HistoryGui
var _control_block: Control
var _output: RichTextLabel
var _entry: CommandEntry
var _previous_gui_focus: Control
# Theme colors
var _output_command_color: Color
var _output_command_mention_color: Color
var _output_error_color: Color
var _output_warning_color: Color
var _output_text_color: Color
var _output_debug_color: Color
var _entry_text_color: Color
var _entry_hint_color: Color
var _entry_command_found_color: Color
var _entry_subcommand_color: Color
var _entry_command_not_found_color: Color
var _options: ConsoleOptions
var _commands: Dictionary # "command" => Callable, or "command sub1 sub2" => Callable
var _aliases: Dictionary # "alias" => command_to_run: PackedStringArray (alias may contain subcommands)
var _command_descriptions: Dictionary # command_name => description_text
var _argument_autocomplete_sources: Dictionary # [command_name, arg_idx] => Callable
var _history: CommandHistory
var _history_iter: CommandHistory.WrappingIterator
var _autocomplete_matches: PackedStringArray
var _eval_inputs: Dictionary
var _silent: bool = false
var _was_already_paused: bool = false
var _open_t: float = 0.0
var _open_speed: float = 5.0
var _is_open: bool = false
func _init() -> void:
layer = 9999
process_mode = ProcessMode.PROCESS_MODE_ALWAYS
_options = ConsoleOptions.new()
ConfigMapper.load_from_config(_options)
_history = CommandHistory.new()
if _options.persist_history:
_history.load()
_history_iter = _history.create_iterator()
_build_gui()
_init_theme()
_control.hide()
_control_block.hide()
_open_speed = _options.open_speed
if _options.disable_in_release_build:
enabled = OS.is_debug_build()
func _ready() -> void:
set_process(false) # Note, if you do it in _init(), it won't actually stop it for some reason.
BuiltinCommands.register_commands()
if _options.greet_user:
_greet()
_add_aliases_from_config.call_deferred()
_run_autoexec_script.call_deferred()
_entry.autocomplete_requested.connect(_autocomplete)
_entry.text_submitted.connect(_on_entry_text_submitted)
_entry.text_changed.connect(_on_entry_text_changed)
func _exit_tree() -> void:
if _options.persist_history:
_history.trim(_options.history_lines)
_history.save()
func _handle_command_input(p_event: InputEvent) -> void:
var handled := true
if not _is_open:
pass # Don't accept input while closing console.
elif p_event.keycode == KEY_UP:
_fill_entry(_history_iter.prev())
_clear_autocomplete()
_update_autocomplete()
elif p_event.keycode == KEY_DOWN:
_fill_entry(_history_iter.next())
_clear_autocomplete()
_update_autocomplete()
elif p_event.is_action_pressed("limbo_auto_complete_reverse"):
_reverse_autocomplete()
elif p_event.keycode == KEY_TAB:
_autocomplete()
elif p_event.keycode == KEY_PAGEUP:
var scroll_bar: VScrollBar = _output.get_v_scroll_bar()
scroll_bar.value -= scroll_bar.page
elif p_event.keycode == KEY_PAGEDOWN:
var scroll_bar: VScrollBar = _output.get_v_scroll_bar()
scroll_bar.value += scroll_bar.page
else:
handled = false
if handled:
get_viewport().set_input_as_handled()
func _handle_history_input(p_event: InputEvent):
# Allow tab complete (reverse)
if p_event.is_action_pressed("limbo_auto_complete_reverse"):
_reverse_autocomplete()
get_viewport().set_input_as_handled()
# Allow tab complete (forward)
elif p_event.keycode == KEY_TAB and p_event.is_pressed():
_autocomplete()
get_viewport().set_input_as_handled()
# Perform search
elif p_event is InputEventKey:
_history_gui.search(_entry.text)
_entry.grab_focus()
# Make sure entry is always focused
_entry.grab_focus()
func _input(p_event: InputEvent) -> void:
if p_event.is_action_pressed("limbo_console_toggle"):
toggle_console()
get_viewport().set_input_as_handled()
# Check to see if the history gui should open
elif _control.visible and p_event.is_action_pressed("limbo_console_search_history"):
toggle_history()
get_viewport().set_input_as_handled()
elif _history_gui.visible and p_event is InputEventKey:
_handle_history_input(p_event)
elif _control.visible and p_event is InputEventKey and p_event.is_pressed():
_handle_command_input(p_event)
func _process(delta: float) -> void:
var done_sliding := false
if _is_open:
_open_t = move_toward(_open_t, 1.0, _open_speed * delta * 1.0/Engine.time_scale)
if _open_t == 1.0:
done_sliding = true
else: # We close faster than opening.
_open_t = move_toward(_open_t, 0.0, _open_speed * delta * 1.5 * 1.0/Engine.time_scale)
if is_zero_approx(_open_t):
done_sliding = true
var eased := ease(_open_t, -1.75)
var new_y := remap(eased, 0, 1, -_control.size.y, 0)
_control.position.y = new_y
if done_sliding:
set_process(false)
if not _is_open:
_hide_console()
# *** PUBLIC INTERFACE
func open_console() -> void:
if enabled:
_is_open = true
set_process(true)
_show_console()
func close_console() -> void:
if enabled:
_is_open = false
set_process(true)
_history_gui.visible = false
if _options.persist_history:
_history.save()
# _hide_console() is called in _process()
func is_open() -> bool:
return _is_open
func toggle_console() -> void:
if _is_open:
close_console()
else:
open_console()
func toggle_history() -> void:
_history_gui.set_visibility(not _history_gui.visible)
# Whenever the history gui becomes visible, make sure it has the latest
# history and do an initial search
if _history_gui.visible:
_history_gui.search(_entry.text)
## Clears all messages in the console.
func clear_console() -> void:
_output.text = ""
## Erases the history that is persisted to the disk
func erase_history() -> void:
_history.clear()
var file := FileAccess.open(CommandHistory.HISTORY_FILE, FileAccess.WRITE)
if file:
file.store_string("")
file.close()
## Prints an info message to the console and the output.
func info(p_line: String) -> void:
print_line(p_line)
## Prints an error message to the console and the output.
func error(p_line: String) -> void:
print_line("[color=%s]ERROR:[/color] %s" % [_output_error_color.to_html(), p_line])
## Prints a warning message to the console and the output.
func warn(p_line: String) -> void:
print_line("[color=%s]WARNING:[/color] %s" % [_output_warning_color.to_html(), p_line])
## Prints a debug message to the console and the output.
func debug(p_line: String) -> void:
print_line("[color=%s]DEBUG: %s[/color]" % [_output_debug_color.to_html(), p_line])
## Prints a line using boxed ASCII art style.
func print_boxed(p_line: String) -> void:
for line in AsciiArt.str_to_boxed_art(p_line):
print_line(line)
## Prints a line to the console, and optionally to standard output.
func print_line(p_line: String, p_stdout: bool = _options.print_to_stdout) -> void:
if _silent:
return
_output.text += p_line + "\n"
if p_stdout:
print(Util.bbcode_strip(p_line))
## Registers a callable as a command, with optional name and description.
## Name can have up to 4 space-separated identifiers (e.g., "command sub1 sub2 sub3"),
## using letters, digits, or underscores, starting with a non-digit.
func register_command(p_func: Callable, p_name: String = "", p_desc: String = "") -> void:
if p_name and not Util.is_valid_command_sequence(p_name):
push_error("LimboConsole: Failed to register command: %s. Name can have up to 4 space-separated identifiers, using letters, digits, or underscores, starting with non-digit." % [p_name])
return
if not _validate_callable(p_func):
push_error("LimboConsole: Failed to register command: %s" % [p_func if p_name.is_empty() else p_name])
return
var name: String = p_name
if name.is_empty():
if p_func.is_custom():
push_error("LimboConsole: Failed to register command: Callable is not method and no name was provided")
return
name = p_func.get_method().trim_prefix("_").trim_prefix("cmd_")
if not OS.is_debug_build() and _options.commands_disabled_in_release.has(name):
return
if _commands.has(name):
push_error("LimboConsole: Command already registered: " + p_name)
return
# Note: It should be possible to have an alias with the same name.
_commands[name] = p_func
_command_descriptions[name] = p_desc
## Unregisters the command specified by its name or a callable.
func unregister_command(p_func_or_name) -> void:
var cmd_name: String
if p_func_or_name is Callable:
var key = _commands.find_key(p_func_or_name)
if key != null:
cmd_name = key
elif p_func_or_name is String:
cmd_name = p_func_or_name
if cmd_name.is_empty() or not _commands.has(cmd_name):
push_error("LimboConsole: Unregister failed - command not found: " % [p_func_or_name])
return
_commands.erase(cmd_name)
_command_descriptions.erase(cmd_name)
for i in range(1, 5):
_argument_autocomplete_sources.erase([cmd_name, i])
## Is a command or an alias registered by the given name.
func has_command(p_name: String) -> bool:
return _commands.has(p_name)
func get_command_names(p_include_aliases: bool = false) -> PackedStringArray:
var names: PackedStringArray = _commands.keys()
if p_include_aliases:
names.append_array(_aliases.keys())
names.sort()
return names
func get_command_description(p_name: String) -> String:
return _command_descriptions.get(p_name, "")
## Registers an alias for command line. [br]
## Alias may contain space-separated parts, e.g. "command sub1" which must match
## against two subsequent arguments on the command line.
func add_alias(p_alias: String, p_command_to_run: String) -> void:
# It should be possible to override commands and existing aliases.
# It should be possible to create aliases for commands that are not yet registered,
# because some commands may be registered by local-to-scene scripts.
_aliases[p_alias] = _parse_command_line(p_command_to_run)
## Removes an alias by name.
func remove_alias(p_name: String) -> void:
_aliases.erase(p_name)
## Is an alias registered by the given name.
func has_alias(p_name: String) -> bool:
return _aliases.has(p_name)
## Lists all registered aliases.
func get_aliases() -> PackedStringArray:
return PackedStringArray(_aliases.keys())
## Returns the alias's actual command as an argument vector.
func get_alias_argv(p_alias: String) -> PackedStringArray:
# TODO: I believe _aliases values are stored as an array so this iis unneccessary?
return _aliases.get(p_alias, [p_alias]).duplicate()
## Registers a callable that should return an array of possible values for the given argument and command.
## It will be used for autocompletion.
func add_argument_autocomplete_source(p_command: String, p_argument: int, p_source: Callable) -> void:
if not p_source.is_valid():
push_error("LimboConsole: Can't add autocomplete source: source callable is not valid")
return
if not has_command(p_command):
push_error("LimboConsole: Can't add autocomplete source: command doesn't exist: ", p_command)
return
if p_argument < 0 or p_argument > 4:
push_error("LimboConsole: Can't add autocomplete source: argument index out of bounds: ", p_argument)
return
var argument_values = p_source.call()
if not _validate_autocomplete_result(argument_values, p_command):
push_error("LimboConsole: Failed to add argument autocomplete source: Callable must return an array.")
return
var key := [p_command, p_argument]
_argument_autocomplete_sources[key] = p_source
## Parses the command line and executes the command if it's valid.
func execute_command(p_command_line: String, p_silent: bool = false) -> void:
p_command_line = p_command_line.strip_edges()
if p_command_line.is_empty() or p_command_line.strip_edges().begins_with('#'):
return
var argv: PackedStringArray = _parse_command_line(p_command_line)
var expanded_argv: PackedStringArray = _join_subcommands(_expand_alias(argv))
var command_name: String = expanded_argv[0]
var command_args: Array = []
_silent = p_silent
if not p_silent:
var history_line: String = " ".join(argv)
_history.push_entry(history_line)
info("[color=%s][b]>[/b] %s[/color] %s" %
[_output_command_color.to_html(), argv[0], " ".join(argv.slice(1))])
if not has_command(command_name):
error("Unknown command: " + command_name)
_suggest_similar_command(expanded_argv)
_silent = false
return
var cmd: Callable = _commands.get(command_name)
var valid: bool = _parse_argv(expanded_argv, cmd, command_args)
if valid:
var err = cmd.callv(command_args)
var failed: bool = typeof(err) == TYPE_INT and err > 0
if failed:
_suggest_argument_corrections(expanded_argv)
else:
usage(command_name)
if _options.sparse_mode:
print_line("")
_silent = false
## Execute commands from file.
func execute_script(p_file: String, p_silent: bool = true) -> void:
if FileAccess.file_exists(p_file):
if not p_silent:
LimboConsole.info("Executing " + p_file);
var fa := FileAccess.open(p_file, FileAccess.READ)
while not fa.eof_reached():
var line: String = fa.get_line()
LimboConsole.execute_command(line, p_silent)
else:
LimboConsole.error("File not found: " + p_file.trim_prefix("user://"))
_resume_scroll_following.call_deferred()
## Formats the tip text (hopefully useful ;).
func format_tip(p_text: String) -> String:
return "[i][color=" + _output_debug_color.to_html() + "]" + p_text + "[/color][/i]"
## Formats the command name for display.
func format_name(p_name: String) -> String:
return "[color=" + _output_command_mention_color.to_html() + "]" + p_name + "[/color]"
## Prints the help text for the given command.
func usage(p_command: String) -> Error:
if _aliases.has(p_command):
var alias_argv: PackedStringArray = get_alias_argv(p_command)
var formatted_cmd := "%s %s" % [format_name(alias_argv[0]), ' '.join(alias_argv.slice(1))]
print_line("Alias of: " + formatted_cmd)
p_command = alias_argv[0]
if not has_command(p_command):
error("Command not found: " + p_command)
return ERR_INVALID_PARAMETER
var callable: Callable = _commands[p_command]
var method_info: Dictionary = Util.get_method_info(callable)
if method_info.is_empty():
error("Couldn't find method info for: " + callable.get_method())
print_line("Usage: ???")
var usage_line: String = "Usage: %s" % [p_command]
var arg_lines: String = ""
var values_lines: String = ""
var required_args: int = method_info.args.size() - method_info.default_args.size()
for i in range(method_info.args.size() - callable.get_bound_arguments_count()):
var arg_name: String = method_info.args[i].name.trim_prefix("p_")
var arg_type: int = method_info.args[i].type
if i < required_args:
usage_line += " " + arg_name
else:
usage_line += " [lb]" + arg_name + "[rb]"
var def_spec: String = ""
var num_required_args: int = method_info.args.size() - method_info.default_args.size()
if i >= num_required_args:
var def_value = method_info.default_args[i - num_required_args]
if typeof(def_value) == TYPE_STRING:
def_value = "\"" + def_value + "\""
def_spec = " = %s" % [def_value]
arg_lines += " %s: %s%s\n" % [arg_name, type_string(arg_type) if arg_type != TYPE_NIL else "Variant", def_spec]
if _argument_autocomplete_sources.has([p_command, i]):
var auto_complete_callable: Callable = _argument_autocomplete_sources[[p_command, i]]
var arg_autocompletes = auto_complete_callable.call()
if len(arg_autocompletes) > 0:
var values: String = str(arg_autocompletes).replace("[", "").replace("]", "")
values_lines += " %s: %s\n" % [arg_name, values]
arg_lines = arg_lines.trim_suffix('\n')
print_line(usage_line)
var desc_line: String = ""
desc_line = _command_descriptions.get(p_command, "")
if not desc_line.is_empty():
desc_line[0] = desc_line[0].capitalize()
if desc_line.right(1) != ".":
desc_line += "."
print_line(desc_line)
if not arg_lines.is_empty():
print_line("Arguments:")
print_line(arg_lines)
if not values_lines.is_empty():
print_line("Values:")
print_line(values_lines)
return OK
## Define an input variable for "eval" command.
func add_eval_input(p_name: String, p_value) -> void:
_eval_inputs[p_name] = p_value
## Remove specified input variable from "eval" command.
func remove_eval_input(p_name) -> void:
_eval_inputs.erase(p_name)
## List the defined input variables used in "eval" command.
func get_eval_input_names() -> PackedStringArray:
return _eval_inputs.keys()
## Get input variable values used in "eval" command, listed in the same order as names.
func get_eval_inputs() -> Array:
return _eval_inputs.values()
## Define the object that will be used as the base instance for "eval" command.
## When defined, this object will be the "self" for expressions.
## Can be null (the default) to not use any base instance.
func set_eval_base_instance(object):
_eval_inputs["_base_instance"] = object
## Get the object that will be used as the base instance for "eval" command.
## Null by default.
func get_eval_base_instance():
return _eval_inputs.get("_base_instance")
# *** PRIVATE
# *** INITIALIZATION
func _build_gui() -> void:
var con := Control.new() # To block mouse input.
_control_block = con
con.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(con)
var panel := PanelContainer.new()
_control = panel
panel.anchor_bottom = _options.height_ratio
panel.anchor_right = 1.0
add_child(panel)
var vbox := VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(vbox)
_output = RichTextLabel.new()
_output.size_flags_vertical = Control.SIZE_EXPAND_FILL
_output.scroll_active = true
_output.scroll_following = false # To implement custom scroll following behavior
_output.bbcode_enabled = true
_output.focus_mode = Control.FOCUS_CLICK
vbox.add_child(_output)
# To allow user to pause scrolling, we need to track the scroll bar ourselves
var scroll_bar: VScrollBar = _output.get_v_scroll_bar()
var scroll_history: Dictionary = {
"last_value": 1.0,
"last_height": scroll_bar.max_value - scroll_bar.page,
"correction_queued": false,
}
var correct_scroll := func() -> void:
scroll_history["correction_queued"] = false
scroll_history["last_height"] = scroll_bar.max_value - scroll_bar.page
if scroll_history["last_value"] == 1.0:
_resume_scroll_following()
scroll_bar.value_changed.connect(func(value):
# Scroll bar height changes multiple times on printing new lines
var current_height := scroll_bar.max_value - scroll_bar.page
if current_height != scroll_history["last_height"]:
if not scroll_history["correction_queued"]:
scroll_history["correction_queued"] = true
correct_scroll.call_deferred()
else:
# Scroll bar is done moving from prints, so it must have been moved by user. Thus, we update the last value
if scroll_bar.max_value - scroll_bar.page > 0:
scroll_history["last_value"] = scroll_bar.value / (scroll_bar.max_value - scroll_bar.page)
else:
scroll_history["last_value"] = 1.0
)
_entry = CommandEntry.new()
vbox.add_child(_entry)
_control.modulate = Color(1.0, 1.0, 1.0, _options.opacity)
_history_gui = HistoryGui.new(_history)
_output.add_child(_history_gui)
_history_gui.visible = false
func _init_theme() -> void:
var theme: Theme
if ResourceLoader.exists(_options.custom_theme, "Theme"):
theme = load(_options.custom_theme)
else:
theme = load(THEME_DEFAULT)
_control.theme = theme
const CONSOLE_COLORS_THEME_TYPE := &"ConsoleColors"
_output_command_color = theme.get_color(&"output_command_color", CONSOLE_COLORS_THEME_TYPE)
_output_command_mention_color = theme.get_color(&"output_command_mention_color", CONSOLE_COLORS_THEME_TYPE)
_output_text_color = theme.get_color(&"output_text_color", CONSOLE_COLORS_THEME_TYPE)
_output_error_color = theme.get_color(&"output_error_color", CONSOLE_COLORS_THEME_TYPE)
_output_warning_color = theme.get_color(&"output_warning_color", CONSOLE_COLORS_THEME_TYPE)
_output_debug_color = theme.get_color(&"output_debug_color", CONSOLE_COLORS_THEME_TYPE)
_entry_text_color = theme.get_color(&"entry_text_color", CONSOLE_COLORS_THEME_TYPE)
_entry_hint_color = theme.get_color(&"entry_hint_color", CONSOLE_COLORS_THEME_TYPE)
_entry_command_found_color = theme.get_color(&"entry_command_found_color", CONSOLE_COLORS_THEME_TYPE)
_entry_subcommand_color = theme.get_color(&"entry_subcommand_color", CONSOLE_COLORS_THEME_TYPE)
_entry_command_not_found_color = theme.get_color(&"entry_command_not_found_color", CONSOLE_COLORS_THEME_TYPE)
_output.add_theme_color_override(&"default_color", _output_text_color)
_entry.add_theme_color_override(&"font_color", _entry_text_color)
_entry.add_theme_color_override(&"hint_color", _entry_hint_color)
_entry.syntax_highlighter.command_found_color = _entry_command_found_color
_entry.syntax_highlighter.command_not_found_color = _entry_command_not_found_color
_entry.syntax_highlighter.subcommand_color = _entry_subcommand_color
_entry.syntax_highlighter.text_color = _entry_text_color
func _greet() -> void:
var message: String = _options.greeting_message
message = message.format({
"project_name": ProjectSettings.get_setting("application/config/name"),
"project_version": ProjectSettings.get_setting("application/config/version"),
})
if not message.is_empty():
if _options.greet_using_ascii_art and AsciiArt.is_boxed_art_supported(message):
print_boxed(message)
info("")
else:
info("[b]" + message + "[/b]")
BuiltinCommands.cmd_help()
info(format_tip("-----"))
func _add_aliases_from_config() -> void:
for alias in _options.aliases:
var target = _options.aliases[alias]
if not alias is String:
push_error("LimboConsole: Config error: Alias name should be String")
elif not target is String:
push_error("LimboConsole: Config error: Alias target should be String")
elif has_command(alias):
push_error("LimboConsole: Config error: Alias or command already registered: ", alias)
elif not has_command(target):
push_error("LimboConsole: Config error: Alias target not found: ", target)
else:
add_alias(alias, target)
func _run_autoexec_script() -> void:
if _options.autoexec_script.is_empty():
return
if _options.autoexec_auto_create and not FileAccess.file_exists(_options.autoexec_script):
FileAccess.open(_options.autoexec_script, FileAccess.WRITE)
if FileAccess.file_exists(_options.autoexec_script):
execute_script(_options.autoexec_script)
# *** PARSING
## Splits the command line string into an array of arguments (aka argv).
func _parse_command_line(p_line: String) -> PackedStringArray:
var argv: PackedStringArray = []
var arg: String = ""
var in_quotes: bool = false
var in_brackets: bool = false
var line: String = p_line.strip_edges()
var start: int = 0
var cur: int = 0
for char in line:
if char == '"':
in_quotes = not in_quotes
elif char == '(':
in_brackets = true
elif char == ')':
in_brackets = false
elif char == ' ' and not in_quotes and not in_brackets:
if cur > start:
argv.append(line.substr(start, cur - start))
start = cur + 1
cur += 1
if cur > start:
argv.append(line.substr(start, cur))
return argv
## Joins recognized subcommands in the argument vector into a single
## space-separated command sequence at index zero.
func _join_subcommands(p_argv: PackedStringArray) -> PackedStringArray:
for num_parts in range(MAX_SUBCOMMANDS, 1, -1):
if p_argv.size() >= num_parts:
var cmd: String = ' '.join(p_argv.slice(0, num_parts))
if has_command(cmd) or has_alias(cmd):
var argv: PackedStringArray = [cmd]
return argv + p_argv.slice(num_parts)
return p_argv
## Substitutes an array of strings with its real command in argv.
## Will recursively expand aliases until no aliases are left.
func _expand_alias(p_argv: PackedStringArray) -> PackedStringArray:
var argv: PackedStringArray = p_argv.duplicate()
var result := PackedStringArray()
const max_depth: int = 1000
var current_depth: int = 0
while not argv.is_empty() and current_depth != max_depth:
argv = _join_subcommands(argv)
var current: String = argv[0]
argv.remove_at(0)
var alias_argv: PackedStringArray = _aliases.get(current, [])
current_depth += 1
if not alias_argv.is_empty():
argv = alias_argv + argv
else:
result.append(current)
if current_depth >= max_depth:
push_error("LimboConsole: Max depth for alias reached. Is there a loop in your aliasing?")
return p_argv
return result
## Converts arguments from String to types expected by the callable, and returns true if successful.
## The converted values are placed into a separate r_args array.
func _parse_argv(p_argv: PackedStringArray, p_callable: Callable, r_args: Array) -> bool:
var passed := true
var method_info: Dictionary = Util.get_method_info(p_callable)
if method_info.is_empty():
error("Couldn't find method info for: " + p_callable.get_method())
return false
var num_bound_args: int = p_callable.get_bound_arguments_count()
var num_args: int = p_argv.size() + num_bound_args - 1
var max_args: int = method_info.args.size()
var num_with_defaults: int = method_info.default_args.size()
var required_args: int = max_args - num_with_defaults
# Join all arguments into a single string if the callable accepts a single string argument.
if max_args - num_bound_args == 1 and method_info.args[0].type == TYPE_STRING:
var a: String = " ".join(p_argv.slice(1))
if a.left(1) == '"' and a.right(1) == '"':
a = a.trim_prefix('"').trim_suffix('"')
r_args.append(a)
return true
if num_args < required_args:
error("Missing arguments.")
return false
if num_args > max_args:
error("Too many arguments.")
return false
r_args.resize(p_argv.size() - 1)
for i in range(1, p_argv.size()):
var a: String = p_argv[i]
var incorrect_type := false
var expected_type: int = method_info.args[i - 1].type
if expected_type == TYPE_STRING:
if a.left(1) == '"' and a.right(1) == '"':
a = a.trim_prefix('"').trim_suffix('"')
r_args[i - 1] = a
elif a.begins_with('(') and a.ends_with(')'):
var vec = _parse_vector_arg(a)
if vec != null:
r_args[i - 1] = vec
else:
r_args[i - 1] = a
passed = false
elif a.is_valid_float():
r_args[i - 1] = a.to_float()
elif a.is_valid_int():
r_args[i - 1] = a.to_int()
elif a == "true" or a == "1" or a == "yes" or a == "on":
r_args[i - 1] = true
elif a == "false" or a == "0" or a == "no" or a == "off":
r_args[i - 1] = false
else:
r_args[i - 1] = a.trim_prefix('"').trim_suffix('"')
var parsed_type: int = typeof(r_args[i - 1])
if not _are_compatible_types(expected_type, parsed_type):
error("Argument %d expects %s, but %s provided." % [i, type_string(expected_type), type_string(parsed_type)])
passed = false
return passed
## Returns true if the parsed type is compatible with the expected type.
func _are_compatible_types(p_expected_type: int, p_parsed_type: int) -> bool:
return p_expected_type == p_parsed_type or \
p_expected_type == TYPE_NIL or \
p_expected_type == TYPE_STRING or p_expected_type == TYPE_STRING_NAME or \
(p_expected_type in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT] and p_parsed_type in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]) or \
(p_expected_type in [TYPE_VECTOR2, TYPE_VECTOR2I] and p_parsed_type in [TYPE_VECTOR2, TYPE_VECTOR2I]) or \
(p_expected_type in [TYPE_VECTOR3, TYPE_VECTOR3I] and p_parsed_type in [TYPE_VECTOR3, TYPE_VECTOR3I]) or \
(p_expected_type in [TYPE_VECTOR4, TYPE_VECTOR4I] and p_parsed_type in [TYPE_VECTOR4, TYPE_VECTOR4I])
func _parse_vector_arg(p_text):
assert(p_text.begins_with('(') and p_text.ends_with(')'), "Vector string presentation must begin and end with round brackets")
var comp: Array
var token: String
for i in range(1, p_text.length()):
var c: String = p_text[i]
if c.is_valid_int() or c == '.' or c == '-':
token += c
elif c == ',' or c == ' ' or c == ')':
if token.is_empty() and c == ',' and p_text[i - 1] in [',', '(']:
# Support shorthand notation: (,,1) => (0,0,1)
token = '0'
if token.is_valid_float():
comp.append(token.to_float())
token = ""
elif not token.is_empty():
error("Failed to parse vector argument: Not a number: \"" + token + "\"")
info(format_tip("Tip: Supported formats are (1, 2, 3) and (1 2 3) with 2, 3 and 4 elements."))
return null
else:
error("Failed to parse vector argument: Bad formatting: \"" + p_text + "\"")
info(format_tip("Tip: Supported formats are (1, 2, 3) and (1 2 3) with 2, 3 and 4 elements."))
return null
if comp.size() == 2:
return Vector2(comp[0], comp[1])
elif comp.size() == 3:
return Vector3(comp[0], comp[1], comp[2])
elif comp.size() == 4:
return Vector4(comp[0], comp[1], comp[2], comp[3])
else:
error("LimboConsole supports 2,3,4-element vectors, but %d-element vector given." % [comp.size()])
return null
# *** AUTOCOMPLETE
## Auto-completes a command or auto-correction on TAB.
func _autocomplete() -> void:
if not _autocomplete_matches.is_empty():
var match_str: String = _autocomplete_matches[0]
_fill_entry(match_str)
_autocomplete_matches.remove_at(0)
_autocomplete_matches.push_back(match_str)
_update_autocomplete()
## Goes in the opposite direction for the autocomplete suggestion
func _reverse_autocomplete():
if not _autocomplete_matches.is_empty():
var match_str = _autocomplete_matches[_autocomplete_matches.size() - 1]
_autocomplete_matches.remove_at(_autocomplete_matches.size() - 1)
_autocomplete_matches.insert(0, match_str)
match_str = _autocomplete_matches[_autocomplete_matches.size() - 1]
_fill_entry(match_str)
_update_autocomplete()
## Updates autocomplete suggestions and hint based on user input.
func _update_autocomplete() -> void:
var argv: PackedStringArray = _expand_alias(_parse_command_line(_entry.text))
if _entry.text.right(1) == ' ' or argv.size() == 0:
argv.append("")
var command_name: String = argv[0]
var last_arg: int = argv.size() - 1
if _autocomplete_matches.is_empty() and not _entry.text.is_empty():
if last_arg == 0 and not argv[0].is_empty() \
and len(argv[0].split(" ")) <= 1:
_add_first_input_autocompletes(command_name)
elif last_arg != 0:
_add_argument_autocompletes(argv)
_add_subcommand_autocompletes(_entry.text)
_add_history_autocompletes()
if _autocomplete_matches.size() > 0 \
and _autocomplete_matches[0].length() > _entry.text.length() \
and _autocomplete_matches[0].begins_with(_entry.text):
_entry.autocomplete_hint = _autocomplete_matches[0].substr(_entry.text.length())
else:
_entry.autocomplete_hint = ""
## Adds auto completes for the first index of a registered
## commands when the command is split on " "
func _add_first_input_autocompletes(command_name: String) -> void:
for cmd_name in get_command_names(true):
var first_input: String = cmd_name.split(" ")[0]
if first_input.begins_with(command_name) and \
first_input not in _autocomplete_matches:
_autocomplete_matches.append(first_input)
_autocomplete_matches.sort()
## Adds auto-completes based on user added arguments for a command. [br]
## p_argv is expected to contain full command as the first element (including subcommands).
func _add_argument_autocompletes(p_argv: PackedStringArray) -> void:
if p_argv.is_empty():
return
var command: String = p_argv[0]
var last_arg: int = p_argv.size() - 1
var key := [command, last_arg - 1] # Argument indices are 0-based.
if _argument_autocomplete_sources.has(key):
var argument_values = _argument_autocomplete_sources[key].call()
if not _validate_autocomplete_result(argument_values, command):
argument_values = []
var matches: PackedStringArray = []
for value in argument_values:
if str(value).begins_with(p_argv[last_arg]):
matches.append(_entry.text.substr(0, _entry.text.length() - p_argv[last_arg].length()) + str(value))
matches.sort()
_autocomplete_matches.append_array(matches)
## Adds auto-completes based on the history
func _add_history_autocompletes() -> void:
if _options.autocomplete_use_history_with_matches or \
len(_autocomplete_matches) == 0:
for i in range(_history.size() - 1, -1, -1):
if _history.get_entry(i).begins_with(_entry.text):
_autocomplete_matches.append(_history.get_entry(i))
## Adds subcommand auto-complete suggestions based on registered commands
## and the current user input
func _add_subcommand_autocompletes(typed_val: String) -> void:
var command_names: PackedStringArray = get_command_names(true)
var typed_val_tokens: PackedStringArray = typed_val.split(" ")
var result: Dictionary = {} # Hashset. "autocomplete" => N/A
for cmd in command_names:
var cmd_split = cmd.split(" ")
if len(cmd_split) < len(typed_val_tokens):
continue
var last_match: int = 0
for i in len(typed_val_tokens):
if cmd_split[i] != typed_val_tokens[i]:
break
last_match += 1
if last_match < len(typed_val_tokens) - 1:
continue
if len(cmd_split) >= len(typed_val_tokens) \
and cmd_split[last_match].begins_with(typed_val_tokens[-1]):
var partial_cmd_arr: PackedStringArray = cmd_split.slice(0, last_match + 1)
result.get_or_add(" ".join(partial_cmd_arr))
var matches = result.keys()
matches.sort()
_autocomplete_matches.append_array(matches)
func _clear_autocomplete() -> void:
_autocomplete_matches.clear()
_entry.autocomplete_hint = ""
## Suggests corrections to user input based on similar command names.
func _suggest_similar_command(p_argv: PackedStringArray) -> void:
if _silent:
return
var fuzzy_hit: String = Util.fuzzy_match_string(p_argv[0], 2, get_command_names(true))
if fuzzy_hit:
info(format_tip("Did you mean %s? ([b]TAB[/b] to fill)" % [format_name(fuzzy_hit)]))
var argv := p_argv.duplicate()
argv[0] = fuzzy_hit
var suggest_command: String = " ".join(argv)
suggest_command = suggest_command.strip_edges()
_autocomplete_matches.append(suggest_command)
## Suggests corrections to user input based on similar autocomplete argument values.
func _suggest_argument_corrections(p_argv: PackedStringArray) -> void: