aboutsummaryrefslogtreecommitdiff
path: root/game/addons
diff options
context:
space:
mode:
author George L. Albany <Megacake1234@gmail.com>2023-02-21 22:30:12 +0100
committer GitHub <noreply@github.com>2023-02-21 22:30:12 +0100
commitf1c23555878ee4b0e40c6af5f89f05b666012309 (patch)
tree978dfdfd1ac6940414af5e19128060419076de76 /game/addons
parentfb9e316a18139ea6b6ffe3b237796b42d7114738 (diff)
Add Keychain plugin for Controls tab (#15)
* Add modified Keychain plugin for future Controls tab See https://github.com/Orama-Interactive/Keychain/tree/4.x Added Events autoload singleton for global eventing namespace Added Events.Options for global options functionality * Add Controls tab via Keychain plugin Use Events.Options for save, load, and reset of settings Separate OptionMenu tabs into scene files Add locale saving and loading Refactor SettingNodes scripts for more generalized use Remove random prints Remove useless spinbox signal connection Make Resolution consistently use Vector2i * Implement Godot project overrides for resolution and window mode Overrides are necessary as Godot does not load resolution or window mode on startup, so an override is necessary to ensure this happens. Add null checks to SettingHSlider and SettingOptionButton * Fix incorrect resolution value in ResolutionSelector * Correct project settings override behavior in editor Godot normally tries to overwrite the project settings in the editor, a template feature tag must be used to prevent the editor from overwriting the project.godot settings. * Fix Orama-Interactive/Keychain#8
Diffstat (limited to 'game/addons')
-rw-r--r--game/addons/keychain/Keychain.gd212
-rw-r--r--game/addons/keychain/LICENSE21
-rw-r--r--game/addons/keychain/ShortcutEdit.gd416
-rw-r--r--game/addons/keychain/ShortcutEdit.tscn113
-rw-r--r--game/addons/keychain/ShortcutProfile.gd38
-rw-r--r--game/addons/keychain/ShortcutSelectorDialog.gd199
-rw-r--r--game/addons/keychain/ShortcutSelectorDialog.tscn46
-rw-r--r--game/addons/keychain/assets/add.svg1
-rw-r--r--game/addons/keychain/assets/add.svg.import37
-rw-r--r--game/addons/keychain/assets/close.svg1
-rw-r--r--game/addons/keychain/assets/close.svg.import37
-rw-r--r--game/addons/keychain/assets/edit.svg1
-rw-r--r--game/addons/keychain/assets/edit.svg.import37
-rw-r--r--game/addons/keychain/assets/folder.svg1
-rw-r--r--game/addons/keychain/assets/folder.svg.import37
-rw-r--r--game/addons/keychain/assets/joy_axis.svg1
-rw-r--r--game/addons/keychain/assets/joy_axis.svg.import37
-rw-r--r--game/addons/keychain/assets/joy_button.svg1
-rw-r--r--game/addons/keychain/assets/joy_button.svg.import37
-rw-r--r--game/addons/keychain/assets/keyboard.svg1
-rw-r--r--game/addons/keychain/assets/keyboard.svg.import37
-rw-r--r--game/addons/keychain/assets/keyboard_physical.svg1
-rw-r--r--game/addons/keychain/assets/keyboard_physical.svg.import37
-rw-r--r--game/addons/keychain/assets/mouse.svg1
-rw-r--r--game/addons/keychain/assets/mouse.svg.import37
-rw-r--r--game/addons/keychain/assets/shortcut.svg1
-rw-r--r--game/addons/keychain/assets/shortcut.svg.import37
-rw-r--r--game/addons/keychain/plugin.cfg7
-rw-r--r--game/addons/keychain/plugin.gd10
-rw-r--r--game/addons/keychain/profiles/default.tres9
-rw-r--r--game/addons/keychain/translations/README.md4
-rw-r--r--game/addons/keychain/translations/Translations.pot209
-rw-r--r--game/addons/keychain/translations/el_GR.po215
33 files changed, 1879 insertions, 0 deletions
diff --git a/game/addons/keychain/Keychain.gd b/game/addons/keychain/Keychain.gd
new file mode 100644
index 0000000..ff939f3
--- /dev/null
+++ b/game/addons/keychain/Keychain.gd
@@ -0,0 +1,212 @@
+extends Node
+
+const TRANSLATIONS_PATH := "res://addons/keychain/translations"
+const PROFILES_PATH := "user://shortcut_profiles"
+
+# Change these settings
+var profiles := [preload("profiles/default.tres")]
+var selected_profile: ShortcutProfile = profiles[0]
+var profile_index := 0
+# Syntax: "action_name": InputAction.new("Action Display Name", "Group", true)
+# Note that "action_name" must already exist in the Project's Input Map.
+var actions := {}
+# Syntax: "Group Name": InputGroup.new("Parent Group Name")
+var groups := {}
+var ignore_actions := []
+var ignore_ui_actions := true
+var changeable_types := [true, true, true, true]
+var multiple_menu_accelerators := false
+var config_path := "user://cache.ini"
+var config_file: ConfigFile
+
+
+class InputAction:
+ var display_name := ""
+ var group := ""
+ var global := true
+
+ func _init(_display_name := "",_group := "",_global := true):
+ display_name = _display_name
+ group = _group
+ global = _global
+
+ func update_node(_action: String) -> void:
+ pass
+
+ func handle_input(_event: InputEvent, _action: String) -> bool:
+ return false
+
+
+# This class is useful for the accelerators of PopupMenu items
+# It's possible for PopupMenu items to have multiple shortcuts by using
+# set_item_shortcut(), but we have no control over the accelerator text that appears.
+# Thus, we are stuck with using accelerators instead of shortcuts.
+# If Godot ever receives the ability to change the accelerator text of the items,
+# we could in theory remove this class.
+# If you don't care about PopupMenus in the same scene as ShortcutEdit
+# such as projects like Pixelorama where everything is in the same scene,
+# then you can ignore this class.
+class MenuInputAction:
+ extends InputAction
+ var node_path := ""
+ var node: PopupMenu
+ var menu_item_id := 0
+ var echo := false
+
+ func _init(
+ _display_name := "",
+ _group := "",
+ _global := true,
+ _node_path := "",
+ _menu_item_id := 0,
+ _echo := false
+ ) -> void:
+ super._init(_display_name, _group, _global)
+ node_path = _node_path
+ menu_item_id = _menu_item_id
+ echo = _echo
+
+ func get_node(root: Node) -> void:
+ var temp_node = root.get_node(node_path)
+ if temp_node is PopupMenu:
+ node = node
+ elif temp_node is MenuButton:
+ node = temp_node.get_popup()
+
+ func update_node(action: String) -> void:
+ if !node:
+ return
+ var first_key: InputEventKey = Keychain.action_get_first_key(action)
+ var accel := first_key.get_keycode_with_modifiers() if first_key else 0
+ node.set_item_accelerator(menu_item_id, accel)
+
+ func handle_input(event: InputEvent, action: String) -> bool:
+ if not node:
+ return false
+ if event.is_action_pressed(action, false, true):
+ if event is InputEventKey:
+ var acc: int = node.get_item_accelerator(menu_item_id)
+ # If the event is the same as the menu item's accelerator, skip
+ if acc == event.get_keycode_with_modifiers():
+ return true
+ node.emit_signal("id_pressed", menu_item_id)
+ return true
+ if event.is_action(action, true) and echo:
+ if event.is_echo():
+ node.emit_signal("id_pressed", menu_item_id)
+ return true
+
+ return false
+
+
+class InputGroup:
+ var parent_group := ""
+ var folded := true
+ var tree_item: TreeItem
+
+ func _init(_parent_group := "", _folded := true) -> void:
+ parent_group = _parent_group
+ folded = _folded
+
+
+func _ready() -> void:
+ if !config_file:
+ config_file = ConfigFile.new()
+ if !config_path.is_empty():
+ config_file.load(config_path)
+
+ set_process_input(multiple_menu_accelerators)
+
+ # Load shortcut profiles
+ DirAccess.make_dir_recursive_absolute(PROFILES_PATH)
+ var profile_dir := DirAccess.open(PROFILES_PATH)
+ profile_dir.make_dir(PROFILES_PATH)
+ profile_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var file_name = profile_dir.get_next()
+ while file_name != "":
+ if !profile_dir.current_is_dir():
+ if file_name.get_extension() == "tres":
+ var file = load(PROFILES_PATH.path_join(file_name))
+ if file is ShortcutProfile:
+ profiles.append(file)
+ file_name = profile_dir.get_next()
+
+ # If there are no profiles besides the default, create one custom
+ if profiles.size() == 1:
+ var profile := ShortcutProfile.new()
+ profile.name = "Custom"
+ profile.resource_path = PROFILES_PATH.path_join("custom.tres")
+ var saved := profile.save()
+ if saved:
+ profiles.append(profile)
+
+ for profile in profiles:
+ profile.fill_bindings()
+
+ var l18n_dir := DirAccess.open(TRANSLATIONS_PATH)
+ l18n_dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ file_name = l18n_dir.get_next()
+ while file_name != "":
+ if !l18n_dir.current_is_dir():
+ if file_name.get_extension() == "po":
+ var t: Translation = load(TRANSLATIONS_PATH.path_join(file_name))
+ TranslationServer.add_translation(t)
+ file_name = l18n_dir.get_next()
+
+ profile_index = config_file.get_value("shortcuts", "shortcuts_profile", 0)
+ change_profile(profile_index)
+
+ for action in actions:
+ var input_action: InputAction = actions[action]
+ if input_action is MenuInputAction:
+ input_action.get_node(get_tree().current_scene)
+
+
+func _input(event: InputEvent) -> void:
+ if event is InputEventMouseMotion:
+ return
+
+ for action in actions:
+ var input_action: InputAction = actions[action]
+ var done: bool = input_action.handle_input(event, action)
+ if done:
+ return
+
+
+func change_profile(index: int) -> void:
+ if index >= profiles.size():
+ index = profiles.size() - 1
+ profile_index = index
+ selected_profile = profiles[index]
+ for action in selected_profile.bindings:
+ action_erase_events(action)
+ for event in selected_profile.bindings[action]:
+ action_add_event(action, event)
+
+
+func action_add_event(action: String, event: InputEvent) -> void:
+ InputMap.action_add_event(action, event)
+ if action in actions:
+ actions[action].update_node(action)
+
+
+func action_erase_event(action: String, event: InputEvent) -> void:
+ InputMap.action_erase_event(action, event)
+ if action in actions:
+ actions[action].update_node(action)
+
+
+func action_erase_events(action: String) -> void:
+ InputMap.action_erase_events(action)
+ if action in actions:
+ actions[action].update_node(action)
+
+
+func action_get_first_key(action: String) -> InputEventKey:
+ var first_key: InputEventKey = null
+ var events := InputMap.action_get_events(action)
+ for event in events:
+ if event is InputEventKey:
+ first_key = event
+ break
+ return first_key
diff --git a/game/addons/keychain/LICENSE b/game/addons/keychain/LICENSE
new file mode 100644
index 0000000..5deb9a7
--- /dev/null
+++ b/game/addons/keychain/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Orama Interactive
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/game/addons/keychain/ShortcutEdit.gd b/game/addons/keychain/ShortcutEdit.gd
new file mode 100644
index 0000000..fe4e69f
--- /dev/null
+++ b/game/addons/keychain/ShortcutEdit.gd
@@ -0,0 +1,416 @@
+extends Control
+
+enum { KEYBOARD, MOUSE, JOY_BUTTON, JOY_AXIS }
+
+const MOUSE_BUTTON_NAMES := [
+ "Left Button",
+ "Right Button",
+ "Middle Button",
+ "Wheel Up Button",
+ "Wheel Down Button",
+ "Wheel Left Button",
+ "Wheel Right Button",
+ "X Button 1",
+ "X Button 2",
+]
+
+const JOY_BUTTON_NAMES := [
+ "DualShock Cross, Xbox A, Nintendo B",
+ "DualShock Circle, Xbox B, Nintendo A",
+ "DualShock Square, Xbox X, Nintendo Y",
+ "DualShock Triangle, Xbox Y, Nintendo X",
+ "L, L1",
+ "R, R1",
+ "L2",
+ "R2",
+ "L3",
+ "R3",
+ "Select, DualShock Share, Nintendo -",
+ "Start, DualShock Options, Nintendo +",
+ "D-Pad Up",
+ "D-Pad Down",
+ "D-Pad Left",
+ "D-Pad Right",
+ "Home, DualShock PS, Guide",
+ "Xbox Share, PS5 Microphone, Nintendo Capture",
+ "Xbox Paddle 1",
+ "Xbox Paddle 2",
+ "Xbox Paddle 3",
+ "Xbox Paddle 4",
+ "PS4/5 Touchpad",
+]
+
+const JOY_AXIS_NAMES := [
+ "(Left Stick Left)",
+ "(Left Stick Right)",
+ "(Left Stick Up)",
+ "(Left Stick Down)",
+ "(Right Stick Left)",
+ "(Right Stick Right)",
+ "(Right Stick Up)",
+ "(Right Stick Down)",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "(L2)",
+ "",
+ "(R2)",
+ "",
+ "",
+ "",
+ "",
+]
+
+var currently_editing_tree_item: TreeItem
+var is_editing := false
+# Textures taken from Godot https://github.com/godotengine/godot/tree/master/editor/icons
+var add_tex: Texture2D = preload("assets/add.svg")
+var edit_tex: Texture2D = preload("assets/edit.svg")
+var delete_tex: Texture2D = preload("assets/close.svg")
+var joy_axis_tex: Texture2D = preload("assets/joy_axis.svg")
+var joy_button_tex: Texture2D = preload("assets/joy_button.svg")
+var key_tex: Texture2D = preload("assets/keyboard.svg")
+var key_phys_tex: Texture2D = preload("assets/keyboard_physical.svg")
+var mouse_tex: Texture2D = preload("assets/mouse.svg")
+var shortcut_tex: Texture2D = preload("assets/shortcut.svg")
+var folder_tex: Texture2D = preload("assets/folder.svg")
+
+@onready var tree: Tree = $VBoxContainer/ShortcutTree
+@onready var profile_option_button: OptionButton = find_child("ProfileOptionButton")
+@onready var rename_profile_button: Button = find_child("RenameProfile")
+@onready var delete_profile_button: Button = find_child("DeleteProfile")
+@onready var shortcut_type_menu: PopupMenu = $ShortcutTypeMenu
+@onready var keyboard_shortcut_selector: ConfirmationDialog = $KeyboardShortcutSelectorDialog
+@onready var mouse_shortcut_selector: ConfirmationDialog = $MouseShortcutSelectorDialog
+@onready var joy_key_shortcut_selector: ConfirmationDialog = $JoyKeyShortcutSelectorDialog
+@onready var joy_axis_shortcut_selector: ConfirmationDialog = $JoyAxisShortcutSelectorDialog
+@onready var profile_settings: ConfirmationDialog = $ProfileSettings
+@onready var profile_name: LineEdit = $ProfileSettings/ProfileName
+@onready var delete_confirmation: ConfirmationDialog = $DeleteConfirmation
+
+
+func _ready() -> void:
+ for profile in Keychain.profiles:
+ profile_option_button.add_item(profile.name)
+
+ _fill_selector_options()
+
+ # Remove input types that are not changeable
+ var i := 0
+ for type in Keychain.changeable_types:
+ if !type:
+ shortcut_type_menu.remove_item(i)
+ else:
+ i += 1
+
+ profile_option_button.select(Keychain.profile_index)
+ _on_ProfileOptionButton_item_selected(Keychain.profile_index)
+ if OS.get_name() == "HTML5":
+ $VBoxContainer/HBoxContainer/OpenProfileFolder.queue_free()
+
+
+func _construct_tree() -> void:
+ var buttons_disabled := false if Keychain.selected_profile.customizable else true
+ var tree_root: TreeItem = tree.create_item()
+ for group in Keychain.groups: # Create groups
+ var input_group: Keychain.InputGroup = Keychain.groups[group]
+ _create_group_tree_item(input_group, group)
+
+ for action in InputMap.get_actions(): # Fill the tree with actions and their events
+ if action in Keychain.ignore_actions:
+ continue
+ if Keychain.ignore_ui_actions and (action as String).begins_with("ui_"):
+ continue
+
+ var display_name := get_action_name(action)
+ var group_name := ""
+ if action in Keychain.actions:
+ var input_action: Keychain.InputAction = Keychain.actions[action]
+ group_name = input_action.group
+
+ var tree_item: TreeItem
+ if not group_name.is_empty() and group_name in Keychain.groups:
+ var input_group: Keychain.InputGroup = Keychain.groups[group_name]
+ var group_root: TreeItem = input_group.tree_item
+ tree_item = tree.create_item(group_root)
+
+ else:
+ tree_item = tree.create_item(tree_root)
+
+ tree_item.set_text(0, display_name)
+ tree_item.set_metadata(0, action)
+ tree_item.set_icon(0, shortcut_tex)
+ for event in InputMap.action_get_events(action):
+ add_event_tree_item(event, tree_item)
+
+ tree_item.add_button(0, add_tex, 0, buttons_disabled, "Add")
+ tree_item.add_button(0, delete_tex, 1, buttons_disabled, "Delete")
+ tree_item.collapsed = true
+
+
+func _fill_selector_options() -> void:
+ keyboard_shortcut_selector.entered_shortcut.visible = true
+ keyboard_shortcut_selector.option_button.visible = false
+ mouse_shortcut_selector.input_type_l.text = "Mouse Button Index:"
+ joy_key_shortcut_selector.input_type_l.text = "Joypad Button Index:"
+ joy_axis_shortcut_selector.input_type_l.text = "Joypad Axis Index:"
+
+ var mouse_option_button: OptionButton = mouse_shortcut_selector.option_button
+ for option in MOUSE_BUTTON_NAMES:
+ mouse_option_button.add_item(option)
+
+ var joy_key_option_button: OptionButton = joy_key_shortcut_selector.option_button
+ for i in JOY_BUTTON_MAX:
+ var text: String = tr("Button") + " %s" % i
+ if i < JOY_BUTTON_NAMES.size():
+ text += " (%s)" % tr(JOY_BUTTON_NAMES[i])
+ joy_key_option_button.add_item(text)
+
+ var joy_axis_option_button: OptionButton = joy_axis_shortcut_selector.option_button
+ var i := 0.0
+ for option in JOY_AXIS_NAMES:
+ var sign_symbol = "+" if floor(i) != i else "-"
+ var text: String = tr("Axis") + " %s %s %s" % [floor(i), sign_symbol, tr(option)]
+ joy_axis_option_button.add_item(text)
+ i += 0.5
+
+
+func _create_group_tree_item(group: Keychain.InputGroup, group_name: String) -> void:
+ if group.tree_item:
+ return
+
+ var group_root: TreeItem
+ if group.parent_group:
+ var parent_group: Keychain.InputGroup = Keychain.groups[group.parent_group]
+ _create_group_tree_item(parent_group, group.parent_group)
+ group_root = tree.create_item(parent_group.tree_item)
+ else:
+ group_root = tree.create_item(tree.get_root())
+ group_root.set_text(0, group_name)
+ group_root.set_icon(0, folder_tex)
+ group.tree_item = group_root
+ if group.folded:
+ group_root.collapsed = true
+
+
+func get_action_name(action: String) -> String:
+ var display_name := ""
+ if action in Keychain.actions:
+ display_name = Keychain.actions[action].display_name
+
+ if display_name.is_empty():
+ display_name = _humanize_snake_case(action)
+ return display_name
+
+
+func _humanize_snake_case(text: String) -> String:
+ text = text.replace("_", " ")
+ var first_letter := text.left(1)
+ first_letter = first_letter.capitalize()
+ text = text.right(-1)
+ text = text.insert(0, first_letter)
+ return text
+
+
+func add_event_tree_item(event: InputEvent, action_tree_item: TreeItem) -> void:
+ var event_class := event.get_class()
+ match event_class:
+ "InputEventKey":
+ if !Keychain.changeable_types[0]:
+ return
+ "InputEventMouseButton":
+ if !Keychain.changeable_types[1]:
+ return
+ "InputEventJoypadButton":
+ if !Keychain.changeable_types[2]:
+ return
+ "InputEventJoypadMotion":
+ if !Keychain.changeable_types[3]:
+ return
+
+ var buttons_disabled := false if Keychain.selected_profile.customizable else true
+ var event_tree_item: TreeItem = tree.create_item(action_tree_item)
+ event_tree_item.set_text(0, event_to_str(event))
+ event_tree_item.set_metadata(0, event)
+ match event_class:
+ "InputEventKey":
+ var scancode: int = event.get_keycode_with_modifiers()
+ if scancode > 0:
+ event_tree_item.set_icon(0, key_tex)
+ else:
+ event_tree_item.set_icon(0, key_phys_tex)
+ "InputEventMouseButton":
+ event_tree_item.set_icon(0, mouse_tex)
+ "InputEventJoypadButton":
+ event_tree_item.set_icon(0, joy_button_tex)
+ "InputEventJoypadMotion":
+ event_tree_item.set_icon(0, joy_axis_tex)
+ event_tree_item.add_button(0, edit_tex, 0, buttons_disabled, "Edit")
+ event_tree_item.add_button(0, delete_tex, 1, buttons_disabled, "Delete")
+
+
+func event_to_str(event: InputEvent) -> String:
+ var output := ""
+ if event is InputEventKey:
+ var scancode: int = 0
+ if event.keycode != 0:
+ scancode = event.get_keycode_with_modifiers()
+ var physical_str := ""
+ if scancode == 0:
+ scancode = event.get_physical_keycode_with_modifiers()
+ physical_str = " " + tr("(Physical)")
+ output = OS.get_keycode_string(scancode) + physical_str
+
+ elif event is InputEventMouseButton:
+ output = tr(MOUSE_BUTTON_NAMES[event.button_index - 1])
+
+ elif event is InputEventJoypadButton:
+ var button_index: int = event.button_index
+ output = tr("Button")
+ if button_index >= JOY_BUTTON_NAMES.size():
+ output += " %s" % button_index
+ else:
+ output += " %s (%s)" % [button_index, tr(JOY_BUTTON_NAMES[button_index])]
+
+ elif event is InputEventJoypadMotion:
+ var positive_axis: bool = event.axis_value > 0
+ var axis_value: int = event.axis * 2 + int(positive_axis)
+ var sign_symbol = "+" if positive_axis else "-"
+ output = tr("Axis")
+ output += " %s %s %s" % [event.axis, sign_symbol, tr(JOY_AXIS_NAMES[axis_value])]
+ return output
+
+
+func _on_shortcut_tree_button_clicked(item: TreeItem, _column: int, id: int, _mbi: int) -> void:
+ var action = item.get_metadata(0)
+ currently_editing_tree_item = item
+ if action is StringName:
+ if id == 0: # Add
+ var rect: Rect2 = tree.get_item_area_rect(item, 0)
+ rect.position.x = rect.end.x - 42
+ rect.position.y += 42 - tree.get_scroll().y
+ rect.position += global_position
+ rect.size = Vector2(110, 23 * shortcut_type_menu.get_item_count())
+ shortcut_type_menu.popup(rect)
+ elif id == 1: # Delete
+ Keychain.action_erase_events(action)
+ Keychain.selected_profile.change_action(action)
+ for child in item.get_children():
+ child.free()
+
+ elif action is InputEvent:
+ var parent_action = item.get_parent().get_metadata(0)
+ if id == 0: # Edit
+ if action is InputEventKey:
+ keyboard_shortcut_selector.popup_centered()
+ elif action is InputEventMouseButton:
+ mouse_shortcut_selector.popup_centered()
+ elif action is InputEventJoypadButton:
+ joy_key_shortcut_selector.popup_centered()
+ elif action is InputEventJoypadMotion:
+ joy_axis_shortcut_selector.popup_centered()
+ elif id == 1: # Delete
+ if not parent_action is StringName:
+ return
+ Keychain.action_erase_event(parent_action, action)
+ Keychain.selected_profile.change_action(parent_action)
+ item.free()
+
+
+func _on_ShortcutTree_item_activated() -> void:
+ var selected_item: TreeItem = tree.get_selected()
+ if selected_item.get_button_count(0) > 0 and !selected_item.is_button_disabled(0, 0):
+ _on_shortcut_tree_button_clicked(tree.get_selected(), 0, 0, 0)
+
+
+func _on_ShortcutTypeMenu_id_pressed(id: int) -> void:
+ if id == KEYBOARD:
+ keyboard_shortcut_selector.popup_centered()
+ elif id == MOUSE:
+ mouse_shortcut_selector.popup_centered()
+ elif id == JOY_BUTTON:
+ joy_key_shortcut_selector.popup_centered()
+ elif id == JOY_AXIS:
+ joy_axis_shortcut_selector.popup_centered()
+
+
+func _on_ProfileOptionButton_item_selected(index: int) -> void:
+ Keychain.change_profile(index)
+ rename_profile_button.disabled = false if Keychain.selected_profile.customizable else true
+ delete_profile_button.disabled = false if Keychain.selected_profile.customizable else true
+
+ # Re-construct the tree
+ for group in Keychain.groups:
+ Keychain.groups[group].tree_item = null
+ tree.clear()
+ _construct_tree()
+ Keychain.config_file.set_value("shortcuts", "shortcuts_profile", index)
+ Keychain.config_file.save(Keychain.config_path)
+
+
+func _on_NewProfile_pressed() -> void:
+ is_editing = false
+ profile_name.text = "New Shortcut Profile"
+ profile_settings.title = "New Shortcut Profile"
+ profile_settings.popup_centered()
+
+
+func _on_RenameProfile_pressed() -> void:
+ is_editing = true
+ profile_name.text = Keychain.selected_profile.name
+ profile_settings.title = "Rename Shortcut Profile"
+ profile_settings.popup_centered()
+
+
+func _on_DeleteProfile_pressed() -> void:
+ delete_confirmation.popup_centered()
+
+
+func _on_OpenProfileFolder_pressed() -> void:
+ OS.shell_open(ProjectSettings.globalize_path(Keychain.PROFILES_PATH))
+
+
+func _on_ProfileSettings_confirmed() -> void:
+ var file_name := profile_name.text + ".tres"
+ var profile := ShortcutProfile.new()
+ profile.name = profile_name.text
+ profile.resource_path = Keychain.PROFILES_PATH.path_join(file_name)
+ profile.fill_bindings()
+ var saved := profile.save()
+ if not saved:
+ return
+
+ if is_editing:
+ var old_file_name: String = Keychain.selected_profile.resource_path
+ if old_file_name != file_name:
+ _delete_profile_file(old_file_name)
+ Keychain.profiles[Keychain.profile_index] = profile
+ profile_option_button.set_item_text(Keychain.profile_index, profile.name)
+ else: # Add new shortcut profile
+ Keychain.profiles.append(profile)
+ profile_option_button.add_item(profile.name)
+ Keychain.profile_index = Keychain.profiles.size() - 1
+ profile_option_button.select(Keychain.profile_index)
+ _on_ProfileOptionButton_item_selected(Keychain.profile_index)
+
+
+func _delete_profile_file(file_name: String) -> void:
+ var dir := DirAccess.open(file_name.get_base_dir())
+ var err := dir.get_open_error()
+ if err != OK:
+ print("Error deleting shortcut profile %s. Error code: %s" % [file_name, err])
+ return
+ dir.remove(file_name)
+
+
+func _on_DeleteConfirmation_confirmed() -> void:
+ _delete_profile_file(Keychain.selected_profile.resource_path)
+ profile_option_button.remove_item(Keychain.profile_index)
+ Keychain.profiles.remove_at(Keychain.profile_index)
+ Keychain.profile_index -= 1
+ if Keychain.profile_index < 0:
+ Keychain.profile_index = 0
+ profile_option_button.select(Keychain.profile_index)
+ _on_ProfileOptionButton_item_selected(Keychain.profile_index)
diff --git a/game/addons/keychain/ShortcutEdit.tscn b/game/addons/keychain/ShortcutEdit.tscn
new file mode 100644
index 0000000..daeb0b8
--- /dev/null
+++ b/game/addons/keychain/ShortcutEdit.tscn
@@ -0,0 +1,113 @@
+[gd_scene load_steps=7 format=3 uid="uid://bq7ibhm0txl5p"]
+
+[ext_resource type="Script" path="res://addons/keychain/ShortcutEdit.gd" id="1"]
+[ext_resource type="Texture2D" uid="uid://ca58ufal2ufd8" path="res://addons/keychain/assets/joy_button.svg" id="2"]
+[ext_resource type="Texture2D" uid="uid://c2s5rm4nec5yh" path="res://addons/keychain/assets/keyboard.svg" id="3"]
+[ext_resource type="Texture2D" uid="uid://bb6q6om3d08cm" path="res://addons/keychain/assets/joy_axis.svg" id="4"]
+[ext_resource type="Texture2D" uid="uid://bma7xj2rqqcr8" path="res://addons/keychain/assets/mouse.svg" id="5"]
+[ext_resource type="PackedScene" uid="uid://bfjcafe2kvx7n" path="res://addons/keychain/ShortcutSelectorDialog.tscn" id="6"]
+
+[node name="ShortcutEdit" type="VBoxContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_vertical = 3
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="ProfileLabel" type="Label" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+text = "Shortcut profile:"
+
+[node name="ProfileOptionButton" type="OptionButton" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+mouse_default_cursor_shape = 2
+
+[node name="NewProfile" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+mouse_default_cursor_shape = 2
+text = "New"
+
+[node name="RenameProfile" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+mouse_default_cursor_shape = 2
+text = "Rename"
+
+[node name="DeleteProfile" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+mouse_default_cursor_shape = 2
+text = "Delete"
+
+[node name="OpenProfileFolder" type="Button" parent="VBoxContainer/HBoxContainer"]
+layout_mode = 2
+mouse_default_cursor_shape = 2
+text = "Open Folder"
+
+[node name="ShortcutTree" type="Tree" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+hide_root = true
+
+[node name="ShortcutTypeMenu" type="PopupMenu" parent="."]
+size = Vector2i(154, 116)
+item_count = 4
+item_0/text = "Key"
+item_0/icon = ExtResource("3")
+item_0/id = 0
+item_1/text = "Mouse Button"
+item_1/icon = ExtResource("5")
+item_1/id = 1
+item_2/text = "Joy Button"
+item_2/icon = ExtResource("2")
+item_2/id = 2
+item_3/text = "Joy Axis"
+item_3/icon = ExtResource("4")
+item_3/id = 3
+
+[node name="KeyboardShortcutSelectorDialog" parent="." instance=ExtResource("6")]
+size = Vector2i(417, 134)
+
+[node name="MouseShortcutSelectorDialog" parent="." instance=ExtResource("6")]
+size = Vector2i(417, 134)
+input_type = 1
+
+[node name="JoyKeyShortcutSelectorDialog" parent="." instance=ExtResource("6")]
+size = Vector2i(417, 134)
+input_type = 2
+
+[node name="JoyAxisShortcutSelectorDialog" parent="." instance=ExtResource("6")]
+size = Vector2i(417, 134)
+input_type = 3
+
+[node name="ProfileSettings" type="ConfirmationDialog" parent="."]
+
+[node name="ProfileName" type="LineEdit" parent="ProfileSettings"]
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 192.0
+offset_bottom = 51.0
+caret_blink = true
+caret_blink_interval = 0.5
+
+[node name="DeleteConfirmation" type="ConfirmationDialog" parent="."]
+size = Vector2i(427, 100)
+dialog_text = "Are you sure you want to delete this shortcut profile?"
+
+[connection signal="item_selected" from="VBoxContainer/HBoxContainer/ProfileOptionButton" to="." method="_on_ProfileOptionButton_item_selected"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/NewProfile" to="." method="_on_NewProfile_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/RenameProfile" to="." method="_on_RenameProfile_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/DeleteProfile" to="." method="_on_DeleteProfile_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/OpenProfileFolder" to="." method="_on_OpenProfileFolder_pressed"]
+[connection signal="button_clicked" from="VBoxContainer/ShortcutTree" to="." method="_on_shortcut_tree_button_clicked"]
+[connection signal="item_activated" from="VBoxContainer/ShortcutTree" to="." method="_on_ShortcutTree_item_activated"]
+[connection signal="id_pressed" from="ShortcutTypeMenu" to="." method="_on_ShortcutTypeMenu_id_pressed"]
+[connection signal="confirmed" from="ProfileSettings" to="." method="_on_ProfileSettings_confirmed"]
+[connection signal="confirmed" from="DeleteConfirmation" to="." method="_on_DeleteConfirmation_confirmed"]
diff --git a/game/addons/keychain/ShortcutProfile.gd b/game/addons/keychain/ShortcutProfile.gd
new file mode 100644
index 0000000..0fb269a
--- /dev/null
+++ b/game/addons/keychain/ShortcutProfile.gd
@@ -0,0 +1,38 @@
+class_name ShortcutProfile
+extends Resource
+
+@export var name := ""
+@export var customizable := true
+@export var bindings := {}
+
+
+func _init() -> void:
+ bindings = bindings.duplicate(true)
+
+
+func fill_bindings() -> void:
+ var unnecessary_actions = bindings.duplicate() # Checks if the profile has any unused actions
+ for action in InputMap.get_actions():
+ if not action in bindings:
+ bindings[action] = InputMap.action_get_events(action)
+ unnecessary_actions.erase(action)
+ for action in unnecessary_actions:
+ bindings.erase(action)
+ save()
+
+
+func change_action(action: String) -> void:
+ if not customizable:
+ return
+ bindings[action] = InputMap.action_get_events(action)
+ save()
+
+
+func save() -> bool:
+ if !customizable:
+ return false
+ var err := ResourceSaver.save(self, resource_path)
+ if err != OK:
+ print("Error saving shortcut profile %s. Error code: %s" % [resource_path, err])
+ return false
+ return true
diff --git a/game/addons/keychain/ShortcutSelectorDialog.gd b/game/addons/keychain/ShortcutSelectorDialog.gd
new file mode 100644
index 0000000..38cb555
--- /dev/null
+++ b/game/addons/keychain/ShortcutSelectorDialog.gd
@@ -0,0 +1,199 @@
+extends ConfirmationDialog
+
+enum InputTypes { KEYBOARD, MOUSE, JOY_BUTTON, JOY_AXIS }
+
+@export var input_type: InputTypes = InputTypes.KEYBOARD
+var listened_input: InputEvent
+
+@onready var root: Node = get_parent()
+@onready var input_type_l: Label = $VBoxContainer/InputTypeLabel
+@onready var entered_shortcut: LineEdit = $VBoxContainer/EnteredShortcut
+@onready var option_button: OptionButton = $VBoxContainer/OptionButton
+@onready var already_exists: Label = $VBoxContainer/AlreadyExistsLabel
+
+
+func _ready() -> void:
+ set_process_input(false)
+ if input_type == InputTypes.KEYBOARD:
+ get_ok_button().focus_neighbor_top = entered_shortcut.get_path()
+ get_cancel_button().focus_neighbor_top = entered_shortcut.get_path()
+ entered_shortcut.focus_neighbor_bottom = get_ok_button().get_path()
+ else:
+ get_ok_button().focus_neighbor_top = option_button.get_path()
+ get_cancel_button().focus_neighbor_top = option_button.get_path()
+ option_button.focus_neighbor_bottom = get_ok_button().get_path()
+
+# get_close_button().focus_mode = Control.FOCUS_NONE
+
+
+func _input(event: InputEvent) -> void:
+ if not event is InputEventKey:
+ return
+ if event.pressed:
+ listened_input = event
+ entered_shortcut.text = OS.get_keycode_string(event.get_keycode_with_modifiers())
+ _show_assigned_state(event)
+
+
+func _show_assigned_state(event: InputEvent) -> void:
+ var metadata = root.currently_editing_tree_item.get_metadata(0)
+ var action := ""
+ if metadata is InputEvent: # Editing an input event
+ action = root.currently_editing_tree_item.get_parent().get_metadata(0)
+ elif metadata is StringName: # Adding a new input event to an action
+ action = metadata
+
+ var matching_pair: Array = _find_matching_event_in_map(action, event)
+ if matching_pair:
+ already_exists.text = tr("Already assigned to: %s") % root.get_action_name(matching_pair[0])
+ else:
+ already_exists.text = ""
+
+
+func _on_ShortcutSelectorDialog_confirmed() -> void:
+ if listened_input == null:
+ return
+ _apply_shortcut_change(listened_input)
+
+
+func _apply_shortcut_change(input_event: InputEvent) -> void:
+ var metadata = root.currently_editing_tree_item.get_metadata(0)
+ if metadata is InputEvent: # Editing an input event
+ var parent_metadata = root.currently_editing_tree_item.get_parent().get_metadata(0)
+ var changed: bool = _set_shortcut(parent_metadata, metadata, input_event)
+ if !changed:
+ return
+ root.currently_editing_tree_item.set_metadata(0, input_event)
+ root.currently_editing_tree_item.set_text(0, root.event_to_str(input_event))
+ elif metadata is StringName: # Adding a new input event to an action
+ var changed: bool = _set_shortcut(metadata, null, input_event)
+ if !changed:
+ return
+ root.add_event_tree_item(input_event, root.currently_editing_tree_item)
+
+
+func _set_shortcut(action: StringName, old_event: InputEvent, new_event: InputEvent) -> bool:
+ if InputMap.action_has_event(action, new_event): # If the current action already has that event
+ return false
+ if old_event:
+ Keychain.action_erase_event(action, old_event)
+
+ # Loop through other actions to see if the event exists there, to re-assign it
+ var matching_pair := _find_matching_event_in_map(action, new_event)
+
+ if matching_pair:
+ var group := ""
+ if action in Keychain.actions:
+ group = Keychain.actions[action].group
+
+ var action_to_replace: StringName = matching_pair[0]
+ var input_to_replace: InputEvent = matching_pair[1]
+ Keychain.action_erase_event(action_to_replace, input_to_replace)
+ Keychain.selected_profile.change_action(action_to_replace)
+ var tree_item: TreeItem = root.tree.get_root()
+ var prev_tree_item: TreeItem
+ while tree_item != null: # Loop through Tree's TreeItems...
+ var metadata = tree_item.get_metadata(0)
+ if metadata is InputEvent:
+ if input_to_replace.is_match(metadata):
+ var map_action: StringName = tree_item.get_parent().get_metadata(0)
+ if map_action == action_to_replace:
+ tree_item.free()
+ break
+
+ tree_item = _get_next_tree_item(tree_item)
+
+ Keychain.action_add_event(action, new_event)
+ Keychain.selected_profile.change_action(action)
+ return true
+
+
+# Based on https://github.com/godotengine/godot/blob/master/scene/gui/tree.cpp#L685
+func _get_next_tree_item(current: TreeItem) -> TreeItem:
+ if current.get_first_child():
+ current = current.get_first_child()
+ elif current.get_next():
+ current = current.get_next()
+ else:
+ while current and !current.get_next():
+ current = current.get_parent()
+ if current:
+ current = current.get_next()
+ return current
+
+
+func _find_matching_event_in_map(action: StringName, event: InputEvent) -> Array:
+ var group := ""
+ if action in Keychain.actions:
+ group = Keychain.actions[action].group
+
+ for map_action in InputMap.get_actions():
+ if map_action in Keychain.ignore_actions:
+ continue
+ if Keychain.ignore_ui_actions and (map_action as String).begins_with("ui_"):
+ continue
+ for map_event in InputMap.action_get_events(map_action):
+ if !event.is_match(map_event):
+ continue
+
+ if map_action in Keychain.actions:
+ # If it's local, check if it's the same group, otherwise ignore
+ if !Keychain.actions[action].global or !Keychain.actions[map_action].global:
+ if Keychain.actions[map_action].group != group:
+ continue
+
+ return [map_action, map_event]
+
+ return []
+
+
+func _on_ShortcutSelectorDialog_about_to_show() -> void:
+ if input_type == InputTypes.KEYBOARD:
+ listened_input = null
+ already_exists.text = ""
+ entered_shortcut.text = ""
+ await get_tree().process_frame
+ entered_shortcut.grab_focus()
+ else:
+ var metadata = root.currently_editing_tree_item.get_metadata(0)
+ if metadata is InputEvent: # Editing an input event
+ var index := 0
+ if metadata is InputEventMouseButton:
+ index = metadata.button_index - 1
+ elif metadata is InputEventJoypadButton:
+ index = metadata.button_index
+ elif metadata is InputEventJoypadMotion:
+ index = metadata.axis * 2
+ index += round(metadata.axis_value) / 2.0 + 0.5
+ option_button.select(index)
+ _on_OptionButton_item_selected(index)
+
+ elif metadata is StringName: # Adding a new input event to an action
+ option_button.select(0)
+ _on_OptionButton_item_selected(0)
+
+
+func _on_ShortcutSelectorDialog_popup_hide() -> void:
+ set_process_input(false)
+
+
+func _on_OptionButton_item_selected(index: int) -> void:
+ if input_type == InputTypes.MOUSE:
+ listened_input = InputEventMouseButton.new()
+ listened_input.button_index = index + 1
+ elif input_type == InputTypes.JOY_BUTTON:
+ listened_input = InputEventJoypadButton.new()
+ listened_input.button_index = index
+ elif input_type == InputTypes.JOY_AXIS:
+ listened_input = InputEventJoypadMotion.new()
+ listened_input.axis = index / 2
+ listened_input.axis_value = -1.0 if index % 2 == 0 else 1.0
+ _show_assigned_state(listened_input)
+
+
+func _on_EnteredShortcut_focus_entered() -> void:
+ set_process_input(true)
+
+
+func _on_EnteredShortcut_focus_exited() -> void:
+ set_process_input(false)
diff --git a/game/addons/keychain/ShortcutSelectorDialog.tscn b/game/addons/keychain/ShortcutSelectorDialog.tscn
new file mode 100644
index 0000000..d8f528f
--- /dev/null
+++ b/game/addons/keychain/ShortcutSelectorDialog.tscn
@@ -0,0 +1,46 @@
+[gd_scene load_steps=2 format=3 uid="uid://bfjcafe2kvx7n"]
+
+[ext_resource type="Script" path="res://addons/keychain/ShortcutSelectorDialog.gd" id="1"]
+
+[node name="ShortcutSelectorDialog" type="ConfirmationDialog"]
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 341.0
+offset_bottom = 64.0
+
+[node name="InputTypeLabel" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+offset_right = 333.0
+offset_bottom = 14.0
+text = "Press a key or a key combination to set the shortcut"
+
+[node name="EnteredShortcut" type="LineEdit" parent="VBoxContainer"]
+visible = false
+layout_mode = 2
+offset_top = 18.0
+offset_right = 333.0
+offset_bottom = 32.0
+editable = false
+virtual_keyboard_enabled = false
+
+[node name="OptionButton" type="OptionButton" parent="VBoxContainer"]
+layout_mode = 2
+offset_top = 18.0
+offset_right = 333.0
+offset_bottom = 38.0
+mouse_default_cursor_shape = 2
+
+[node name="AlreadyExistsLabel" type="Label" parent="VBoxContainer"]
+layout_mode = 2
+offset_top = 42.0
+offset_right = 333.0
+offset_bottom = 56.0
+
+[connection signal="about_to_popup" from="." to="." method="_on_ShortcutSelectorDialog_about_to_show"]
+[connection signal="confirmed" from="." to="." method="_on_ShortcutSelectorDialog_confirmed"]
+[connection signal="focus_entered" from="VBoxContainer/EnteredShortcut" to="." method="_on_EnteredShortcut_focus_entered"]
+[connection signal="focus_exited" from="VBoxContainer/EnteredShortcut" to="." method="_on_EnteredShortcut_focus_exited"]
+[connection signal="item_selected" from="VBoxContainer/OptionButton" to="." method="_on_OptionButton_item_selected"]
diff --git a/game/addons/keychain/assets/add.svg b/game/addons/keychain/assets/add.svg
new file mode 100644
index 0000000..afad08a
--- /dev/null
+++ b/game/addons/keychain/assets/add.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m7 1v6h-6v2h6v6h2v-6h6v-2h-6v-6z" fill="#e0e0e0"/></svg>
diff --git a/game/addons/keychain/assets/add.svg.import b/game/addons/keychain/assets/add.svg.import
new file mode 100644
index 0000000..25442bd
--- /dev/null
+++ b/game/addons/keychain/assets/add.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c4433pa25cxp2"
+path="res://.godot/imported/add.svg-4084e314648c872072757f9b0f544cf9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/add.svg"
+dest_files=["res://.godot/imported/add.svg-4084e314648c872072757f9b0f544cf9.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/close.svg b/game/addons/keychain/assets/close.svg
new file mode 100644
index 0000000..331727a
--- /dev/null
+++ b/game/addons/keychain/assets/close.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m3.7578 2.3438-1.4141 1.4141 4.2422 4.2422-4.2422 4.2422 1.4141 1.4141 4.2422-4.2422 4.2422 4.2422 1.4141-1.4141-4.2422-4.2422 4.2422-4.2422-1.4141-1.4141-4.2422 4.2422z" fill="#e0e0e0"/></svg>
diff --git a/game/addons/keychain/assets/close.svg.import b/game/addons/keychain/assets/close.svg.import
new file mode 100644
index 0000000..d1bc742
--- /dev/null
+++ b/game/addons/keychain/assets/close.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://f1gcylay6c6f"
+path="res://.godot/imported/close.svg-12d57fa7e5a34826f312eed6ba1feb1a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/close.svg"
+dest_files=["res://.godot/imported/close.svg-12d57fa7e5a34826f312eed6ba1feb1a.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/edit.svg b/game/addons/keychain/assets/edit.svg
new file mode 100644
index 0000000..6fc7ae0
--- /dev/null
+++ b/game/addons/keychain/assets/edit.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m7 1c-.554 0-1 .446-1 1v2h4v-2c0-.554-.446-1-1-1zm-1 4v7l2 3 2-3v-7zm1 1h1v5h-1z" fill="#e0e0e0"/></svg>
diff --git a/game/addons/keychain/assets/edit.svg.import b/game/addons/keychain/assets/edit.svg.import
new file mode 100644
index 0000000..73ee367
--- /dev/null
+++ b/game/addons/keychain/assets/edit.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://df8gl3u2nqpl3"
+path="res://.godot/imported/edit.svg-cd9834545a8696f1e8611efa12a48f33.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/edit.svg"
+dest_files=["res://.godot/imported/edit.svg-cd9834545a8696f1e8611efa12a48f33.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/folder.svg b/game/addons/keychain/assets/folder.svg
new file mode 100644
index 0000000..c2def25
--- /dev/null
+++ b/game/addons/keychain/assets/folder.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m2 2a1 1 0 0 0 -1 1v2 6 2a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7a1 1 0 0 0 -1-1h-4a1 1 0 0 1 -1-1v-1a1 1 0 0 0 -1-1z" fill="#e0e0e0"/></svg>
diff --git a/game/addons/keychain/assets/folder.svg.import b/game/addons/keychain/assets/folder.svg.import
new file mode 100644
index 0000000..d2eece2
--- /dev/null
+++ b/game/addons/keychain/assets/folder.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b0gbmkb8xwksb"
+path="res://.godot/imported/folder.svg-490bb7e2d2aa4425de998b1d382cf534.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/folder.svg"
+dest_files=["res://.godot/imported/folder.svg-490bb7e2d2aa4425de998b1d382cf534.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/joy_axis.svg b/game/addons/keychain/assets/joy_axis.svg
new file mode 100644
index 0000000..1ab65f0
--- /dev/null
+++ b/game/addons/keychain/assets/joy_axis.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -1036.4)"><path d="m27 1038.4h7v14h-7z" fill="#fff" fill-opacity=".99608"/><g fill="#e0e0e0"><path d="m3 1a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h12v-14zm4 2h2a1 1 0 0 1 1 1v2h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1-1v-2h-2a1 1 0 0 1 -1-1v-2a1 1 0 0 1 1-1h2v-2a1 1 0 0 1 1-1z" fill-opacity=".99608" transform="translate(0 1036.4)"/><circle cx="8" cy="1044.4" r="1"/></g></g></svg>
diff --git a/game/addons/keychain/assets/joy_axis.svg.import b/game/addons/keychain/assets/joy_axis.svg.import
new file mode 100644
index 0000000..f517dae
--- /dev/null
+++ b/game/addons/keychain/assets/joy_axis.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bb6q6om3d08cm"
+path="res://.godot/imported/joy_axis.svg-9e1156700cfe46afb1ca622536a9a2e1.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/joy_axis.svg"
+dest_files=["res://.godot/imported/joy_axis.svg-9e1156700cfe46afb1ca622536a9a2e1.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/joy_button.svg b/game/addons/keychain/assets/joy_button.svg
new file mode 100644
index 0000000..080d91a
--- /dev/null
+++ b/game/addons/keychain/assets/joy_button.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill-opacity=".99608" transform="translate(0 -1036.4)"><path d="m27 1038.4h7v14h-7z" fill="#fff"/><path d="m1 1v14h12c1.1046 0 2-.8954 2-2v-10c0-1.1046-.89543-2-2-2zm7 1a2 2 0 0 1 2 2 2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2zm-4 4a2 2 0 0 1 2 2 2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2zm8 0a2 2 0 0 1 2 2 2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2zm-4 4a2 2 0 0 1 2 2 2 2 0 0 1 -2 2 2 2 0 0 1 -2-2 2 2 0 0 1 2-2z" fill="#e0e0e0" transform="translate(0 1036.4)"/></g></svg>
diff --git a/game/addons/keychain/assets/joy_button.svg.import b/game/addons/keychain/assets/joy_button.svg.import
new file mode 100644
index 0000000..8963b8b
--- /dev/null
+++ b/game/addons/keychain/assets/joy_button.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ca58ufal2ufd8"
+path="res://.godot/imported/joy_button.svg-df5663c6f296cab556e81b9c770699d0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/joy_button.svg"
+dest_files=["res://.godot/imported/joy_button.svg-df5663c6f296cab556e81b9c770699d0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/keyboard.svg b/game/addons/keychain/assets/keyboard.svg
new file mode 100644
index 0000000..b9dfab7
--- /dev/null
+++ b/game/addons/keychain/assets/keyboard.svg
@@ -0,0 +1 @@
+<svg height="16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill-opacity=".996"><path d="m4 2a1 1 0 0 0 -1 1v9.084c0 .506.448.916 1 .916h8c.552 0 1-.41 1-.916v-9.084a1 1 0 0 0 -1-1zm1.543 1.139h1.393l1.834 4.199h1.295v.437c.708.052 1.246.239 1.61.559.368.316.55.747.55 1.295 0 .552-.182.99-.55 1.314-.368.32-.906.505-1.61.553v.467h-1.294v-.473c-.708-.06-1.247-.248-1.615-.564-.364-.316-.545-.75-.545-1.297 0-.548.181-.977.545-1.29.368-.315.907-.504 1.615-.564v-.437h-1.464l-.282-.733h-1.595l-.284.733h-1.439l1.836-4.2zm.684 1.39-.409 1.057h.817zm3.84 4.338v1.526c.28-.04.483-.12.607-.24.124-.125.185-.302.185-.53 0-.224-.063-.396-.191-.516-.124-.12-.326-.2-.602-.24zm-1.296.006c-.284.04-.487.12-.61.24-.12.116-.182.288-.182.516 0 .22.065.392.193.512.132.12.331.202.6.246v-1.514z" fill="#e0e0e0"/><path d="m27 2h7v14h-7z" fill="#fff"/><path d="m1 4v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9h-1v9a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1-1v-9z" fill="#e0e0e0"/></g></svg>
diff --git a/game/addons/keychain/assets/keyboard.svg.import b/game/addons/keychain/assets/keyboard.svg.import
new file mode 100644
index 0000000..0294680
--- /dev/null
+++ b/game/addons/keychain/assets/keyboard.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c2s5rm4nec5yh"
+path="res://.godot/imported/keyboard.svg-fac365b6f70899f1dfa71ce4b4761ac6.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/keyboard.svg"
+dest_files=["res://.godot/imported/keyboard.svg-fac365b6f70899f1dfa71ce4b4761ac6.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/keyboard_physical.svg b/game/addons/keychain/assets/keyboard_physical.svg
new file mode 100644
index 0000000..4364e0b
--- /dev/null
+++ b/game/addons/keychain/assets/keyboard_physical.svg
@@ -0,0 +1 @@
+<svg height="16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill-opacity=".996"><path d="m4 2a1 1 0 0 0 -1 1v9.084c0 .506.448.916 1 .916h8c.552 0 1-.41 1-.916v-9.084a1 1 0 0 0 -1-1zm2.762 1.768h2.476l3.264 7.464h-2.604l-.502-1.3h-2.835l-.502 1.3h-2.561zm1.217 2.474-.725 1.878h1.45z" fill="#e0e0e0"/><path d="m27 2h7v14h-7z" fill="#fff"/><path d="m1 4v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9h-1v9a1 1 0 0 1 -1 1h-10a1 1 0 0 1 -1-1v-9z" fill="#e0e0e0"/></g></svg>
diff --git a/game/addons/keychain/assets/keyboard_physical.svg.import b/game/addons/keychain/assets/keyboard_physical.svg.import
new file mode 100644
index 0000000..86e2b3e
--- /dev/null
+++ b/game/addons/keychain/assets/keyboard_physical.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cmh8eaibhn5y8"
+path="res://.godot/imported/keyboard_physical.svg-f50c796569ade32b57ece1ba0bd7dfbb.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/keyboard_physical.svg"
+dest_files=["res://.godot/imported/keyboard_physical.svg-f50c796569ade32b57ece1ba0bd7dfbb.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/mouse.svg b/game/addons/keychain/assets/mouse.svg
new file mode 100644
index 0000000..2175120
--- /dev/null
+++ b/game/addons/keychain/assets/mouse.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m7 1.1016a5 5 0 0 0 -4 4.8984h4zm2 .0039063v4.8945h4a5 5 0 0 0 -4-4.8945zm-6 6.8945v2a5 5 0 0 0 5 5 5 5 0 0 0 5-5v-2z" fill="#e0e0e0"/></svg>
diff --git a/game/addons/keychain/assets/mouse.svg.import b/game/addons/keychain/assets/mouse.svg.import
new file mode 100644
index 0000000..21c2216
--- /dev/null
+++ b/game/addons/keychain/assets/mouse.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bma7xj2rqqcr8"
+path="res://.godot/imported/mouse.svg-559695787f3bb55c16dc66bd6a9b9032.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/mouse.svg"
+dest_files=["res://.godot/imported/mouse.svg-559695787f3bb55c16dc66bd6a9b9032.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/assets/shortcut.svg b/game/addons/keychain/assets/shortcut.svg
new file mode 100644
index 0000000..4ef16f0
--- /dev/null
+++ b/game/addons/keychain/assets/shortcut.svg
@@ -0,0 +1 @@
+<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m4 2c-.55228 0-1 .4477-1 1v9.084c.0004015.506.448.91602 1 .91602h8c.552 0 .9996-.41002 1-.91602v-9.084c0-.5523-.44772-1-1-1zm-3 2v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9h-1v9a.99998.99998 0 0 1 -1 1h-10a1 1 0 0 1 -1-1v-9zm6 0h3l-1 3h2l-4 4 1-3h-2z" fill="#e0e0e0" fill-opacity=".99608"/></svg>
diff --git a/game/addons/keychain/assets/shortcut.svg.import b/game/addons/keychain/assets/shortcut.svg.import
new file mode 100644
index 0000000..ae3bd49
--- /dev/null
+++ b/game/addons/keychain/assets/shortcut.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bmi24jp4fqi7k"
+path="res://.godot/imported/shortcut.svg-401daac18a817142e2b29e5801e00053.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/keychain/assets/shortcut.svg"
+dest_files=["res://.godot/imported/shortcut.svg-401daac18a817142e2b29e5801e00053.ctex"]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/bptc_ldr=0
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/addons/keychain/plugin.cfg b/game/addons/keychain/plugin.cfg
new file mode 100644
index 0000000..095c8ee
--- /dev/null
+++ b/game/addons/keychain/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Keychain"
+description="A plugin for the Godot Engine that aims to give the player full control over the input actions of the game."
+author="Orama Interactive"
+version="2.0"
+script="plugin.gd"
diff --git a/game/addons/keychain/plugin.gd b/game/addons/keychain/plugin.gd
new file mode 100644
index 0000000..1eb1d3c
--- /dev/null
+++ b/game/addons/keychain/plugin.gd
@@ -0,0 +1,10 @@
+@tool
+extends EditorPlugin
+
+
+func _enter_tree() -> void:
+ add_autoload_singleton("Keychain", "res://addons/keychain/Keychain.gd")
+
+
+func _exit_tree() -> void:
+ remove_autoload_singleton("Keychain")
diff --git a/game/addons/keychain/profiles/default.tres b/game/addons/keychain/profiles/default.tres
new file mode 100644
index 0000000..b952157
--- /dev/null
+++ b/game/addons/keychain/profiles/default.tres
@@ -0,0 +1,9 @@
+[gd_resource type="Resource" script_class="ShortcutProfile" load_steps=2 format=3 uid="uid://df04uev1epmmo"]
+
+[ext_resource type="Script" path="res://addons/keychain/ShortcutProfile.gd" id="1"]
+
+[resource]
+script = ExtResource("1")
+name = "Default"
+customizable = false
+bindings = {}
diff --git a/game/addons/keychain/translations/README.md b/game/addons/keychain/translations/README.md
new file mode 100644
index 0000000..086527b
--- /dev/null
+++ b/game/addons/keychain/translations/README.md
@@ -0,0 +1,4 @@
+# Localization files for Keychain
+Keychain uses .po files to handle localization. More information in the Godot documentation: [Localization using gettext](https://docs.godotengine.org/en/stable/tutorials/i18n/localization_using_gettext.html)
+
+Simply add a .po file of the language you want to provide localization for, and Keychain will automatically add it to the TranslationServer when the project runs.
diff --git a/game/addons/keychain/translations/Translations.pot b/game/addons/keychain/translations/Translations.pot
new file mode 100644
index 0000000..1f10c77
--- /dev/null
+++ b/game/addons/keychain/translations/Translations.pot
@@ -0,0 +1,209 @@
+msgid ""
+msgstr ""
+
+msgid "OK"
+msgstr ""
+
+msgid "Cancel"
+msgstr ""
+
+msgid "Add"
+msgstr ""
+
+msgid "Edit"
+msgstr ""
+
+msgid "Delete"
+msgstr ""
+
+msgid "Shortcut profile:"
+msgstr ""
+
+msgid "Default"
+msgstr ""
+
+msgid "Custom"
+msgstr ""
+
+msgid "New"
+msgstr ""
+
+msgid "Rename"
+msgstr ""
+
+msgid "Open Folder"
+msgstr ""
+
+msgid "New Shortcut Profile"
+msgstr ""
+
+msgid "Rename Shortcut Profile"
+msgstr ""
+
+msgid "Are you sure you want to delete this shortcut profile?"
+msgstr ""
+
+msgid "Key"
+msgstr ""
+
+msgid "Mouse Button"
+msgstr ""
+
+msgid "Joy Button"
+msgstr ""
+
+msgid "Joy Axis"
+msgstr ""
+
+msgid "Button"
+msgstr ""
+
+msgid "Axis"
+msgstr ""
+
+msgid "(Physical)"
+msgstr ""
+
+msgid "Set the shortcut"
+msgstr ""
+
+msgid "Press a key or a key combination to set the shortcut"
+msgstr ""
+
+msgid "Mouse Button Index:"
+msgstr ""
+
+msgid "Joypad Button Index:"
+msgstr ""
+
+msgid "Joypad Axis Index:"
+msgstr ""
+
+msgid "Already assigned to: %s"
+msgstr ""
+
+msgid "Left Button"
+msgstr ""
+
+msgid "Right Button"
+msgstr ""
+
+msgid "Middle Button"
+msgstr ""
+
+msgid "Wheel Up Button"
+msgstr ""
+
+msgid "Wheel Down Button"
+msgstr ""
+
+msgid "Wheel Left Button"
+msgstr ""
+
+msgid "Wheel Right Button"
+msgstr ""
+
+msgid "X Button 1"
+msgstr ""
+
+msgid "X Button 2"
+msgstr ""
+
+msgid "DualShock Cross, Xbox A, Nintendo B"
+msgstr ""
+
+msgid "DualShock Circle, Xbox B, Nintendo A"
+msgstr ""
+
+msgid "DualShock Square, Xbox X, Nintendo Y"
+msgstr ""
+
+msgid "DualShock Triangle, Xbox Y, Nintendo X"
+msgstr ""
+
+msgid "L, L1"
+msgstr ""
+
+msgid "R, R1"
+msgstr ""
+
+msgid "L2"
+msgstr ""
+
+msgid "R2"
+msgstr ""
+
+msgid "L3"
+msgstr ""
+
+msgid "R3"
+msgstr ""
+
+msgid "Select, DualShock Share, Nintendo -"
+msgstr ""
+
+msgid "Start, DualShock Options, Nintendo +"
+msgstr ""
+
+msgid "D-Pad Up"
+msgstr ""
+
+msgid "D-Pad Down"
+msgstr ""
+
+msgid "D-Pad Left"
+msgstr ""
+
+msgid "D-Pad Right"
+msgstr ""
+
+msgid "Home, DualShock PS, Guide"
+msgstr ""
+
+msgid "Xbox Share, PS5 Microphone, Nintendo Capture"
+msgstr ""
+
+msgid "Xbox Paddle 1"
+msgstr ""
+
+msgid "Xbox Paddle 2"
+msgstr ""
+
+msgid "Xbox Paddle 3"
+msgstr ""
+
+msgid "Xbox Paddle 4"
+msgstr ""
+
+msgid "PS4/5 Touchpad"
+msgstr ""
+
+msgid "(Left Stick Left)"
+msgstr ""
+
+msgid "(Left Stick Right)"
+msgstr ""
+
+msgid "(Left Stick Up)"
+msgstr ""
+
+msgid "(Left Stick Down)"
+msgstr ""
+
+msgid "(Right Stick Left)"
+msgstr ""
+
+msgid "(Right Stick Right)"
+msgstr ""
+
+msgid "(Right Stick Up)"
+msgstr ""
+
+msgid "(Right Stick Down)"
+msgstr ""
+
+msgid "(L2)"
+msgstr ""
+
+msgid "(R2)"
+msgstr ""
diff --git a/game/addons/keychain/translations/el_GR.po b/game/addons/keychain/translations/el_GR.po
new file mode 100644
index 0000000..4b6ce8b
--- /dev/null
+++ b/game/addons/keychain/translations/el_GR.po
@@ -0,0 +1,215 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Keychain\n"
+"Last-Translator: Overloaded\n"
+"Language-Team: none\n"
+"Language: el_GR\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+msgid "OK"
+msgstr "Εντάξει"
+
+msgid "Cancel"
+msgstr "Άκυρο"
+
+msgid "Add"
+msgstr "Προσθήκη"
+
+msgid "Edit"
+msgstr "Επεξεργασία"
+
+msgid "Delete"
+msgstr "Διαγραφή"
+
+msgid "Shortcut profile:"
+msgstr "Προφίλ συντομέυσεων:"
+
+msgid "Default"
+msgstr "Προεπιλογή"
+
+msgid "Custom"
+msgstr "Προσαρμοσμένο"
+
+msgid "New"
+msgstr "Νέο"
+
+msgid "Rename"
+msgstr "Μετονομασία"
+
+msgid "Open Folder"
+msgstr "Άνοιγμα φακέλου"
+
+msgid "New Shortcut Profile"
+msgstr "Νέο προφίλ συντομέυσεων"
+
+msgid "Rename Shortcut Profile"
+msgstr "Μετονομασία προφίλ συντομεύσεων"
+
+msgid "Are you sure you want to delete this shortcut profile?"
+msgstr "Είστε σίγουροι πως θέλετε να διαγραφτεί αυτό το προφίλ συντομέυσεων;"
+
+msgid "Key"
+msgstr "Πλήκτρο"
+
+msgid "Mouse Button"
+msgstr "Κουμπί ποντικιού"
+
+msgid "Joy Button"
+msgstr "Κουμπί χειριστηρίου"
+
+msgid "Joy Axis"
+msgstr "Άξονας χειριστηρίου"
+
+msgid "Button"
+msgstr "Κουμπί"
+
+msgid "Axis"
+msgstr "Άξονας"
+
+msgid "(Physical)"
+msgstr "(Φυσικό)"
+
+msgid "Set the shortcut"
+msgstr "Επιλέξτε μια συντόμευση"
+
+msgid "Press a key or a key combination to set the shortcut"
+msgstr "Πατήστε ένα πλήκτρο ή συνδυασμό πλήκτρων για να ορίσετε τη συντόμευση"
+
+msgid "Mouse Button Index:"
+msgstr "Δείκτης κουμπιού ποντικού"
+
+msgid "Joypad Button Index:"
+msgstr "Δείκτης κουμπιού χειριστηρίου"
+
+msgid "Joypad Axis Index:"
+msgstr "Δείκτης άξονα χειριστηρίου"
+
+msgid "Already assigned to: %s"
+msgstr "Έχει ήδη εκχωρηθεί σε: %s"
+
+msgid "Left Button"
+msgstr "Αριστερό κουμπί"
+
+msgid "Right Button"
+msgstr "Δεξί κουμπί"
+
+msgid "Middle Button"
+msgstr "Μεσαίο κουμπί"
+
+msgid "Wheel Up Button"
+msgstr "Τρόχος πάνω"
+
+msgid "Wheel Down Button"
+msgstr "Τρόχος κάτω"
+
+msgid "Wheel Left Button"
+msgstr "Τρόχος αριστερά"
+
+msgid "Wheel Right Button"
+msgstr "Τρόχος δεξιά"
+
+msgid "X Button 1"
+msgstr "Κουμπί X 1"
+
+msgid "X Button 2"
+msgstr "Κουμπί X 2"
+
+msgid "DualShock Cross, Xbox A, Nintendo B"
+msgstr "DualShock Σταυρός, Xbox A, Nintendo B"
+
+msgid "DualShock Circle, Xbox B, Nintendo A"
+msgstr "DualShock Κύκλος, Xbox B, Nintendo A"
+
+msgid "DualShock Square, Xbox X, Nintendo Y"
+msgstr "DualShock Τετράγωνο, Xbox X, Nintendo Y"
+
+msgid "DualShock Triangle, Xbox Y, Nintendo X"
+msgstr "DualShock Τρίγωνο, Xbox Y, Nintendo X"
+
+msgid "L, L1"
+msgstr "L, L1"
+
+msgid "R, R1"
+msgstr "R, R1"
+
+msgid "L2"
+msgstr "L2"
+
+msgid "R2"
+msgstr "R2"
+
+msgid "L3"
+msgstr "L3"
+
+msgid "R3"
+msgstr "R3"
+
+msgid "Select, DualShock Share, Nintendo -"
+msgstr ""
+
+msgid "Start, DualShock Options, Nintendo +"
+msgstr ""
+
+msgid "D-Pad Up"
+msgstr "D-Pad Πάνω"
+
+msgid "D-Pad Down"
+msgstr "D-Pad Κάτω"
+
+msgid "D-Pad Left"
+msgstr "D-Pad Αριστερά"
+
+msgid "D-Pad Right"
+msgstr "D-Pad Δεξιά"
+
+msgid "Home, DualShock PS, Guide"
+msgstr ""
+
+msgid "Xbox Share, PS5 Microphone, Nintendo Capture"
+msgstr "Xbox Share, PS5 Μικρόφωνο, Nintendo Capture"
+
+msgid "Xbox Paddle 1"
+msgstr ""
+
+msgid "Xbox Paddle 2"
+msgstr ""
+
+msgid "Xbox Paddle 3"
+msgstr ""
+
+msgid "Xbox Paddle 4"
+msgstr ""
+
+msgid "PS4/5 Touchpad"
+msgstr "PS4/5 επιφάνεια αφής"
+
+msgid "(Left Stick Left)"
+msgstr "(Αριστερό Stick Αριστερά)"
+
+msgid "(Left Stick Right)"
+msgstr "(Αριστερό Stick Δεξιά)"
+
+msgid "(Left Stick Up)"
+msgstr "(Αριστερό Stick Πάνω)"
+
+msgid "(Left Stick Down)"
+msgstr "(Αριστερό Stick Κάτω)"
+
+msgid "(Right Stick Left)"
+msgstr "(Δεξί Stick Αριστερά)"
+
+msgid "(Right Stick Right)"
+msgstr "(Δεξί Stick Δεξιά)"
+
+msgid "(Right Stick Up)"
+msgstr "(Δεξί Stick Πάνω)"
+
+msgid "(Right Stick Down)"
+msgstr "(Δεξί Stick Κάτω)"
+
+msgid "(L2)"
+msgstr "(L2)"
+
+msgid "(R2)"
+msgstr "(R2)"