Bundle Example: Add an HTML-based Tool

This tutorial builds on the material from Bundle Example: Add a Command.

This example describes how to create a ChimeraX bundle that defines a graphical interface to the two commands, tutorial cofm and tutorial highlight, defined in the Bundle Example: Add a Command example.

The ChimeraX user interface is built using PyQt6, which has a significant learning curve. However, PyQt6 has very good support for displaying HTML 5 with JavaScript in a window, which provides a simpler avenue for implementing graphical interfaces. This example shows how to combine a static HTML page with dynamically generated JavaScript to create an interface with only a small amount of code.

The steps in implementing the bundle are:

  1. Create a bundle_info.xml containing information about the bundle,

  2. Create a Python package that interfaces with ChimeraX and implements the command functionality, and

  3. Install and test the bundle in ChimeraX.

The final step builds a Python wheel that ChimeraX uses to install the bundle. So if the bundle passes testing, it is immediately available for sharing with other users.

Source Code Organization

The source code for this example may be downloaded as a zip-format file containing a folder named tut_tool_html. Alternatively, one can start with an empty folder and create source files based on the samples below. The source folder may be arbitrarily named, as it is only used during installation; however, avoiding whitespace characters in the folder name bypasses the need to type quote characters in some steps.

Sample Files

The files in the tut_tool_html folder are:

  • tut_tool_html - bundle folder
    • bundle_info.xml - bundle information read by ChimeraX

    • src - source code to Python package for bundle
      • __init__.py - package initializer and interface to ChimeraX

      • tool.py - source code to implement the Tutorial (HTML) tool

      • docs/user/commands/tutorial.html - help file describing the graphical tool

The file contents are shown below.

bundle_info.xml

bundle_info.xml is an eXtensible Markup Language format file whose tags are listed in Bundle Information XML Tags. While there are many tags defined, only a few are needed for bundles written completely in Python. The bundle_info.xml in this example is similar to the one from the Bundle Example: Add a Command example with changes highlighted. For explanations of the unhighlighted sections, please see Bundle Example: Hello World and Bundle Example: Add a Command.

The BundleInfo, Synopsis and Description tags are changed to reflect the new bundle name and documentation (lines 8-10 and 19-24). Three other changes are needed for this bundle to declare that:

  1. this bundle depends on the ChimeraX-UI and ChimeraX-Tutorial_Command bundles (lines 35-36),

  2. non-Python files need to be included in the bundle (lines 40-43), and

  3. a single graphical interface tool is provided in this bundle (lines 51-52).

The Dependency tags on lines 35 and 36 inform ChimeraX that the ChimeraX-UI and ChimeraX-Tutorial_Command bundles must be present when this bundle is installed. If they are not, they are installed first. The ChimeraX-UI bundle is needed to provide the chimerax.ui.HtmlToolInstance class used for building the user interface (see tool.py` below) and the ChimeraX-Tutorial_Command bundle is needed to provide the ChimeraX commands that will be used for actually performing user actions.

The DataFiles tag on lines 40-43 informs ChimeraX to include non-Python files as part of the bundle when building. In this case, tool.html (implicitly in the src folder) which provides the HTML component of our interface should be included. Also, the help documentation for our tool, tutorial.html.

The ChimeraXClassifier tag on lines 51-52 informs ChimeraX that there is one graphical interface tool named Tutorial (HTML) in the bundle. The last two fields (separated by ::) are the tool category and the tool description. ChimeraX will add a Tutorial (HTML) menu entry in its Tool submenu that matches the tool category, General; if the submenu does not exist, it will be created.

src

src is the folder containing the source code for the Python package that implements the bundle functionality. The ChimeraX devel command, used for building and installing bundles, automatically includes all .py files in src as part of the bundle. (Additional files may also be included using bundle information tags such as DataFiles as shown in Bundle Example: Add a Tool.) The only required file in src is __init__.py. Other .py files are typically arranged to implement different types of functionality. For example, cmd.py is used for command-line commands; tool.py or gui.py for graphical interfaces; io.py for reading and saving files, etc.

src/__init__.py

As described in Bundle Example: Hello World, __init__.py contains the initialization code that defines the bundle_api object that ChimeraX needs in order to invoke bundle functionality. ChimeraX expects bundle_api class to be derived from chimerax.core.toolshed.BundleAPI with methods overridden for registering commands, tools, etc.

In this example, the start_tool() method is overridden to invoke a bundle function, tool.TutorialTool, when the user selects the Tutorial (HTML) menu item from the General submenu of the Tools menu. (The Tutorial (HTML) and General names are from the ChimeraXClassifier tag in bundle_info.xml as described above.)

The arguments to start_tool(), in bundle API version 1, are session, a chimerax.core.session.Session instance, bi, a chimerax.core.toolshed.BundleInfo instance, and ti, a chimerax.core.toolshed.ToolInfo instance. session is used to access other available data such as open models, running tasks and the logger for displaying messages, warnings and errors. bi contains the bundle information and is not used in this example. ti contains the tool information; in this case, it is used to make sure the name of the tool being invoked is the expected one. If it is, tool.TutorialTool is called; if not, an exception is thrown, which ChimeraX will turn into an error message displayed to the user.

src/tool.py

tool.py defines the TutorialTool class that is invoked by ChimeraX (via the start_tool() method of bundle_api in __init__.py) when the user selects the Tutorial (HTML) menu item from the Tools menu.

chimerax.ui.HtmlToolInstance is the base class for simplifying construction of tools with HTML-based graphical interface. When an instance of a subclass of HtmlToolInstance is created, its constructor must call the HtmlToolInstance constructor to set up the graphical interface framework. The arguments to the HtmlToolInstance constructor is the session and the tool name. An optional argument, size_hint, may be supplied to guide the tool layout, but, as the name suggests, it is only a hint and may not be honored. The superclass constructor creates a ChimeraX tool which contains a single widget for displaying an HTML page. The widget is accessible using the html_view attribute, an instance of chimerax.ui.widgets.HtmlView. In this example, the TutorialTool constructor calls its superclass constructor and then its own _build_ui method, which simply constructs the URL to a static HTML file in the bundle Python package and displays it in the widget using self.html_view's setUrl() method.

The HtmlToolInstance class also helps manage threading issues that arise from the way HTML is displayed using PyQt6. The underlying Qt WebEngine machinery uses a separate thread for rendering HTML, so developers need to make sure that code is run in the proper thread. In particular, access to shared data must be synchronized between the Qt main and WebEngine threads. HtmlToolInstance simplifies the issues by calling subclass methods in the main thread when an interesting event occurs in the WebEngine thread.

The HtmlToolInstance constructor checks the derived class for the presence of an attribute, CUSTOM_SCHEME and a method, handle_scheme(). If both are defined, then the base class will arrange for handle_scheme(). to be called (in the main thread) whenever a link matching CUSTOM_SCHEME is followed. In this example, the custom scheme is tutorial (line 31), so when the user clicks on links such as tutorial:cofm and tutorial:highlight (see tool.html below), handle_scheme(). is called with the clicked URL as its lone argument. Currently, the argument is an instance of PyQt6.QtCore.QUrl but that may change later to remove explicit dependency on PyQt. handle_scheme(). is expected to parse the URL and take appropriate action depending on the data. In this example, the URL path is a command name and the query contains data for command arguments. Three command names are supported: update_models(), cofm, and highlight. update_models() is invoked when the page is loaded (see tool.html below) and is handled as special case (see below). For the other commands, known query fields are target, model, color, count, weighted and transformed. The command names and query fields are combined to generate a ChimeraX command string, which is then executed using chimerax.core.commands.run(). The main benefit of executing a command string is automatic display of command and replies in the ChimeraX log.

The HtmlToolInstance class also helps with monitoring the opening and closing of models. If the derived class defines a method named update_models(), the method will be called whenever a new model is opened or an existing model is closed. Note that this is not when a model instance is created or deleted, because transient models that are not shown to the user (opened) do not trigger calls to update_models(). update_models() is typically called with two arguments: the name of the triggering event (either “add models” or “remove models”) and the list of models added or removed. In this example, update_models() is used for updating the HTML drop-down list of models, so only the currently opened models are important, and neither the trigger name nor the models added or removed is relevant. In fact, its arguments are given default values so that update_models() can be called with no arguments when the HTML page is first loaded. Whether called in response to model addition/removal or HTML events, update_models() does the following:

  1. build a list of 2-tuples of (display text, atom_specifier), one for each open model.

  2. convert the list into HTML strings of option elements.

  3. concatenate them into a single HTML text string.

  4. set a string to “true” or “false” depending on whether there are any models open.

  5. combine the HTML text string and the boolean string with a JavaScript template to generate a JavaScript script.

  6. execute the JavaScript script in the HTML widget using self.html_view's runJavaScript() method.

Note the conversion from Python string to JavaScript string is accomplished using json.dumps(), which properly handles special characters such as quotes. The JavaScript template uses standard JavaScript HTML DOM functionality to manipulate the HTML page contents. If executing JavaScript results in errors, the messages should appear in the ChimeraX log.

src/tool.html

tool.html is an HTML 5 file containing the skeleton of the graphical user interface, consisting of a form with multiple elements such as check boxes for boolean options and radio buttons for multiple-choice options. Even more exotic inputs like color selection or date and time are supported in HTML 5 forms.

The name attributes in the HTML form elements correspond to the query field names, and are exactly the same set of query field names expected by handle_scheme() in tool.py.

The select element is the drop-down list that is modified when update_models() runs its generated JavaScript script. To make the element easier to find, it not only has a name attribute, which does not have to be unique among all elements, but also an id attribute, which is (or should be) unique. The JavaScript getElementById function returns a single element, whereas getElementsByName function returns a list of elements.

The two submit buttons are tagged with class name submit so that they can be found using getElementsByClassName. The buttons are enabled or disabled in the same JavaScript script that updates the drop-down list of models.

src/docs/user/commands/tutorial.html

The documentation for the graphical tool should be written in HTML 5 and saved in a file with a suffix of .html. For our example, we named the help file tutorial.html. The location of the help file (relative to src/docs) is expicitly indicated by setting the help attribute of the HtmlToolInstance, as shown on line 31 of tool.py:

When help files are included in bundles, documentation for the tools may be displayed using the Help entry of the tool’s context menu, the same as built-in ChimeraX tools. The directory structure is chosen to allow for multiple types of documentation for a bundle. For example, developer documentation such as the bundle API are saved in a devel directory instead of user; documentation for typed commands are saved in user/commands instead of user/tools.

While the only requirement for documentation is that it be written as HTML, it is recommended that developers write tool help files following the above template, with:

  • a banner linking to the documentation index,

  • text describing the tool, and

  • an address for contacting the bundle author.

Note that the target links used in the HTML file are all relative to ... Even though the tool documentation HTML file is stored with the bundle, ChimeraX treats the links as if the file were located in the tools directory in the developer documentation tree. This creates a virtual HTML documentation tree where tool HTML files can reference each other without having to be collected together.

Optional: Session Saving

The current session behavior of our example tool (disappearing/closing) may be fine for some tools, particularly simpler ones. However, some tools may prefer to either stay in existence across a session restore, or to save state in sessions and restore appropriately.

The Models tool is an example of the former behavior. It does not close when a session restores, but instead simply displays the new model information. It saves no state in the session. To achieve this behavior, it just sets the chimerax.core.tools.ToolInstance class attribute SESSION_ENDURING to True. In the above example, changing the SESSION_ENDURING HtmlToolInstance class attribute would have the same effect, since HtmlToolInstance inherits from ToolInstance.

To achieve the latter behavior, you would instead change the SESSION_SAVE class variable to True, and in addition you would implement a couple of additional methods in the TutorialTool class and one in the _MyAPI class. Before we get to the details of that, it would be good to go over how the ChimeraX session-saving mechanism works, so you can have a better understanding of how these new methods are used and should be implemented…

When a session is saved, ChimeraX looks through the session object for attributes that inherit from chimerax.core.state.StateManager. For such attributes it calls their take_snapshot() method and stows the result. One of the state managers in the session is the tool manager. The tool manager will in turn call take_snapshot() on all running tools that inherit from chimerax.core.state.State. (which should be all of them since ToolInstance inherits from State) and stow the result. On restore, the class static method restore_snapshot() is called with the data that take_snapshot() produced, and restore_snapshot() needs to return a restored object.

In practice, take_snapshot() typically returns a dictionary with descriptive key names and associated values of various information that would be needed during restore. Frequently one of the keys is ‘version’ so that restore_snapshot can do the right thing if the format of various session data items changes. The values can be regular Python data (including numpy/tinyarray) or class instances that themselves inherit from State.

restore_snapshot(session, data) uses data to instantiate an object of that class and return it. If it is difficult to form the constructor arguments for the class from the session data, or to completely set the object state via those arguments then you will have to use “two pass” initialization, where you call the constructor in a way that indicates that it is being restored from a session (e.g. passing None to an otherwise mandatory argument) and then calling some method (frequently called set_state_from_snapshot()) to fully initialize the minimally initialized object.

Session restore knows what bundles various classes came from, but not how to get those classes from the bundle so therefore the bundle’s BundleAPI object needs to implement it’s get_class(class_name) static method to return the class object that corresponds to a string containing the class name.

Now, the TutorialTool class doesn’t really have any state that would need to be saved into a session. For the purpose of example, let’s suppose that the tool’s behavior somehow depended on the last command it had issued, and that command was saved in an attribute of TutorialTool named prev_command.

To save/restore the tool and its prev_command attribute, we add take_snapshot() and restore_snapshot() methods to the TutorialTool class, as per the below:

class TutorialTool(HtmlToolInstance):

    # previously implemented parts of the class here...

    def take_snapshot(self, session, flags):
        # For now, the 'flags' argument can be ignored.  In the
        # future, it will be used to distnguish between saving
        # for inclusion in a session vs. inclusion in a scene
        #
        # take_snapshot can actually return any type of data
        # it wants, but a dictionary is usually preferred because
        # it is easy to add to if the tool is later enhanced or
        # modified.  Also, the data returned has to consist of
        # builtin Python types (including numpy/tinyarray
        # types) and/or class instances that derive from State.

        return {
            # The 'version' key not strictly necessary here,
            # but will simplify coding the restore_snapshot
            # method in the future if the format of the
            # data dictionary is changed
            'version': 1,
            'prev_command': self.prev_command
        }

    @classmethod
    def restore_snapshot(class_obj, session, data):
        # This could also be coded as an @staticmethod, in which
        # case you would have to use the actual class name in 
        # lieu of 'class_obj' below
        #
        # 'data' is what take_snaphot returned.  At this time,
        # we have no need for the 'version' key of 'data'
        inst = class_obj(session, "Tutorial (HTML)")
        inst.prev_command = data['prev_command']
        return inst

Finally, for the session-restore code to be able to find the TutorialTool class, we must implement the get_class() static method in our _MyAPI class, like so:

class _MyAPI(BundleAPI):

    # previously implemented parts of the class here...

    @staticmethod
    def get_class(class_name):
        # class_name will be a string
        if class_name == "TutorialTool":
            from . import tool
            return tool.TutorialTool
        raise ValueError("Unknown class name '%s'" % class_name)

Building and Testing Bundles

To build a bundle, start ChimeraX and execute the command:

devel build PATH_TO_SOURCE_CODE_FOLDER

Python source code and other resource files are copied into a build sub-folder below the source code folder. C/C++ source files, if any, are compiled and also copied into the build folder. The files in build are then assembled into a Python wheel in the dist sub-folder. The file with the .whl extension in the dist folder is the ChimeraX bundle.

To test the bundle, execute the ChimeraX command:

devel install PATH_TO_SOURCE_CODE_FOLDER

This will build the bundle, if necessary, and install the bundle in ChimeraX. Bundle functionality should be available immediately.

To remove temporary files created while building the bundle, execute the ChimeraX command:

devel clean PATH_TO_SOURCE_CODE_FOLDER

Some files, such as the bundle itself, may still remain and need to be removed manually.

Building bundles as part of a batch process is straightforward, as these ChimeraX commands may be invoked directly by using commands such as:

ChimeraX --nogui --exit --cmd 'devel install PATH_TO_SOURCE_CODE_FOLDER exit true'

This example executes the devel install command without displaying a graphics window (--nogui) and exits immediately after installation (exit true). The initial --exit flag guarantees that ChimeraX will exit even if installation fails for some reason.

Distributing Bundles

With ChimeraX bundles being packaged as standard Python wheel-format files, they can be distributed as plain files and installed using the ChimeraX toolshed install command. Thus, electronic mail, web sites and file sharing services can all be used to distribute ChimeraX bundles.

Private distributions are most useful during bundle development, when circulation may be limited to testers. When bundles are ready for public release, they can be published on the ChimeraX Toolshed, which is designed to help developers by eliminating the need for custom distribution channels, and to aid users by providing a central repository where bundles with a variety of different functionality may be found.

Customizable information for each bundle on the toolshed includes its description, screen captures, authors, citation instructions and license terms. Automatically maintained information includes release history and download statistics.

To submit a bundle for publication on the toolshed, you must first sign in. Currently, only Google sign in is supported. Once signed in, use the Submit a Bundle link at the top of the page to initiate submission, and follow the instructions. The first time a bundle is submitted to the toolshed, it is held for inspection by the ChimeraX team, which may contact the authors for more information. Once approved, all subsequent submissions of new versions of the bundle are posted immediately on the site.

What’s Next