The other day I was looking at @passivestar's Minimal Theme for the Godot editor. I noticed that it came with two files, one for standard DPI screens and another for high DPI. The user was asked to pick the one that they find more appropriate for their setup, with either file containing a complete editor theme with some adjustments compared to each other.
And one thought kept bugging me: can't we streamline this? Why manually pick from two files when it can be done automatically, based on the editor settings? Or with a custom toggle from the settings. What if we have more configurable options, how many files would we then need to provide? Can we respect user's color and other theme settings in some way?
The answer, of course, is "Yes". Yes, we can do something smart and cool here.
In Godot every object can have a script attached to it. Theme
is a kind of Resource
and Resource
is a kind of Object
, so naturally you can attach scripts to your themes. This should not be confused with creating scripts that extend a resource type, calling it, say, MyTheme
, and then creating a new resource based on that. This is important, as will become clear later.
To add a new script to your theme resource, open it in the Inspector and then scroll all the way to the bottom of it. You'll find the Script
property with a standard resource picker. Click on the arrow and choose New GDScript. Then double-click on the resource picker to open the script for editing. You still need to save it, so press Ctrl + S
in the script editor and give the file a name. Et voilà!
So far this is not that different from creating a script extending Theme
and then making a new resource based on that. And your current script would look pretty much the same, for example:
@tool
extends Theme
func _init() -> void:
print("Hello there!")
But we need to take it one step further if we're making an editor theme for Godot.
When the editor loads your custom theme, it needs to be able to resolve all of its contents. Themes can contain various sub-resources, like icons and styleboxes. Those could technically be put into separate files, however that presents a problem. When you create a resource that points to another resource in separate file, its path is stored in relation to the current project, e.g. res://assets/my_icon.png
. If you make an editor theme that points to such a file and then try to open a different project, the editor will not be able to resolve the path to this file. Because it doesn't exist in that other project.
You could potentially trick Godot by editing your main resource, your theme, manually and editing these paths to be relative. This may or may not work, but at this point you're fighting the system and that will make authoring themes harder for you. For this reason, you cannot really put icons and styleboxes into separate files, they must all be bundled into the same resource.
Our script falls under the same constraint. It must be bundled as well, and luckily Godot allows you to bundle scripts directly into resources, instead of linking to external files. This is called "built-in scripts" (poor name, perhaps, but very popular functionality). You may have noticed this toggle before while attaching scripts to nodes:
And if you use it, save your scene, and then look at the .tscn
file, you'll find that inside of the Script
property there is now your code.
Unfortunately, we don't get this fancy dialog creating a script for a resource. Instead, it is just created in memory and you have to manually save it afterwards, as I explained above. But you can still make that script built-in. Roll back to the step where you create a new GDScript script on your resource. When you try to save it for the first time and are prompted to input a file name, close the dialog instead. That's it! The script will still be saved and will be embedded into your theme.
That was the premise of our approach. So what comes next? My ultimate goal was to find a way to provide @passivestar's Minimal Theme as just one file and let the user choose the DPI option, or any other potential future option, in the editor settings. So my script needs to be able to read editor settings, potentially create new editor settings, and react to change in editor settings. Let's do all that!
Starting with Godot 4.2 you can use the EditorInterface
singleton directly from any script running in the editor. Via EditorInterface
you can access a lot of editor-specific types and objects, including the EditorSettings
instance. If you've created Godot editor plugins before, you might know that this class allows you to do all of the aforementioned things related to editor settings.
You can read settings:
var settings := EditorInterface.get_editor_settings()
var base_color: Color = settings.get_setting("interface/theme/base_color")
You can create custom settings (making sure you don't override them on subsequent launches):
var settings := EditorInterface.get_editor_settings()
if not settings.has_setting("interface/theme/minimal/use_high_ppi"):
settings.set_setting("interface/theme/minimal/use_high_ppi", false)
And of course you can react to setting changes (but this won't be necessary or actually possible in our specific case):
var settings := EditorInterface.get_editor_settings()
settings.settings_changed.connect(func() -> void:
if settings.check_changed_settings_in_group("interface/theme"):
# Do something...
pass
)
This gives me tools to make decisions about what goes into the editor theme based on user-configurable options, standard and custom alike. And at this point you can follow one of the two paths. The first path leads you the way of the Godot editor itself and how it defines its standard editor theme. It's all done in code, with the theme resource being constructed on the fly with both hardcoded and semi-hardcoded, adjustable values. If that's your path, you do this:
@tool
extends Theme
func _init() -> void:
var settings := EditorInterface.get_editor_settings()
# Define custom editor settings, if necessary.
if not settings.has_setting("interface/theme/minimal/use_high_ppi"):
settings.set_setting("interface/theme/minimal/use_high_ppi", false)
# Read the settings.
var use_high_ppi: bool = settings.get_setting("interface/theme/minimal/use_high_ppi")
# Generate the theme.
if use_high_ppi:
set_constant("text_primary_margin", "AnimationTimelineEdit", 4)
set_constant("text_secondary_margin", "AnimationTimelineEdit", 2)
else:
set_constant("text_primary_margin", "AnimationTimelineEdit", 2)
set_constant("text_secondary_margin", "AnimationTimelineEdit", 1)
# Etc.
You can also forgo using custom settings, and in this example rely on the editor scale. Of course you can also mix and match!
@tool
extends Theme
func _init() -> void:
var scale := EditorInterface.get_editor_scale()
# Generate the theme.
set_constant("text_primary_margin", "AnimationTimelineEdit", 2 * scale)
set_constant("text_secondary_margin", "AnimationTimelineEdit", 1 * scale)
# Etc.
A word of warning, though. Due to the implementation detail of the Godot editor, your script has a short lifespan. I will go into it a bit more below, but what it means in practice is that you must do everything with the scope of the _init
method. You cannot use async code, and you cannot use signals to wait for something, even just one frame. Consider the _init
method to be like an "Execute it" method, and once its done, your script and your theme is gone from memory.
So that's the first path. For the Minimal Theme project this means rewriting its resources into code that sets corresponding values. This is a valid approach, but I wanted to push the idea further and fully preserve our ability to author themes using the theme editor. That proved to be more challenging...
For the second path, the concept in my mind was the following. I combine my script with actual theme resources which are embedded into the main theme resource. This means that I still have only one file for users to copy, but within that file I have one-plus non-scripted themes which are used as data for me to use. I imagined having one main theme sub-resource, that defines default, standard values for everything that needs to be defined. And then a bunch of bundles which get "activated" when appropriate editor settings are selected.
@tool
extends Theme
@export_group("Bundles", "bundle_")
@export var bundle_base: Theme = null
@export var bundle_high_ppi_override: Theme = null
So while the first path allows you to go very granular and dynamic with how settings are applied, in this approach you are still restricted to using hardcoded data. A trade-off, for sure, but also a more GUI-friendly option for authoring the theme. You would open your singular theme resource in the Inspector and have exported properties containing extra theme resources. Remember, they must be embedded, because the editor will not be able to open them as separate files from an arbitrary place.
To make the concept of these theme bundles work I am going to use the merging API available on the Theme
type. By calling theme.merge_with(another_theme)
you can populate and update definitions in your first theme with the definitions from your second theme. New values get added, existing values get replaced. So if a definition exists in both the base theme and the high_ppi
bundle, the high_ppi
value will be the final one. And if there are more options and more bundles, they can all be resolved this way in some order.
The code for this can look something like this:
@tool
extends Theme
@export_group("Bundles", "bundle_")
@export var bundle_base: Theme = null
@export var bundle_high_ppi_override: Theme = null
func _init() -> void:
# Initialize custom editor settings, if necessary.
var settings := EditorInterface.get_editor_settings()
if not settings.has_setting("interface/theme/minimal/use_high_ppi"):
settings.set_setting("interface/theme/minimal/use_high_ppi", false)
# Generate the theme.
_generate_theme()
func _generate_theme() -> void:
clear()
# Initialize base definitions.
var source_theme := Theme.new()
if bundle_base:
source_theme.merge_with(bundle_base)
# Apply overrides.
var settings := EditorInterface.get_editor_settings()
var use_high_ppi: bool = settings.get_setting("interface/theme/minimal/use_high_ppi")
if use_high_ppi && bundle_high_ppi_override:
source_theme.merge_with(bundle_high_ppi_override)
# Apply the combined theme to the current one.
merge_with(source_theme)
Except this would never work. While all the data is bundled inside of the same file, the _init
method of the script is just too early to try and access it. By this point, no exported property has been assigned its stored value, and our inner theme resources are empty. How to do something when they are no longer empty? Well, Godot doesn't have a dedicated signal, notification, or lifecycle hook to run some code after a resource has been fully loaded. There are multiple proposals though.
My first instinct here would be to wait for a frame or some other global signal, to let everything load. But this is not possible due to how custom editor themes are treated internally.
func _init() -> void:
# Initialize custom editor settings, if necessary.
var settings := EditorInterface.get_editor_settings()
if not settings.has_setting("interface/theme/minimal/use_high_ppi"):
settings.set_setting("interface/theme/minimal/use_high_ppi", false)
# Generate the theme on the next frame.
await Engine.get_main_loop().process_frame
_generate_theme() # This line is never reached.
When the Godot editor needs to create its GUI theme, it first generates the standard one from code. Then it checks whether the user has configured the editor to use a custom theme. If so, the custom theme is then loaded and merged into the previously generated standard theme. As I've explained above, merging allows you to have a partial theme that overrides only some parts of the main theme. This way custom editor themes don't have to provide a complete implementation for everything. They can simply adjust the necessary parts.
But because of that, the custom theme is quickly discarded by the editor. It loads, its script is instantiated and executed, properties get set, then it gets merged into the main theme object, and immediately freed from the memory. My code that would try to await for one frame, is never reached. The script is deleted by then. Same for any other external hooks. And for the same reason, at the very top, I have mentioned that there is no point connecting to the EditorSettings.settings_changed
signal — the callback attached to it is never going to be executed. On the flip side, Godot already regenerates the editor theme whenever any interface/theme
properties are set. Which repeats the whole process and runs the script again, anew.
Let's go back, for a second, to the idea of a lifecycle hook for when the exported properties are all set. The script is definitely alive at that moment, and I would have all the data necessary then. If you peeked into one of the linked proposals, you may already know that there is a hacky way to achieve something really close to such a hook. While I vividly remember this not working as expected in Godot 3, in Godot 4 setters are called on exported properties that have them. So by adding one extra bogus property with a setter I can have my hook:
@tool
extends Theme
@export_group("Bundles", "bundle_")
@export var bundle_base: Theme = null
@export var bundle_high_ppi_override: Theme = null
@export_storage var _resource_loaded: bool = false:
set = _on_resource_loaded
func _on_resource_loaded(_val: bool) -> void:
_generate_theme()
And... that's actually it for this concept. At this point, once you have authored your themes, you can go into editor settings and with a toggle of a checkbox switch between the high DPI option and the standard one.
Although...
As established, the second path is based on the premise that you will be able to continue authoring the theme using editor tools. So you would open it in the inspector, and work from there. What will be immediately apparent is that the final state of the merged together theme (from the base and the extra bundles) is explicitly listed in the inspector. That's because the scripted theme, at runtime, does actually own all these theme definitions, which is why it works in the first place.
But that presents a danger. First of all, that listing is misleading, because it is not persistent. It will be overwritten every time the theme is loaded. It's also a lot of visual noise. But most importantly, it will be saved to disk if you save that resource right now. Which means that the theme resource file will contain a lot of extra data that it doesn't actually need. For a project like Minimal Theme it means excessive diffs for every change, as the theme is tracked using git
.
You'd need to differentiate somehow between the theme loading by the editor to be used, and the theme being loaded by you to edit its contents. One thread to pull here is the Inspector dock API.
func _safe_to_generate() -> bool:
Safeguard to avoid saving the transient state via the inspector.
if EditorInterface.get_inspector() && EditorInterface.get_inspector().get_edited_object() == self:
return false
return true
func _generate_theme() -> void:
# Safeguard to avoid saving the transient state.
if not _safe_to_generate():
return
# Do the rest...
However, don't put that into your script just yet. This will crash the editor on startup! Unfortunately, calls to EditorInterface.get_inspector()
are not safe during the startup, because it tries to access the Inspector dock in an unsafe manner. It doesn't exist when the custom theme is loaded, and thus — everything crashes.
For now, I couldn't find a different reliable way to resolve this problem. This could, once again, be solved with some signal or notification. Scenes (or rather nodes), for example, receive NOTIFICATION_EDITOR_PRE_SAVE
and NOTIFICATION_EDITOR_POST_SAVE
, which allows you to do some clean up before the contents are written to the file. Resources do not have such API. Maybe you can make it work though!
So, to finish this off, where does this leave me and my plan to streamline Minimal Theme? Naturally, either way you go, this is a disruptive change to the project, to someone else's project. Thus, I reached out to passivestar and shared my ideas and findings. And I think something might come out of this? Stay tuned 👀
He did bring up some further considerations too. While the first approach is more labor-intensive, it is more preferable because it works without any tricks and there is little reason to open the resource in the inspector to have the need to address the aforementioned problem. The second approach may also not be the nicest to work with in this specific case. You see, both currently available theme options are edited together, and perfectly align to each other. So keeping modifications in synch is easy — just touch up the same respective line numbers with correctly adjusted values. Was it all stored inside of one file, that benefit of order is effectively gone.
So there is that. But I hope you learned a few tricks here! And yes, uhh, not to alarm anyone, but resources that you load in Godot can execute scripts that can do pretty much whatever. Be cautious downloading stuff from the web!
If you find this interesting and, perhaps, educational, please consider supporting my work via itch.io or Patreon. Making tools for and sharing knowledge with other developers is my full-time occupation, and I'd love to continue doing this for the foreseeable future!
Cheers and happy holidays <3
Loading comments and feedback...