-
Notifications
You must be signed in to change notification settings - Fork 1
/
Console.gd
417 lines (300 loc) · 13.8 KB
/
Console.gd
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
"""Godot Console
Console Command Mini Language:
command ::= [a-zA-Z_-.]+
arguments ::= (' ' argument)+
argument ::= 'true' ; bool
| 'false' ; bool
| [0-9]+ ; int
| [0-9]+ '.' [0-9]+ ; float/real
| [c]+ ; small string/name
| '"' [c]+ '"' ; long string
| '@' [c]+ ; node path
where c is short-hand for any character
command_line ::= command arguments?
Author: Brandon Harris ([email protected])
Nota Bene:
This requires a Globals singleton to exist in your project, with the field
'console_commands' and type Dictionary.
Usage:
- Ensure all input elsewhere is handled with _unhandled_input, or else
entering input into the console may move your player charcter, etc.
- Add a new console node to the player character (or other persistent
singleton). This is done the same was as adding any new child node.
- Set the 'Layout' of the 'Console' node and it's parent's to 'Full Rect'.
Developer Notes:
-
Todo:
- history
"""
extends Control
class_name GDConsole
signal console_opened
signal console_closed
onready var console_scene := preload("res://addons/godot_console/Console/Console.tscn")
onready var output : RichTextLabel
onready var input : LineEdit
onready var history_pointer := 0
onready var command_history := []
onready var _open_mouse_mode := Input.MOUSE_MODE_VISIBLE
onready var _closed_mouse_mode := Input.get_mouse_mode()
func _ready() -> void:
pause_mode = PAUSE_MODE_PROCESS
add_child(console_scene.instance(), true)
output = get_node("Container/Output")
input = get_node("Container/Input")
# Disable focusing on output instead of input line
output.set_focus_mode(FOCUS_NONE)
input.connect("text_entered", self, "_on_text_entered")
# Initial open trigger
if visible:
input.grab_focus()
Input.set_mouse_mode(_open_mouse_mode)
emit_signal("console_opened")
_add_default_commands()
func _add_default_commands() -> void:
"""Add the console's default commands."""
add_command(
"help", [["command_name", TYPE_STRING]],
self, "_command_help",
"display the given command's help message")
add_command("clear", [], self, "_command_clear", "clear the console output")
add_command("list", [], self, "_command_list", "list registered commands")
add_command("exit", [], self, "_command_exit", "exit the current console session")
add_command("quit", [], self, "_command_quit", "quit the game")
add_command("echo", [["output", TYPE_NIL]], self, "_command_echo", "echo the given argument")
add_command("print_tree", [], self, "_command_print_tree", "print the scene tree")
add_command(
"print_children", [["node", TYPE_NODE_PATH]],
self, "_command_print_children",
"recursively print the given node's children")
func _input(event : InputEvent):
if event.is_action_pressed("dev_toggle_console"):
_on_toggle_console()
get_tree().set_input_as_handled()
# For any action other than opening the console, the console must already
# be open
if not visible:
return
if event.is_action_pressed("ui_cancel"):
visible = false
get_tree().set_input_as_handled()
if event.is_action_pressed("ui_focus_next"):
_autocomplete()
get_tree().set_input_as_handled()
if event.is_action_pressed("ui_up"):
_history(+1)
get_tree().set_input_as_handled()
if event.is_action_pressed("ui_down"):
_history(-1)
get_tree().set_input_as_handled()
func add_command(
name : String, command_arguments : Array,
parent_node : Object, method_name : String = "",
description : String = "", help : String = ""
) -> void:
"""Add the given command to the console's repertoire.
Type info:
command_arguments : Array[Array[name : String, type : Type]]
If no help message provided, help is autogenerated.
Return error code from @GlobalScope.Error:
ERR_METHOD_NOT_FOUND - if parent_node has no method method_name
ERR_ALREADY_IN_USE - if console command with given name already registered
OK - if successful
"""
assert(not name == "", "must supply a command name")
assert(parent_node, "parent node can't be null")
assert(parent_node.has_method(method_name), "parent has no method '%s'" % method_name)
assert(not Globals.console_commands.get(name), "command name '%s' already in use" % name)
if not method_name:
method_name = name
# TODO: Check argument types are legal
Globals.console_commands[name] = \
Command.new(name, command_arguments, parent_node, method_name, description, help)
func write(text : String, prefix : String = "\n", suffix : String = "") -> int:
"""Write to the console. Return the RichTextLabel write error code."""
return output.append_bbcode(prefix + text + suffix)
func write_error(error_code : int, error_message : String) -> int:
"""Write the given text with an error prefix. Return the RichTextLabel write error code."""
var error_code_prefix := "[b][color=red]%s: [/color][/b]"
match error_code:
ERR_PARSE_ERROR:
error_code_prefix %= "ParseError"
ERR_INVALID_DATA:
error_code_prefix %= "InvalidDataError"
ERR_ALREADY_IN_USE:
error_code_prefix %= "AleadyInUseError"
ERR_ALREADY_EXISTS:
error_code_prefix %= "AleadyExistsError"
ERR_DOES_NOT_EXIST:
error_code_prefix %= "DoesNotExistError"
ERR_METHOD_NOT_FOUND:
error_code_prefix %= "MethodNotFoundError"
ERR_PARAMETER_RANGE_ERROR:
error_code_prefix %= "ParameterRangeError"
_:
error_code_prefix %= "Error"
return write(error_code_prefix + error_message)
static func response_empty() -> CommandResponse:
"""Shorthand for the empty response."""
return CommandResponse.new()
static func response_error(error_message : String) -> CommandResponse:
"""Shorthand for an error response."""
return CommandResponse.new(CommandResponse.ResponseType.ERROR, error_message)
static func response_result(result : String) -> CommandResponse:
"""Shorthand for a standard result."""
return CommandResponse.new(CommandResponse.ResponseType.RESULT, result)
func _on_text_entered(text: String) -> int:
"""Handle user text entry. Clear, add to history, handle execution/output."""
input.clear()
write("> " + text)
# Add to history if history is empty or text is not equal to the previous entry
history_pointer = 0
if not command_history or command_history[len(command_history) - 1] != text:
command_history.append(text)
var command_instance := CommandParser.parse_command_line(text)
if command_instance.status != OK:
return write_error(command_instance.status, command_instance.error_message)
var command : Command = Globals.console_commands.get(command_instance.command_name)
if command == null:
return write_error(
ERR_DOES_NOT_EXIST, "command '%s' not found" % command_instance.command_name
)
var result := command.execute(command_instance.command_arguments)
if result:
return write(result.get_response())
# Something went wrong, try to work out why
if len(command_instance.command_arguments) > len(command.command_arguments):
return write_error(
ERR_PARAMETER_RANGE_ERROR,
"too many arguments for command '%s'" % [command_instance.command_name]
)
if len(command_instance.command_arguments) < len(command.command_arguments):
return write_error(
ERR_PARAMETER_RANGE_ERROR,
"too few arguments for command '%s'" % [command_instance.command_name]
)
for i in len(command_instance.command_arguments):
var expected_type : int = command.command_arguments[i][1]
var received_type : int = command_instance.command_arguments[i][1]
if not Types.equivalent(expected_type, received_type):
var e := "expected argument '%s' of type [color=blue]%s[/color], " \
+ "received '%s' of type [color=blue]%s[/color]"
var format_data = [
command.command_arguments[i][0], Types.get_type_name(expected_type),
command_instance.command_arguments[i][0], Types.get_type_name(received_type)
]
return write_error(ERR_INVALID_DATA, e % format_data)
return write_error(FAILED, "an unexpected error occurred")
func _on_toggle_console() -> void:
"""Toggle visibility, handle mouse mode, emit signal, and clear input."""
if not visible:
_closed_mouse_mode = Input.get_mouse_mode()
visible = !visible
if visible:
input.grab_focus()
Input.set_mouse_mode(_open_mouse_mode if visible else _closed_mouse_mode)
emit_signal("console_opened" if visible else "console_closed")
input.clear()
history_pointer = 0
func _history(direction : int) -> void:
history_pointer = int(clamp(history_pointer + direction, 0, len(command_history)))
input.clear()
if not history_pointer:
return
input.append_at_cursor(command_history[-history_pointer])
class AutocompleteMatchesSorter:
"""
Custom sorter for autocomplete matches.
This is dumb, let me lambda >:(. Or, at least, let me put the class in _autocomplete.
"""
static func sort_ascending(a : String, b : String) -> bool:
if len(a) < len(b):
return true
return false
static func sort_descending(a : String, b : String) -> bool:
return sort_ascending(b, a)
func _autocomplete() -> int:
"""Autocomplete the currently input text."""
if not input.text:
return ERR_INVALID_DATA
# Get matching commands
var matching_commands := []
var matching_command_ends := []
for command in Globals.console_commands.keys():
if command.begins_with(input.text):
matching_commands.append(command)
matching_command_ends.append(command.substr(input.text.length()))
if not matching_commands:
return ERR_DOES_NOT_EXIST
matching_commands.sort_custom(AutocompleteMatchesSorter, "sort_ascending")
matching_command_ends.sort_custom(AutocompleteMatchesSorter, "sort_ascending")
# Get shared segment of matches (e.g. matches = [show_x, show_y], => shared = show_)
var shared := ""
for i in len(matching_command_ends[0]):
var c : String = matching_command_ends[0][i]
var c_in_all := true
for command in matching_command_ends.slice(1, len(matching_command_ends)):
if not command[i] == c:
c_in_all = false
break
if not c_in_all:
break
shared += c
# Jump to shared (e.g. input = "sh", matches = (show_x, show_y) => input' = "show_")
if shared:
input.append_at_cursor(shared)
return OK
# There are matches, but they share nothing so can't progress, show matches to user
# e.g. input = "n", matches = ["name", "none"], 2+ mutex. options -> can't add autocomplete
var r := str(matching_commands)
return write(r.substr(1, len(r) - 2), "\n[b][color=green]Autocomplete: [/color][/b]", "")
func _command_clear() -> CommandResponse:
input.clear()
output.clear()
return response_empty()
func _command_help(command_name: String = "help") -> CommandResponse:
var command : Command = Globals.console_commands.get(command_name)
if not command:
return response_error("no command '[color=green]%s[/color]'" % command_name)
return response_result(command.help)
func _command_exit() -> CommandResponse:
self.visible = false
return _command_clear()
func _command_quit() -> CommandResponse:
get_tree().quit()
return response_empty()
func _command_echo(output) -> CommandResponse:
return response_result(str(output))
func _command_print_tree() -> CommandResponse:
"""Print the full scene tree."""
return _command_print_children(NodePath("/root"))
func _command_print_children(start_node_path : NodePath) -> CommandResponse:
"""Print the children of a node."""
var start_node := get_node_or_null(start_node_path)
if not start_node:
var path_string := "'%s'" % start_node_path
if not start_node_path.is_absolute():
path_string = "'%s/%s'" % [get_path(), start_node_path]
return response_error("no such node %s" % [path_string])
return response_result(_helper_get_node_children_tree(start_node))
func _helper_get_node_children_tree(node : Node, indent_level : int = 0) -> String:
"""Recursively get the node's children as a tree."""
var output := _helper_get_indent(indent_level) + node.name + "\n"
for child in node.get_children():
output += _helper_get_node_children_tree(child, indent_level + 1)
return output
func _helper_get_indent(indent_level : int) -> String:
"""Return the indentation at the given indentation level."""
var output := ""
for _i in range(indent_level):
output += " "
return output
func _command_list() -> CommandResponse:
"""Print the list of all commands."""
var commands := Globals.console_commands
if len(commands) == 0:
return response_error("can't access Globals.console_commands")
var commands_list : String = ""
for command_name in commands:
commands_list += commands[command_name].name + "\t"
return response_result(commands_list)