Toolbox UI Script for Fusion

We use quite a few scripts and custom tools at Muse VFX, and using Fusion's built-in methods of organizing them was starting to get cumbersome. I built a little toolbox panel to ease the burden of keeping track of everything, inspired by SirEdric's ScriptScript script at We Suck Less. It's a nice example of a responsive script that is easy to maintain and attractive. It also extended my understanding of Fusion's UI Manager, enabling even more options in the future. Let's take a look under the hood.

The back end portion of the script is fairly simple. I just needed a way to execute other scripts, and add tools and settings from within this one. There are three different modes: Execute a script, add a tool (including Fuses but excluding macros), and paste a .setting file (both macros and templates). By template here, I mean a collection of preconfigured nodes. It's just like a macro, only it's not wrapped in a group and has no external controls.

The command to run a script is

composition:RunScript(path, [arguments])

RunScript() is a method of the composition object in Fusion and is documented in the Fusion 8 Scripting Guide.

Adding a Fuse to the Flow is also very simple, since they are treated just like other Fusion tools: composition:AddTool(id) Again, this method is documented in the Guide.

Adding a macro or a .setting requires the use of Fusion's relatively new Actions system:

_comp:DoAction("AddSetting", {filename = path})

DoAction() is another method of composition, but it is not documented in the Scripting Guide, which is older than the Actions system. Not very many Fusion functions use the Actions system at this point, but for those that do, you can get information from them by using Andrew Hazelden's ActionListener script, available with the UI Manager Examples in Reactor.

The UI Manager Examples are invaluable in learning how to create a Fusion GUI. It certainly still takes some practice, and there is a lot of information that still needs to be teased out through experimentation, but they certainly gave me a huge leg up.

I wanted the toolbox to be easy to update and understand, so I built it to be as modular as possible. The buttons are defined in a table near the top of the main() function. I'd like to eventually move them into a config file so a TD doesn't even need to open the script itself to make updates. That table looks like this:

BUTTONS = {
{ID = id, Text = string, parent = string, script = [[path]], args = table,},
{ID = id, Text = string, parent = string, macro = [[path]],},
{ID = id, Text = string, parent = string, popup = string, args = [[path]],},
{ID = id, Text = string, parent = string, fuse = tool_id,},

}

The ID field provides a handle to uniquely identify each button. Text is the name that appears on the button in the GUI. Parent is the category in which the button appears.

Script defines a path to the Lua or Python script to be run. An args field accompanying the script field takes a table of arguments. Most commonly, this is the currently selected tool so that tool scripts can be run from the panel.

Macro takes a path to a .setting file; templates and macros work exactly the same, so this field works for both.

Popup will open a new window with a set of buttons created dynamically from a folder full of files. The args field provides the path to that folder. My version of this script uses that feature for the Glitch tools.

Fuse provides only the tool id from Fusion's registry.

With all of this information contained in a single table, it should be easy to read it in from a separate file, or to update the toolbox by altering the table right in the script.

With the desired buttons defined, the first thing to be done is to create the categories and collate the buttons for each one. There is a global constant table variable CATEGORIES configured at the head of the script that contains a list of category headings. These consist simply of an ID and Text entry for each. For each entry in this table, we create a sub-table called buttons, then we scan through the BUTTONS table and find each button that has a parent that matches the category's ID. The button entry is inserted into the appropriate category's buttons subtable. Here's what that looks like:

-- Collate buttons in each category
for i, category in ipairs(CATEGORIES) do
    category.buttons = {}
    for i, button in ipairs(BUTTONS) do
        if button.parent == category.ID then
            table.insert(category.buttons, button)
        end
    end
end

Although from a program efficiency standpoint it would seem better to just make the category and button table to begin with, by separating them in this fashion, we can easily move a button to a different category by changing just one field name instead of having to cut-and-paste the entry from one category sub-table to another. It's been a little while since I wrote this code, though, and I think it would be better to only run the for loop on the BUTTONS table once, inserting each entry into the appropriate sub-table of CATEGORIES as we go instead of looping across BUTTONS in its entirety once for each category. I think that would require modifying the structure of CATEGORIES such that the ID becomes a key. It might be difficult to control the order that the categories appear in the window. That deserves some thought.

UI Manager

Now let's move on the UI Manager stuff. After knocking together a half-dozen scripts or so, I realized that it would simplify my life if I had a main window with a few standard features that I could just copy from a library into each script as needed. Here is my library's createMainWindow() function:

function createMainWindow(x, y, width, height, title, id, link)
    dprint('createMainWindow')

    -- Generate HTML for logo header
    html = "<html>\n"
    html = html.."\t<head>\n"
    html = html.."\t\t<style>\n"
    html = html.."\t\t</style>\n"
    html = html .."\t</head>\n"
    html = html .."\t<body>\n"
    html = html .."\t<div>\n"
    html = html .."\t<div style=\"float:right;\">\n"
    html = html .. museLogo()
    html = html .. "\t</div>\n"
    html = html .. "\t</body>\n"
    html = html .. "</html>"

    -- Create the window
    window = disp:AddWindow({

        -- Window properties
        ID = id,
        WindowTitle = title,
        --Geometry = {x, y, width, height},

        -- Main window container
        ui:VGroup{
        ID = 'root',

        -- The logo header and optional progress bar.
        ui:VGroup{
            ID = 'header',
            Weight = 0,
            ui:TextEdit{
                ID = 'museLogo',
ReadOnly = true,
Alignment = { AlignHCenter = true, AlignTop = true, },
MinimumSize = {286, 80},
MaximumSize = {width * 2, 80},
HTML = html,
},

          --ui:TextEdit{
ID = 'ProgressHTML',
ReadOnly = true,
MinimumSize = {650, 32},
MaximumSize = {width * 2, 32},
Weight = 1,
FontPointSize = 1,
},

        },

        -- This holds the dynamic content
       ui:VGroup{
            ID = 'content',
            Weight = 2.0,
       },

        -- Footer contains the window control buttons
        ui:VGroup{
            ID = 'footer',
            Weight = 0.0,
            ui:HGroup{
                ID = 'control',
                Weight = 0.0,

                ui:Label{
                    ID = 'link',
                    Text = link,
                    Alignment = {AlignRight = true, AlignTop = true, },
                    WordWrap = true,
                    OpenExternalLinks = true,
                },

                ui:HGap(width - 300),

                ui:Button{
                    ID = 'cancel',
                    Text = 'Cancel',
                },

                ui:Button{
                    ID = 'next',
                    Text = 'Next',
                },
            },
        },
    },
})

return window
end

Some items of note. First, if you look in that series of lines that create HTML, you'll see a reference to the function museLogo(), which generates the branding image at the top of my UI Manager windows. I used the Base64-Image string generator to convert our logo into something that could be embedded into the script without requiring any external resources. Thanks go once again to Andrew Hazelden for demonstrating how to create an HTML string and insert it into a TextEdit widget. You can see the entire museLogo() function in the complete script at the end of this article. The Base64 is too long to post here.

Next I'd like to draw your attention to the commented-out TextEdit widget called ProgressHTML. Some scripts take a bit of time to execute, so I've reserved a place right below the logo for a progress bar. Not every script uses it, so it's disabled by default. The toolbox doesn't need it, so it won't be demonstrated here. Hopefully I'll find the time to share more scripts that do make use of the progress bar.

After the header comes an empty VGroup called content. Since this is a generic function that just creates a shell of a window, there is nothing here—it's simply a container into which the window's main contents will be inserted during script execution. We'll see how that works shortly.

Finally, the standard footer contains an optional hypertext link, which I generally use to point to documentation on our internal wiki, and the Cancel and Next buttons, which are useful in the majority of scripts. In this case they will be commented out since they aren't needed.

In this script, I call the function like so:

local x = nil
local y = nil
local width = 400
local height = 400

-- Create main window
window = createMainWindow(x, y, width, height, 'MuseToolBox', 'main', '<a href="http://wiki/doku.php?id=fusion#custom_tools">Documentation</a>')

window:Resize({ width, height })
content = window:GetItems().content

In the createMainWindow() function, I commented out the Geometry attribute so the window will always appear in the center of the screen. You can put it in a specific spot, or have it appear under the user's mouse, but ultimately putting it in the center feels most natural. Since Geometry is disabled, window:Resize() does the job of setting the window's initial size. This will get overridden again when the buttons are added, but it establishes a fixed minimum size so that the logo always fits and the window is never larger than it needs to be. The final line creates a handle to that content widget I mentioned earlier.

Next we'll create the category headers and insert the buttons:

for i, category in pairs(CATEGORIES) do
    local rows = math.ceil(table.getn(category.buttons) / MAX_COLUMNS)
    local pixelSize = 18
    local gap = 5
    if category.suppressGap == true then
        gap = 0
    end
    if category.suppressLabel == true then
        pixelSize = 1
    end

content:AddChild(ui:VGroup{ ID = category.ID..'Panel', Weight = 0,

        ui:VGroup{ ID = category.ID..'Header', Weight = 0, ui:Label{ ID = category.ID..'Label', Text = category.Text, Font = ui:Font{PixelSize = pixelSize}, }, },
})

for i=1, rows do

        content:GetItems()[category.ID..'Header']:AddChild(ui:HGroup{ID = category.ID..i, Weight = 0, })
    end

content:GetItems()[category.ID..'Header']:AddChild(ui:VGap(10))


-- Create buttons

    for i, button in ipairs(category.buttons) do
        local parent = button.parent..math.ceil(i/(table.getn(category.buttons)/rows))
        addUIButton(parent, button.ID, button.Text)
    end
end

For each category, the first thing is to determine the number of rows we'll need, given the maximum number of columns and the number of buttons in the category. math.ceil() returns an integer, rounded up, so if 7/3 = 2.333…, math.ceil(7/3) = 3. pixelSize determines the size of the category header font. I think the value is the x-height, but I haven't taken the time to be sure about that. In any case, it's easy enough to hit the desired size by trial-and-error. The gap determines the distance between the last button in a category and the next header. If for some reason you want to prevent a header from showing up, you can give it some optional attributes in the CATEGORIES stable to suppress it and/or the gap.

Now we finally get to populate the content panel. The AddChild() function lets us insert new widgets into an existing UI Manager window. The ID attribute of the widget and its sub-groups is created dynamically from the category ID, so we don't need to know ahead of time how many categories there are or what they're called. Once the category's group is created, we create even more subgroups—one for each row that we determined we would need. Since we haven't assigned the category to a variable, we'll query the window for the category we want like this:

content:GetItems()[category.ID..'Header']:AddChild(ui:HGroup{ID = category.ID..i, Weight = 0, })

GetItems() returns a table of items in the content panel. We don't care about the entire list, though, just the one with a specific name. To that item, we add a new child named for the category with a numeral appended: Utility1, Utility2, Utility3, and so forth.

And at long last we insert the buttons themselves into the row groups:

local parent = button.parent..math.ceil(i/(table.getn(category.buttons)/rows))
addUIButton(parent, button.ID, button.Text)

That first line contains a long expression that breaks down like so: button.parent is an entry in the master table that holds the parent category for the button. We've already covered what math.ceil() does. That leaves the math problem inside—i is the index of the button in the category.buttons table, which is divided by the total number of buttons and again by the number of rows. This ultimately results in a category name followed by an integer: Utility1, Utility2, Utility3… That's the name of the row the button goes into, and we feed that to the function addUIButton(), along with the button's ID and it's human-readable name (which is often the same as the ID, but not always.)

Here's the addUIButton() function:

function addUIButton(parent, ID, name)
    if not name then
        name = ID
    end

    local content = window:GetItems()[parent]
    content:AddChild(ui:Button{ ID = ID, Weight = 1, Text = name, })
end

As you can see from that first if statement, the name argument is optional. If it isn't provided, the text on the button will be the same as the ID. The rest of the function is the same thing we saw above: We get an item from the window with an ID that matches the parent argument. Then we use AddChild() to make a button with the appropriate ID and Text. The Weight attribute ensures that the buttons expand to fit the available space.

After having put all of those widgets into the UI, you'll likely find that they've just been stacked on top of one another. To sort out the mess, call window:RecalcLayout(). That will re-render the window and make it beautiful.

Event Handling

With the UI configured, the only thing left to do is control what happens when the user interacts with it. UI Manager is a variant of the Qt framework, and it uses the same concept of "slots and signals." You can look that up in numerous Qt tutorials if you like. Briefly stated, whenever the user interacts with the window—in this case, clicks a button or closes it—a signal, or event, is generated. The UIDispatcher listens for these events and executes a function whenever it receives one. The event contains information about what caused it: All of the attributes of the widget that was activated and what exactly was done to it. In this case, the only thing we'll listen for is the button's ID when it is clicked.

In most UI Manager-based scripts, you'd create your event handlers explicitly: function window.On.Next.Clicked(ev)
    <some code>
end

In this case, we can't do that because we don't know the ID of the buttons before-hand. Instead, we need to create these event handlers dynamically, just like we did the buttons themselves. We'll start by creating a table to hold the functions, then loop through the BUTTONS table, creating an event handler for each entry:

local dynamicFunctions = {}
for i, button in ipairs(BUTTONS) do
    dynamicFunctions[button.ID] = {}
    dynamicFunctions[button.ID].Clicked = function (ev)
        if button.macro then
            _fusion.CurrentComp:DoAction("AddSetting", { filename = button.macro })
        elseif button.popup then
            dispatcher(button.popup, button.args)
        elseif button.fuse then
            _fusion.CurrentComp:AddTool(button.fuse)
        else
            _fusion.CurrentComp:RunScript(button.script, args) -- Arguments must be passed as a table.
        end
    end
end

Lua's indifference toward variable typing is really cool because it allows us to put pretty much anything we want in a table, including functions. And that allows us to create functions that do things we couldn't anticipate at the time of writing the code. As a result of running this loop, we'll wind up with a table that looks something like this:

dynamicFunctions{
    FetchRenders{
        Clicked = function(ev) {
_fusion.CurrentComp:RunScript(S:\...\FetchRenders.lua)
        }
    }
    MADKey{
        Clicked = function(ev) {
_fusion.CurrentComp:DoAction("AddSetting", { filename = S:\…\MADKey.setting })
}

    RSCameraExtractor{
        Clicked = function(ev) {
_fusion.CurrentComp:AddTool("Fuse.RSCameraExtractor")
}

    }

And we can call one of these functions with dynamicFunctions[FetchRenders].Clicked

So to prepare the event handlers, we run a for loop over the dynamicFunctions table:

for i, func in pairs(dynamicFunctions) do
    window.On[i] = func
end

Modules

There are two final bits to talk about. If a button has the popup attribute, the script will execute a secondary module. I designed it to give me the ability to put the Glitch Tools macros in a category all their own. The glitchTools() function opens another window and populates it with buttons from .setting files found in a designated folder. There is a dispatcher() function that lets you call additional modules with customized behavior.

With the information presented above, you should be able to figure out how glitchTools() works on your own.

Here's the entire script: MuseToolBox.lua

Enjoy!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.