1084 lines
36 KiB
GDScript
1084 lines
36 KiB
GDScript
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(argv[0])
|
|
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:
|
|
info("Executing " + p_file);
|
|
var fa := FileAccess.open(p_file, FileAccess.READ)
|
|
while not fa.eof_reached():
|
|
var line: String = fa.get_line()
|
|
execute_command(line, p_silent)
|
|
else:
|
|
error("File not found: " + p_file.trim_prefix("user://"))
|
|
|
|
|
|
## 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 = true
|
|
_output.bbcode_enabled = true
|
|
_output.focus_mode = Control.FOCUS_CLICK
|
|
vbox.add_child(_output)
|
|
|
|
_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":
|
|
r_args[i - 1] = true
|
|
elif a == "false" or a == "0" or a == "no":
|
|
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 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:
|
|
if _silent:
|
|
return
|
|
var argv: PackedStringArray
|
|
var command_name: String = p_argv[0]
|
|
command_name = get_alias_argv(command_name)[0]
|
|
var corrected := false
|
|
|
|
argv.resize(p_argv.size())
|
|
argv[0] = command_name
|
|
for i in range(1, p_argv.size()):
|
|
var accepted_values = []
|
|
var key := [command_name, i]
|
|
var source: Callable = _argument_autocomplete_sources.get(key, Callable())
|
|
if source.is_valid():
|
|
accepted_values = source.call()
|
|
if accepted_values == null or not _validate_autocomplete_result(accepted_values, command_name):
|
|
continue
|
|
var fuzzy_hit: String = Util.fuzzy_match_string(p_argv[i], 2, accepted_values)
|
|
if not fuzzy_hit.is_empty():
|
|
argv[i] = fuzzy_hit
|
|
corrected = true
|
|
else:
|
|
argv[i] = p_argv[i]
|
|
if corrected:
|
|
info(format_tip("Did you mean \"%s %s\"? ([b]TAB[/b] to fill)" % [format_name(command_name), " ".join(argv.slice(1))]))
|
|
var suggest_command: String = " ".join(argv)
|
|
suggest_command = suggest_command.strip_edges()
|
|
_autocomplete_matches.append(suggest_command)
|
|
|
|
|
|
# *** MISC
|
|
|
|
|
|
func _show_console() -> void:
|
|
if not _control.visible and enabled:
|
|
_control.show()
|
|
_control_block.show()
|
|
if _options.pause_when_open:
|
|
_was_already_paused = get_tree().paused
|
|
if not _was_already_paused:
|
|
get_tree().paused = true
|
|
_previous_gui_focus = get_viewport().gui_get_focus_owner()
|
|
_entry.grab_focus()
|
|
toggled.emit(true)
|
|
|
|
|
|
func _hide_console() -> void:
|
|
if _control.visible:
|
|
_control.hide()
|
|
_control_block.hide()
|
|
|
|
if _options.pause_when_open:
|
|
if not _was_already_paused:
|
|
get_tree().paused = false
|
|
if is_instance_valid(_previous_gui_focus):
|
|
_previous_gui_focus.grab_focus()
|
|
toggled.emit(false)
|
|
|
|
|
|
## Returns true if the callable can be registered as a command.
|
|
func _validate_callable(p_callable: Callable) -> bool:
|
|
var method_info: Dictionary = Util.get_method_info(p_callable)
|
|
if p_callable.is_standard() and method_info.is_empty():
|
|
push_error("LimboConsole: Couldn't find method info for: " + p_callable.get_method())
|
|
return false
|
|
if p_callable.is_custom() and not method_info.is_empty() \
|
|
and method_info.get("name") == "<anonymous lambda>" \
|
|
and p_callable.get_bound_arguments_count() > 0:
|
|
push_error("LimboConsole: bound anonymous functions are unsupported")
|
|
return false
|
|
|
|
var ret := true
|
|
for arg in method_info.args:
|
|
if not arg.type in [TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I]:
|
|
push_error("LimboConsole: Unsupported argument type: %s is %s" % [arg.name, type_string(arg.type)])
|
|
ret = false
|
|
return ret
|
|
|
|
|
|
func _validate_autocomplete_result(p_result: Variant, p_command: String) -> bool:
|
|
if typeof(p_result) < TYPE_ARRAY:
|
|
push_error("LimboConsole: Argument autocomplete source failed: Expecting array but got: ",
|
|
type_string(typeof(p_result)), " command: ", p_command)
|
|
return false
|
|
return true
|
|
|
|
|
|
func _fill_entry(p_line: String) -> void:
|
|
_entry.text = p_line
|
|
_entry.set_caret_column(p_line.length())
|
|
|
|
|
|
func _on_entry_text_submitted(p_command: String) -> void:
|
|
if _history_gui.visible:
|
|
_history_gui.visible = false
|
|
_clear_autocomplete()
|
|
_fill_entry(_history_gui.get_current_text())
|
|
_update_autocomplete()
|
|
else:
|
|
_clear_autocomplete()
|
|
_fill_entry("")
|
|
execute_command(p_command)
|
|
_update_autocomplete()
|
|
|
|
|
|
func _on_entry_text_changed() -> void:
|
|
_clear_autocomplete()
|
|
if not _entry.text.is_empty():
|
|
_update_autocomplete()
|
|
else:
|
|
_history_iter.reset()
|