@tool extends Node signal input_type_changed(input_type: InputType, controller: int) enum InputType { KEYBOARD_MOUSE, ## The input is from the keyboard and/or mouse. CONTROLLER ## The input is from a controller. } enum PathType { INPUT_ACTION, ## The path is an input action. JOYPAD_PATH, ## The path is a generic joypad path. SPECIFIC_PATH ## The path is a specific path. } var _cached_icons := {} var _custom_input_actions := {} var _cached_callables_lock := Mutex.new() var _cached_callables : Array[Callable] = [] var _last_input_type : InputType var _last_controller : int var _settings : ControllerSettings var _base_extension := "png" # Custom mouse velocity calculation, because Godot # doesn't implement it on some OSes apparently const _MOUSE_VELOCITY_DELTA := 0.1 var _t : float var _mouse_velocity : int var Mapper = preload("res://addons/controller_icons/Mapper.gd").new() # Default actions will be the builtin editor actions when # the script is at editor ("tool") level. To pickup more # actions available, these have to be queried manually var _builtin_keys := [ "input/ui_accept", "input/ui_cancel", "input/ui_copy", "input/ui_cut", "input/ui_down", "input/ui_end", "input/ui_filedialog_refresh", "input/ui_filedialog_show_hidden", "input/ui_filedialog_up_one_level", "input/ui_focus_next", "input/ui_focus_prev", "input/ui_graph_delete", "input/ui_graph_duplicate", "input/ui_home", "input/ui_left", "input/ui_menu", "input/ui_page_down", "input/ui_page_up", "input/ui_paste", "input/ui_redo", "input/ui_right", "input/ui_select", "input/ui_swap_input_direction", "input/ui_text_add_selection_for_next_occurrence", "input/ui_text_backspace", "input/ui_text_backspace_all_to_left", "input/ui_text_backspace_all_to_left.macos", "input/ui_text_backspace_word", "input/ui_text_backspace_word.macos", "input/ui_text_caret_add_above", "input/ui_text_caret_add_above.macos", "input/ui_text_caret_add_below", "input/ui_text_caret_add_below.macos", "input/ui_text_caret_document_end", "input/ui_text_caret_document_end.macos", "input/ui_text_caret_document_start", "input/ui_text_caret_document_start.macos", "input/ui_text_caret_down", "input/ui_text_caret_left", "input/ui_text_caret_line_end", "input/ui_text_caret_line_end.macos", "input/ui_text_caret_line_start", "input/ui_text_caret_line_start.macos", "input/ui_text_caret_page_down", "input/ui_text_caret_page_up", "input/ui_text_caret_right", "input/ui_text_caret_up", "input/ui_text_caret_word_left", "input/ui_text_caret_word_left.macos", "input/ui_text_caret_word_right", "input/ui_text_caret_word_right.macos", "input/ui_text_clear_carets_and_selection", "input/ui_text_completion_accept", "input/ui_text_completion_query", "input/ui_text_completion_replace", "input/ui_text_dedent", "input/ui_text_delete", "input/ui_text_delete_all_to_right", "input/ui_text_delete_all_to_right.macos", "input/ui_text_delete_word", "input/ui_text_delete_word.macos", "input/ui_text_indent", "input/ui_text_newline", "input/ui_text_newline_above", "input/ui_text_newline_blank", "input/ui_text_scroll_down", "input/ui_text_scroll_down.macos", "input/ui_text_scroll_up", "input/ui_text_scroll_up.macos", "input/ui_text_select_all", "input/ui_text_select_word_under_caret", "input/ui_text_select_word_under_caret.macos", "input/ui_text_submit", "input/ui_text_toggle_insert_mode", "input/ui_undo", "input/ui_up", ] func _set_last_input_type(__last_input_type, __last_controller): _last_input_type = __last_input_type _last_controller = __last_controller emit_signal("input_type_changed", _last_input_type, _last_controller) func _enter_tree(): process_mode = Node.PROCESS_MODE_ALWAYS if Engine.is_editor_hint(): _parse_input_actions() func _exit_tree(): Mapper.queue_free() func _parse_input_actions(): _custom_input_actions.clear() for key in _builtin_keys: var data : Dictionary = ProjectSettings.get_setting(key) if not data.is_empty() and data.has("events") and data["events"] is Array: _add_custom_input_action((key as String).trim_prefix("input/"), data) # A script running at editor ("tool") level only has # the default mappings. The way to get around this is # manually parsing the project file and adding the # new input actions to lookup. var proj_file := ConfigFile.new() if proj_file.load("res://project.godot"): printerr("Failed to open \"project.godot\"! Custom input actions will not work on editor view!") return if proj_file.has_section("input"): for input_action in proj_file.get_section_keys("input"): var data : Dictionary = proj_file.get_value("input", input_action) _add_custom_input_action(input_action, data) func _ready(): Input.joy_connection_changed.connect(_on_joy_connection_changed) _settings = load("res://addons/controller_icons/settings.tres") if not _settings: _settings = ControllerSettings.new() if _settings.custom_mapper: Mapper = _settings.custom_mapper.new() if _settings.custom_file_extension and not _settings.custom_file_extension.is_empty(): _base_extension = _settings.custom_file_extension # Wait a frame to give a chance for the app to initialize await get_tree().process_frame # Set input type to what's likely being used currently if Input.get_connected_joypads().is_empty(): _set_last_input_type(InputType.KEYBOARD_MOUSE, -1) else: _set_last_input_type(InputType.CONTROLLER, Input.get_connected_joypads().front()) func _on_joy_connection_changed(device, connected): if connected: _set_last_input_type(InputType.CONTROLLER, device) else: if Input.get_connected_joypads().is_empty(): _set_last_input_type(InputType.KEYBOARD_MOUSE, -1) else: _set_last_input_type(InputType.CONTROLLER, Input.get_connected_joypads().front()) func _input(event: InputEvent): var input_type = _last_input_type var controller = _last_controller match event.get_class(): "InputEventKey", "InputEventMouseButton": input_type = InputType.KEYBOARD_MOUSE "InputEventMouseMotion": if _settings.allow_mouse_remap and _test_mouse_velocity(event.relative): input_type = InputType.KEYBOARD_MOUSE "InputEventJoypadButton": input_type = InputType.CONTROLLER controller = event.device "InputEventJoypadMotion": if abs(event.axis_value) > _settings.joypad_deadzone: input_type = InputType.CONTROLLER controller = event.device if input_type != _last_input_type or controller != _last_controller: _set_last_input_type(input_type, controller) func _test_mouse_velocity(relative_vec: Vector2): if _t > _MOUSE_VELOCITY_DELTA: _t = 0 _mouse_velocity = 0 # We do a component sum instead of a length, to save on a # sqrt operation, and because length_squared is negatively # affected by low value vectors (<10). # It is also good enough for this system, so reliability # is sacrificed in favor of speed. _mouse_velocity += abs(relative_vec.x) + abs(relative_vec.y) return _mouse_velocity / _MOUSE_VELOCITY_DELTA > _settings.mouse_min_movement func _process(delta: float) -> void: _t += delta if not _cached_callables.is_empty() and _cached_callables_lock.try_lock(): # UPGRADE: In Godot 4.2, for-loop variables can be # statically typed: # for f: Callable in _cached_callables: for f in _cached_callables: if f.is_valid(): f.call() _cached_callables.clear() _cached_callables_lock.unlock() func _add_custom_input_action(input_action: String, data: Dictionary): _custom_input_actions[input_action] = data["events"] func refresh(): # All it takes is to signal icons to refresh paths emit_signal("input_type_changed", _last_input_type, _last_controller) func get_joypad_type(controller: int = _last_controller) -> ControllerSettings.Devices: return Mapper._get_joypad_type(controller, _settings.joypad_fallback) func parse_path(path: String, input_type = _last_input_type, last_controller = _last_controller) -> Texture: if typeof(input_type) == TYPE_NIL: return null var root_paths := _expand_path(path, input_type, last_controller) for root_path in root_paths: if _load_icon(root_path): continue return _cached_icons[root_path] return null func parse_event_modifiers(event: InputEvent) -> Array[Texture]: if not event or not event is InputEventWithModifiers: return [] var icons : Array[Texture] = [] var modifiers : Array[String] = [] if event.command_or_control_autoremap: match OS.get_name(): "macOS": modifiers.push_back("key/command") _: modifiers.push_back("key/ctrl") if event.ctrl_pressed and not event.command_or_control_autoremap: modifiers.push_back("key/ctrl") if event.shift_pressed: modifiers.push_back("key/shift") if event.alt_pressed: modifiers.push_back("key/alt") if event.meta_pressed and not event.command_or_control_autoremap: match OS.get_name(): "macOS": modifiers.push_back("key/command") _: modifiers.push_back("key/win") for modifier in modifiers: for icon_path in _expand_path(modifier, InputType.KEYBOARD_MOUSE, -1): if _load_icon(icon_path) == OK: icons.push_back(_cached_icons[icon_path]) return icons func parse_path_to_tts(path: String, input_type: int = _last_input_type, controller: int = _last_controller) -> String: if input_type == null: return "" var tts = _convert_path_to_asset_file(path, input_type, controller) return _convert_asset_file_to_tts(tts.get_basename().get_file()) func parse_event(event: InputEvent) -> Texture: var path = _convert_event_to_path(event) if path.is_empty(): return null var base_paths := [ _settings.custom_asset_dir + "/", "res://addons/controller_icons/assets/" ] for base_path in base_paths: if base_path.is_empty(): continue base_path += path + "." + _base_extension if _load_icon(base_path): continue return _cached_icons[base_path] return null func get_path_type(path: String) -> PathType: if _custom_input_actions.has(path) or InputMap.has_action(path): return PathType.INPUT_ACTION elif path.get_slice("/", 0) == "joypad": return PathType.JOYPAD_PATH else: return PathType.SPECIFIC_PATH func get_matching_event(path: String, input_type: InputType = _last_input_type, controller: int = _last_controller) -> InputEvent: var events : Array if _custom_input_actions.has(path): events = _custom_input_actions[path] else: events = InputMap.action_get_events(path) var fallback = null for event in events: if not is_instance_valid(event): continue match event.get_class(): "InputEventKey", "InputEventMouse", "InputEventMouseMotion", "InputEventMouseButton": if input_type == InputType.KEYBOARD_MOUSE: return event "InputEventJoypadButton", "InputEventJoypadMotion": if input_type == InputType.CONTROLLER: # Use the first device specific mapping if there is one. if event.device == controller: return event # Otherwise use the first "all devices" mapping. elif fallback == null and event.device < 0: fallback = event return fallback func _expand_path(path: String, input_type: int, controller: int) -> Array: var paths := [] var base_paths := [ _settings.custom_asset_dir + "/", "res://addons/controller_icons/assets/" ] for base_path in base_paths: if base_path.is_empty(): continue base_path += _convert_path_to_asset_file(path, input_type, controller) paths.push_back(base_path + "." + _base_extension) return paths func _convert_path_to_asset_file(path: String, input_type: int, controller: int) -> String: match get_path_type(path): PathType.INPUT_ACTION: var event := get_matching_event(path, input_type, controller) if event: return _convert_event_to_path(event) return path PathType.JOYPAD_PATH: return Mapper._convert_joypad_path(path, controller, _settings.joypad_fallback) PathType.SPECIFIC_PATH, _: return path func _convert_asset_file_to_tts(path: String) -> String: match path: "shift_alt": return "shift" "esc": return "escape" "backspace_alt": return "backspace" "enter_alt": return "enter" "enter_tall": return "keypad enter" "arrow_left": return "left arrow" "arrow_right": return "right arrow" "del": return "delete" "arrow_up": return "up arrow" "arrow_down": return "down arrow" "shift_alt": return "shift" "ctrl": return "control" "kp_add": return "keypad plus" "mark_left": return "left mark" "mark_right": return "right mark" "bracket_left": return "left bracket" "bracket_right": return "right bracket" "tilda": return "tilde" "lb": return "left bumper" "rb": return "right bumper" "lt": return "left trigger" "rt": return "right trigger" "l_stick_click": return "left stick click" "r_stick_click": return "right stick click" "l_stick": return "left stick" "r_stick": return "right stick" _: return path func _convert_event_to_path(event: InputEvent): if event is InputEventKey: # If this is a physical key, convert to localized scancode if event.keycode == 0: return _convert_key_to_path(DisplayServer.keyboard_get_keycode_from_physical(event.physical_keycode)) return _convert_key_to_path(event.keycode) elif event is InputEventMouseButton: return _convert_mouse_button_to_path(event.button_index) elif event is InputEventJoypadButton: return _convert_joypad_button_to_path(event.button_index, event.device) elif event is InputEventJoypadMotion: return _convert_joypad_motion_to_path(event.axis, event.device) func _convert_key_to_path(scancode: int): match scancode: KEY_ESCAPE: return "key/esc" KEY_TAB: return "key/tab" KEY_BACKSPACE: return "key/backspace_alt" KEY_ENTER: return "key/enter_alt" KEY_KP_ENTER: return "key/enter_tall" KEY_INSERT: return "key/insert" KEY_DELETE: return "key/del" KEY_PRINT: return "key/print_screen" KEY_HOME: return "key/home" KEY_END: return "key/end" KEY_LEFT: return "key/arrow_left" KEY_UP: return "key/arrow_up" KEY_RIGHT: return "key/arrow_right" KEY_DOWN: return "key/arrow_down" KEY_PAGEUP: return "key/page_up" KEY_PAGEDOWN: return "key/page_down" KEY_SHIFT: return "key/shift_alt" KEY_CTRL: return "key/ctrl" KEY_META: match OS.get_name(): "macOS": return "key/command" _: return "key/meta" KEY_ALT: return "key/alt" KEY_CAPSLOCK: return "key/caps_lock" KEY_NUMLOCK: return "key/num_lock" KEY_F1: return "key/f1" KEY_F2: return "key/f2" KEY_F3: return "key/f3" KEY_F4: return "key/f4" KEY_F5: return "key/f5" KEY_F6: return "key/f6" KEY_F7: return "key/f7" KEY_F8: return "key/f8" KEY_F9: return "key/f9" KEY_F10: return "key/f10" KEY_F11: return "key/f11" KEY_F12: return "key/f12" KEY_KP_MULTIPLY, KEY_ASTERISK: return "key/asterisk" KEY_KP_SUBTRACT, KEY_MINUS: return "key/minus" KEY_KP_ADD: return "key/plus_tall" KEY_KP_0: return "key/0" KEY_KP_1: return "key/1" KEY_KP_2: return "key/2" KEY_KP_3: return "key/3" KEY_KP_4: return "key/4" KEY_KP_5: return "key/5" KEY_KP_6: return "key/6" KEY_KP_7: return "key/7" KEY_KP_8: return "key/8" KEY_KP_9: return "key/9" KEY_UNKNOWN: return "" KEY_SPACE: return "key/space" KEY_QUOTEDBL: return "key/quote" KEY_PLUS: return "key/plus" KEY_0: return "key/0" KEY_1: return "key/1" KEY_2: return "key/2" KEY_3: return "key/3" KEY_4: return "key/4" KEY_5: return "key/5" KEY_6: return "key/6" KEY_7: return "key/7" KEY_8: return "key/8" KEY_9: return "key/9" KEY_SEMICOLON: return "key/semicolon" KEY_LESS: return "key/mark_left" KEY_GREATER: return "key/mark_right" KEY_QUESTION: return "key/question" KEY_A: return "key/a" KEY_B: return "key/b" KEY_C: return "key/c" KEY_D: return "key/d" KEY_E: return "key/e" KEY_F: return "key/f" KEY_G: return "key/g" KEY_H: return "key/h" KEY_I: return "key/i" KEY_J: return "key/j" KEY_K: return "key/k" KEY_L: return "key/l" KEY_M: return "key/m" KEY_N: return "key/n" KEY_O: return "key/o" KEY_P: return "key/p" KEY_Q: return "key/q" KEY_R: return "key/r" KEY_S: return "key/s" KEY_T: return "key/t" KEY_U: return "key/u" KEY_V: return "key/v" KEY_W: return "key/w" KEY_X: return "key/x" KEY_Y: return "key/y" KEY_Z: return "key/z" KEY_BRACKETLEFT: return "key/bracket_left" KEY_BACKSLASH: return "key/slash" KEY_SLASH: return "key/forward_slash" KEY_BRACKETRIGHT: return "key/bracket_right" KEY_ASCIITILDE: return "key/tilda" KEY_QUOTELEFT: return "key/backtick" KEY_APOSTROPHE: return "key/apostrophe" KEY_COMMA: return "key/comma" KEY_EQUAL: return "key/equals" KEY_PERIOD, KEY_KP_PERIOD: return "key/period" _: return "" func _convert_mouse_button_to_path(button_index: int): match button_index: MOUSE_BUTTON_LEFT: return "mouse/left" MOUSE_BUTTON_RIGHT: return "mouse/right" MOUSE_BUTTON_MIDDLE: return "mouse/middle" MOUSE_BUTTON_WHEEL_UP: return "mouse/wheel_up" MOUSE_BUTTON_WHEEL_DOWN: return "mouse/wheel_down" MOUSE_BUTTON_XBUTTON1: return "mouse/side_down" MOUSE_BUTTON_XBUTTON2: return "mouse/side_up" _: return "mouse/sample" func _convert_joypad_button_to_path(button_index: int, controller: int): var path match button_index: JOY_BUTTON_A: path = "joypad/a" JOY_BUTTON_B: path = "joypad/b" JOY_BUTTON_X: path = "joypad/x" JOY_BUTTON_Y: path = "joypad/y" JOY_BUTTON_LEFT_SHOULDER: path = "joypad/lb" JOY_BUTTON_RIGHT_SHOULDER: path = "joypad/rb" JOY_BUTTON_LEFT_STICK: path = "joypad/l_stick_click" JOY_BUTTON_RIGHT_STICK: path = "joypad/r_stick_click" JOY_BUTTON_BACK: path = "joypad/select" JOY_BUTTON_START: path = "joypad/start" JOY_BUTTON_DPAD_UP: path = "joypad/dpad_up" JOY_BUTTON_DPAD_DOWN: path = "joypad/dpad_down" JOY_BUTTON_DPAD_LEFT: path = "joypad/dpad_left" JOY_BUTTON_DPAD_RIGHT: path = "joypad/dpad_right" JOY_BUTTON_GUIDE: path = "joypad/home" JOY_BUTTON_MISC1: path = "joypad/share" _: return "" return Mapper._convert_joypad_path(path, controller, _settings.joypad_fallback) func _convert_joypad_motion_to_path(axis: int, controller: int): var path : String match axis: JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y: path = "joypad/l_stick" JOY_AXIS_RIGHT_X, JOY_AXIS_RIGHT_Y: path = "joypad/r_stick" JOY_AXIS_TRIGGER_LEFT: path = "joypad/lt" JOY_AXIS_TRIGGER_RIGHT: path = "joypad/rt" _: return "" return Mapper._convert_joypad_path(path, controller, _settings.joypad_fallback) func _load_icon(path: String) -> int: if _cached_icons.has(path): return OK var tex = null if path.begins_with("res://"): if ResourceLoader.exists(path): tex = load(path) if not tex: return ERR_FILE_CORRUPT else: return ERR_FILE_NOT_FOUND else: if not FileAccess.file_exists(path): return ERR_FILE_NOT_FOUND var img := Image.new() var err = img.load(path) if err != OK: return err tex = ImageTexture.new() tex.create_from_image(img) _cached_icons[path] = tex return OK func _defer_texture_load(f: Callable) -> void: _cached_callables_lock.lock() _cached_callables.push_back(f) _cached_callables_lock.unlock()