Files
parasitic-god/addons/limbo_console/limbo_console.gd

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()