This manual explains how to create interactive UI elements in the editor using editor scripts written in Lua. To get started with editor scripts, seeEditor Scripts manual. You can find the full editor API referencehere. Currently, it’s only possible to create interactive dialogs, though we want to expand the UI scripting support to the rest of the editor in the future.
All UI-related functionality exists in theeditor.ui
module. Here is the simplest example of an editor script with a custom UI to get started:
localM={}functionM.get_commands()return{{label="Do with confirmation",locations={"View"},run=function()localresult=editor.ui.show_dialog(editor.ui.dialog({title="Perform action?",buttons={editor.ui.dialog_button({text="Cancel",cancel=true,result=false}),editor.ui.dialog_button({text="Perform",default=true,result=true})}}))print('Perform action:',result)end}}endreturnM
This code snippet defines aView → Do with confirmation command. When you execute it, you will see the following dialog:
Finally, after pressingEnter (or clicking on thePerform
button), you’ll see the following line in the editor console:
Perform action:true
The editor provides various UIcomponents that can be composed to create the desired UI. By convention, all components are configured using a single table calledprops. The components themselves are not tables, butimmutable userdata used by the editor for creating the UI.
Props are tables that define inputs into components. Props should be treated as immutable: mutating the props table in-place will not cause the component to re-render, but using a different table will. UI is updated when the component instance receives a props table that is not shallow-equal to the previous one.
When the component gets assigned some bounds in the UI, it will consume the whole space, though it does not mean that the visible part of the component will stretch. Instead, the visible part will take the space it needs, and then it will be aligned within the assigned bounds. Therefore, most built-in components define analignment
prop.
For example, consider this label component:
editor.ui.label({text="Hello",alignment=editor.ui.ALIGNMENT.RIGHT})
The visible part is theHello
text, and it’s aligned within the assigned component bounds:
The editor defines various built-in components that can be used together to build the UI. Components may be roughly grouped into 3 categories: layout, data presentation and input.
Layout components are used for placing other components next to each other. Main layout components arehorizontal
,vertical
andgrid
. These components also define props such aspadding andspacing, where padding is an empty space from the edge of the assigned bounds to the content, and spacing is an empty space between children:
Editor definessmall
,medium
andlarge
padding and spacing constants. When it comes to spacing,small
is intended for spacing between different sub-elements of an individual UI element,medium
is for spacing between individual UI elements, andlarge
is a spacing between groups of elements. Default spacing ismedium
. A padding value oflarge
means padding from the edges of the window to content,medium
is padding from the edges of a significant UI element, andsmall
is a padding from the edges of small UI elements like context menus and tooltips (not implemented yet).
Ahorizontal
container places its children one after another horizontally, always making the height every child fill the available space. By default, the width of every child is kept to a minimum, though it’s possible to make it take as much space as possible by settinggrow
prop totrue
on a child.
Avertical
container is similar to horizontal, but with the axes switched.
Finally,grid
is a container component that lays out its children in a 2D grid, like a table. Thegrow
setting in a grid applies to rows or columns, therefore it’s set not on a child, but on column configuration table. Also, children in a grid may be configured to span multiple rows or columns withrow_span
andcolumn_span
props. Grids are useful for creating multi-input forms:
editor.ui.grid({padding=editor.ui.PADDING.LARGE,-- add padding around dialog edgescolumns={{},{grow=true}},-- make 2nd column growchildren={{editor.ui.label({text="Level Name",alignment=editor.ui.ALIGNMENT.RIGHT}),editor.ui.string_field({})},{editor.ui.label({text="Author",alignment=editor.ui.ALIGNMENT.RIGHT}),editor.ui.string_field({})}}})
The code above will produce the following dialog form:
The editor defines 4 data presentation components:
label
— text label, intended to be used with form inputs.icon
— an icon; currently, it can only be used for presenting a small set of predefined icons, but we intend to allow more icons in the future.heading
— text element intended for presenting a heading line of text in e.g. a form or a dialog. Theeditor.ui.HEADING_STYLE
enum defines various heading styles that include HTML’sH1
-H6
heading, as well as editor-specificDIALOG
andFORM
.paragraph
— text element intended for presenting a paragraph of text. The main difference withlabel
is that paragraph supports word wrapping: if the assigned bounds are too small horizontally, the text will wrap, and possibly will be shortened with"..."
if it can’t fit in the view.Input components are made for the user to interact with the UI. All input components supportenabled
prop to control if the interaction is enabled or not, and define various callback props that notify the editor script on interaction.
If you create a static UI, it’s enough to define callbacks that simply modify locals. For dynamic UIs and more advanced interactions, seereactivity.
For example, it’s possible to create a simple static New File dialog like so:
-- initial file name, will be replaced by the dialoglocalfile_name=""localcreate_file=editor.ui.show_dialog(editor.ui.dialog({title="Create New File",content=editor.ui.horizontal({padding=editor.ui.PADDING.LARGE,spacing=editor.ui.SPACING.MEDIUM,children={editor.ui.label({text="New File Name",alignment=editor.ui.ALIGNMENT.CENTER}),editor.ui.string_field({grow=true,text=file_name,-- Typing callback:on_text_changed=function(new_text)file_name=new_textend})}}),buttons={editor.ui.dialog_button({text="Cancel",cancel=true,result=false}),editor.ui.dialog_button({text="Create File",default=true,result=true})}}))ifcreate_filethenprint("create",file_name)end
Here is a list of built-in input components:
string_field
,integer_field
andnumber_field
are variations of a single-line text field that allow editing strings, integers, and numbers.select_box
is used for selecting an option from predefined array of options with a dropdown control.check_box
is a boolean input field withon_value_changed
callbackbutton
withon_press
callback that gets invoked on button press.external_file_field
is a component intended for selecting a file path on the computer. It consists of a text field and a button that opens a file selection dialog.resource_field
is a component intended for selecting a resource in the project.All components except buttons allow setting anissue
prop that displays the issue related to the component (eithereditor.ui.ISSUE_SEVERITY.ERROR
oreditor.ui.ISSUE_SEVERITY.WARNING
), e.g.:
issue={severity=editor.ui.ISSUE_SEVERITY.WARNING,message="This value is deprecated"}
When issue is specified, it changes how the input component looks, and adds a tooltip with the issue message.
Here is a demo of all inputs with their issue variants:
To show a dialog, you need to useeditor.ui.show_dialog
function. It expects adialog
component that defines the main structure of Defold dialogs:title
,header
,content
andbuttons
. Dialog component is a bit special: you can’t use it as a child of another component, because it represents a window, not a UI element.header
andcontent
are usual components though.
Dialog buttons are special too: they are created usingdialog_button
component. Unlike usual buttons, dialog buttons don’t haveon_pressed
callback. Instead, they define aresult
prop with a value that will be returned by theeditor.ui.show_dialog
function when the dialog is closed. Dialog buttons also definecancel
anddefault
boolean props: button with acancel
prop is triggered when user pressesEscape or closes the dialog with the OS close button, anddefault
button is triggered when the user pressesEnter. A dialog button may have bothcancel
anddefault
props set totrue
at the same time.
Additionally, the editor defines some utility components:
separator
is a thin line used for delimiting blocks of contentscroll
is a wrapper component that shows scroll bars when the wrapped component does not fit in the assigned spaceSince components areimmutable userdata, it’s impossible to change them after they are created. How to make the UI change over time then? The answer:reactive components.
The editor scripting UI draws inspiration fromReact library, so knowing about reactive UI and React hooks will help.
In the most simple terms, a reactive component is a component with a Lua function that receives data (props) and returns view (another component). Reactive component function may usehooks: special functions in theeditor.ui
module that add reactive features to your components. By convention, all hooks have a name that starts withuse_
.
To create a reactive component, useeditor.ui.component()
function.
Let’s have a look at this example — a New File dialog that only allows creating a file if the entered file name is not empty:
-- 1. dialog is a reactive componentlocaldialog=editor.ui.component(function(props)-- 2. the component defines a local state (file name) that defaults to empty stringlocalname,set_name=editor.ui.use_state("")returneditor.ui.dialog({title=props.title,content=editor.ui.vertical({padding=editor.ui.PADDING.LARGE,children={editor.ui.string_field({value=name,-- 3. typing + Enter updates the local stateon_value_changed=set_name})}}),buttons={editor.ui.dialog_button({text="Cancel",cancel=true}),editor.ui.dialog_button({text="Create File",-- 4. creation is enabled when the name existsenabled=name~="",default=true,-- 5. result is the nameresult=name})}})end)-- 6. show_dialog will either return non-empty file name or nil on cancellocalfile_name=editor.ui.show_dialog(dialog({title="New File Name"}))iffile_namethenprint("create "..file_name)elseprint("cancelled")end
When you execute a menu command that runs this code, the editor will show a dialog with disabled"Create File"
dialog at the start, but, when you type a name and pressEnter, it will become enabled:
So, how does it work? On the very first render,use_state
hook creates a local state associated with the component and returns it with a setter for the state. When the setter function is invoked, it schedules a component re-render. On subsequent re-renders, the component function is invoked again, anduse_state
returns the updated state. New view component returned by the component function is then diffed against the old one, and the UI is updated where the changes were detected.
This reactive approach greatly simplifies building interactive UIs and keeping them in sync: instead of explicitly updating all affected UI components on user input, the view is defined as a pure function of the input (props and local state), and the editor handles all the updates itself.
The editor expects reactive function components to behave nicely for them to work:
If you are familiar withReact, you will notice that hooks in the editor have slightly different semantics when it comes to hook dependencies.
The editor defines 2 hooks:use_memo
anduse_state
.
use_state
Local state can be created in 2 ways: with a default value or with an initializer function:
-- default valuelocalenabled,set_enabled=editor.ui.use_state(true)-- initializer function + argslocalid,set_id=editor.ui.use_state(string.lower,props.name)
Similarly, setter can be invoked with a new value or with an updater function:
-- updater functionlocalfunctionincrement_by(n,by)returnn+byendlocalcounter=editor.ui.component(function(props)localcount,set_count=editor.ui.use_state(0)returneditor.ui.horizontal({spacing=editor.ui.SPACING.SMALL,children={editor.ui.label({text=tostring(count),alignment=editor.ui.ALIGNMENT.LEFT,grow=true}),editor.ui.text_button({text="+1",on_pressed=function()set_count(increment_by,1)end}),editor.ui.text_button({text="+5",on_pressed=function()set_count(increment_by,5)end})}})end)
Finally, the state may bereset. The state is reset when any of the arguments toeditor.ui.use_state()
change, checked with==
. Because of this, you must not use literal tables or literal initializer functions as arguments touse_state
hook: this will cause the state to reset on every re-render. To illustrate:
-- ❌ BAD: literal table initializer causes state reset on every re-renderlocaluser,set_user=editor.ui.use_state({first_name=props.first_name,last_name=props.last_name})-- ✅ GOOD: use initializer function outside of component function to create table statelocalfunctioncreate_user(first_name,last_name)return{first_name=first_name,last_name=last_name}end-- ...later, in component function:localuser,set_user=editor.ui.use_state(create_user,props.first_name,props.last_name)-- ❌ BAD: literal initializer function causes state reset on every re-renderlocalid,set_id=editor.ui.use_state(function()returnstring.lower(props.name)end)-- ✅ GOOD: use referenced initializer function to create the statelocalid,set_id=editor.ui.use_state(string.lower,props.name)
use_memo
You can useuse_memo
hook to improve performance. It is common to perform some computations in the render functions, e.g. to check if the user input is valid.use_memo
hook can be used in cases where checking if arguments to the computation function have changed is cheaper than invoking the computation function. The hook will call the computation function on first render, and will re-use the computed value on subsequent re-renders if all the arguments touse_memo
are unchanged:
-- validation function outside of component functionlocalfunctionvalidate_password(password)if#password<8thenreturnfalse,"Password must be at least 8 characters long."elseifnotpassword:match("%l")thenreturnfalse,"Password must include at least one lowercase letter."elseifnotpassword:match("%u")thenreturnfalse,"Password must include at least one uppercase letter."elseifnotpassword:match("%d")thenreturnfalse,"Password must include at least one number."elsereturntrue,"Password is valid."endend-- ...later, in component functionlocalusername,set_username=editor.ui.use_state('')localpassword,set_password=editor.ui.use_state('')localvalid,message=editor.ui.use_memo(validate_password,password)
In this example, password validation will run on every password change (e.g. on typing in a password field), but not when the username is changed.
Another use-case foruse_memo
is creating callbacks that are then used on input components, or when a locally-created function is used as a prop value for another component — this prevents unnecessary re-renders.
Did you spot an error or do you have a suggestion? Please let us know on GitHub!
GITHUB