One of the coolest things about blender is that you can press digits and letters to activate menu buttons. In blender it’s very useful because of how many different tools there are in blender. Some time ago I also made a blender addon to take this workflow even further by putting a bunch of common operations in menus.
Godot doesn’t have nearly as many menus (natively at least), but being able to press keys in the existing ones can still speed up quite a few operations, which is why I made an addon to add this feature.
Just like blender it allows pressing digits and letters. Letters become underlined:
With this addon for example you can press RMB+1+1
(RMB = Right Mouse Button) to quickly create a new folder, RMB+1+4
to create a new resource, RMB+A
to favorite, etc:
Or RMB+S/B/Q
to switch mesh between sphere, box and quad respectively:
For resource pickers that expect a texture, pressing RMB+G
will create a gradient and RMB+S
will create a noise texture:
This goes on for all menus - i.e you can add reverb to an audio bus with LMB+V
on “Add Effect”, switch to half resolution with LMB+H
on “Perspective” button in 3D viewport, add a “Call Method” animation track with LMB+C
on the + button, and so on.
This naturally adds a bunch of shortcuts all over the editor. It won’t cover everything you may ever need, but it’s great value since it’s a feature you implement only once and it works for everything in the future. It’s a feature that doesn’t take effort to learn either because you discover those shortcuts when using menus as usual simply by noticing what common operations correspond to letters or digits that are easy to press!
For a lot of the menus, accelerator keys make more sense than regular hotkeys because if we were to assign a dedicated shortcut for all operations we would run out of shortcut space on the left side of the keyboard. Besides, for a lot of those menu-related operations you still need to click on something with your mouse first, for example to create a new resource you need to click on a folder to let your compooter know where to create the new resource, in which case it may as well be RMB+1+4
which is easy to press.
Although most menus in godot open with a mouse click (unlike blender which has a lot of popups on keyboard shortcuts), this addon also works with menus from other addons, where you can do whatever you want, including showing floating popups on hotkey presses.
For example I have a baker addon (that I will probably share too eventually) that adds a popup menu that can be called with Shift+B
:
Combined with the accelerator keys addon, this allows me for example to rebake all lightmaps in the scene by pressing Shift+B+B
or Shift+B+4
, without making a dedicated shortcut for that.
Of course this doesn’t mean that accelerator keys are better than regular hotkeys for all buttons in all menus. The obvious downside of them is that they are automatically generated and depend on the content of the menu. If the menu changes, so do the shortcuts. Although from my experience of using accelerator keys heavily in blender I can tell that menus don’t change that often, and even when they do it’s easy to adapt to.
Addon
The addon is pretty simple, it’s only around 80 lines of gdscript:
@tool
extends EditorPlugin
var _popup_data: Dictionary[PopupMenu, Dictionary] = {}
var _opened_popups: Array[PopupMenu] = []
func _enter_tree() -> void:
get_tree().node_added.connect(_on_node_added)
for popup in get_tree().root.find_children("", "PopupMenu", true, false):
_setup_popup(popup)
func _exit_tree() -> void:
get_tree().node_added.disconnect(_on_node_added)
for popup in get_tree().root.find_children("", "PopupMenu", true, false):
_cleanup_popup(popup)
func _on_node_added(node: Node) -> void:
if node is PopupMenu and not (node in _popup_data):
_setup_popup(node)
func _setup_popup(popup: PopupMenu) -> void:
var data := {
"popup_callable": _on_popup.bind(popup),
"input_callable": _on_input.bind(popup),
"letter_indices": {},
"number_indices": []
}
popup.about_to_popup.connect(data.popup_callable)
popup.window_input.connect(data.input_callable)
popup.allow_search = false
_popup_data[popup] = data
func _cleanup_popup(popup: PopupMenu) -> void:
var data = _popup_data.get(popup, null)
if data:
popup.about_to_popup.disconnect(data.popup_callable)
popup.window_input.disconnect(data.input_callable)
popup.allow_search = true
_popup_data.erase(popup)
func _on_popup(popup: PopupMenu) -> void:
var data = _popup_data[popup]
data.letter_indices.clear()
data.number_indices.clear()
for i in popup.get_item_count():
if popup.is_item_separator(i):
continue
var text := popup.get_item_text(i).remove_char(0x0332)
for j in text.length():
var char := text[j].to_lower()
if char >= "a" and char <= "z" and not char in data.letter_indices:
data.letter_indices[char] = i
popup.set_item_text(i, text.insert(j + 1, "\u0332"))
break
data.number_indices.append(i)
func _on_input(event: InputEvent, popup: PopupMenu) -> void:
if not (event is InputEventKey and event.is_pressed()):
return
var data = _popup_data[popup]
var idx := -1
if event.keycode >= KEY_0 and event.keycode <= KEY_9:
var num = (event.keycode - KEY_1) % 10 + (10 if event.shift_pressed else 0)
if num >= 0 and num < data.number_indices.size():
idx = data.number_indices[num]
elif event.keycode >= KEY_A and event.keycode <= KEY_Z:
idx = data.letter_indices.get(char(event.keycode).to_lower(), -1)
if idx < 0:
return
popup.index_pressed.emit(idx)
popup.id_pressed.emit(popup.get_item_id(idx))
_opened_popups.append(popup)
var submenu = popup.get_item_submenu_node(idx)
if submenu:
submenu.popup(Rect2(popup.position + Vector2i(popup.size.x, 0), Vector2.ZERO))
else:
for opened_popup in _opened_popups:
opened_popup.hide()
_opened_popups.clear()
Here’s the download link:
How it works
One of the reasons I write these posts is to show you what kinds of things you can do in godot with editor scripting, so let’s do a quick review!
Godot menus already have built-in accelerator key logic in them but it only works for letters and it only puts focus on items instead of activating them. We want immediate activation so we just disable the built-in logic when we set up popups:
popup.allow_search = false
For our accelerator key functionality we store some data for each popup:
var data := {
"popup_callable": _on_popup.bind(popup),
"input_callable": _on_input.bind(popup),
"letter_indices": {},
"number_indices": []
}
popup_callable
- function that’s called when popup appears. This is where we map letters to indices and underline letters.input_callable
- function that’s called on input in a popup. This is where digit and letter presses are actually handled.letter_indices
- a lookup dictionary that maps letters to menu item indices.number_indices
- a lookup array that maps numbers to menu item indices. These aren’t the same because separators have their own indices in godot menus.
Callables are unique per popup since we’re binding a reference to a popup there, which is why we need to store them because we want to be able to disconnect those callables when the addon is disabled:
func _cleanup_popup(popup: PopupMenu) -> void:
var data = _popup_data.get(popup, null)
if data:
popup.about_to_popup.disconnect(data.popup_callable)
popup.window_input.disconnect(data.input_callable)
popup.allow_search = true
_popup_data.erase(popup)
We’re storing all that data for each node in a global dictionary on top:
var _popup_data: Dictionary[PopupMenu, Dictionary] = {}
Alternatively we could store it in node metadata, but I figured a dictionary is a bit cleaner because this way we aren’t polluting the node tree and keeping the addon logic mostly self-contained. Both approaches work though and both are exactly the same number of lines.
On popup
When a menu popup is… popped up we run some code to underline letters and generate mappings for numbers and letters, while skipping the separators:
func _on_popup(popup: PopupMenu) -> void:
var data = _popup_data[popup]
data.letter_indices.clear()
data.number_indices.clear()
for i in popup.get_item_count():
if popup.is_item_separator(i):
continue
var text := popup.get_item_text(i).remove_char(0x0332)
for j in text.length():
var char := text[j].to_lower()
if char >= "a" and char <= "z" and not char in data.letter_indices:
data.letter_indices[char] = i
popup.set_item_text(i, text.insert(j + 1, "\u0332"))
break
data.number_indices.append(i)
We need to do this every time a popup is opened because it’s common for godot to generate the contents of popup menus dynamically. It’s not a problem though because it’s fast.
In godot, menu item labels are just regular text, they don’t support bbcode or anything like that. Luckily for us unicode has a special \u0332
character to underline the character that comes before it. So all we need to do is to insert it into the item text after the letter that we want to underline, as we do here:
popup.set_item_text(i, text.insert(j + 1, "\u0332"))
Notice that we also remove underlines from text first to avoid underlining things multiple times:
var text := popup.get_item_text(i).remove_char(0x0332)
remove_char
is just a faster version of replace
.
Handling input
When a popup detects input we check if we’re pressing a digit or a letter and find a corresponding menu item index for it:
func _on_input(event: InputEvent, popup: PopupMenu) -> void:
if not (event is InputEventKey and event.is_pressed()):
return
var data = _popup_data[popup]
var idx := -1
if event.keycode >= KEY_0 and event.keycode <= KEY_9:
var num = (event.keycode - KEY_1) % 10 + (10 if event.shift_pressed else 0)
if num >= 0 and num < data.number_indices.size():
idx = data.number_indices[num]
elif event.keycode >= KEY_A and event.keycode <= KEY_Z:
idx = data.letter_indices.get(char(event.keycode).to_lower(), -1)
if idx < 0:
return
Here we’re also adding support for holding Shift
to reach items further than 10
when pressing digits, although tbh I don’t use that functionality very often:
var num = (event.keycode - KEY_1) % 10 + (10 if event.shift_pressed else 0)
Emitting foreign signals in gdscript
If a valid index exists for whatever the user pressed we programmatically press on the corresponding menu item by emitting two signals:
popup.index_pressed.emit(idx)
popup.id_pressed.emit(popup.get_item_id(idx))
Both index_pressed
and id_pressed
signals need to be emitted to activate the menu item. If we only emit id_pressed
, radio buttons won’t work. And if we only emit index_pressed
, regular buttons won’t work.
I don’t know why, it’s just something I figured out experimentally. I didn’t bother reading the source code for those signal calls. Normally you wouldn’t be emitting those signals from gdscript, you would be listening to them instead, but “normally” implementing an addon like this would be impossible, which is the case in a lot of software. For example if godot’s primary user language was C# and the API adhered to C# philosophy that events are only allowed to be called from inside of their class it would have been a GG and you woudn’t be reading this blog post. It can be hard to predict what APIs should be made public and what private when making a game engine, and how it will all be used by people. Overall most software out there doesn’t allow this level of editor hacking.
To think about it I don’t think I can name any other app that would let me implement my own accelerator key logic 🤔
Opening submenus
Emitting those signals still won’t open submenus so we need to open them manually:
_opened_popups.append(popup)
var submenu = popup.get_item_submenu_node(idx)
if submenu:
submenu.popup(Rect2(popup.position + Vector2i(popup.size.x, 0), Vector2.ZERO))
else:
for opened_popup in _opened_popups:
opened_popup.hide()
_opened_popups.clear()
As you can see we’re also keeping track of the opened popups to close them all when the user is done digging through menus.
That’s it, I think we’ve covered everything! Like I said it’s a simple addon!
Proposals
Btw addons like these is the reason global addons should be a thing. Because it’s an editor-level addon and not a project-level addon.
I also opened a proposal some time ago to add accelerator keys to godot natively so that you don’t need an addon for this at all!
Thanks for reading <3