OpenFlexure eV GUI

Introduction

The main client application for the OpenFlexure Microscope, OpenFlexure eV, can render simple GUIs (graphical user interfaces) for extensions.

We define our user interface by making use of the extensions general metadata, added using the add_meta function. This function adds arbitrary additional data to your extensions web API description, for example:

# Create your extension object
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")

...

my_extension.add_meta("myKey", "My metadata value")

OpenFlexure eV will recognise the gui metadata key, and render properly structured descriptions of a GUI in the format described below. The gui data essentially describes HTML forms, which it is up to the client to render. The form is constructed by specifying a set of components, and their values.

Each component in the form has a name property, which must match up to a property your API route expects in JSON POST requests, and returns in JSON GET requests.

Structure of gui

Root level

The root of your gui dictionary expects 2 properties:

icon - The name of a Material Design icon to use for your plugin

viewPanel (optional) - Content to display to the right of the extension form. Either stream (default), gallery, or settings.

forms - An array of forms as described below

Form level

Your extension can contain multiple forms. For example, if your extension creates several API routes, you will need a separate form for each route.

Each form is described by a JSON object, with the following properties:

name - A human-readable name for the form

route - String of the corresponding API route. Must match a route defined in your api_views dictionary

isTask (optional) - Whether the client should treat your API route as a long-running task

isCollapsible (optional) - Whether the form can be collapsed into an accordion

submitLabel (optional) - String to place inside of the form’s submit button

schema - List of dictionaries. Each dictionary element describes a form component.

emitOnResponse (optional) - OpenFlexure eV event to emit when a response is recieved from the extension (generally avoid unless you know you need this.)

Component level

Each form can (and probably should) contain multiple components. For example, if your API route expects several parameters in a POST requests, each parameter can be bound to a form component.

Upon form submission, the form data will be converted into a JSON object of key-value pairs, where the key is the components name, and the value is it’s current value.

An overview of available components, and their properties, can be found below.

Arranging components

You can request that the client render several components in a horizontal grid by placing them in an array. You cannot nest arrays however. Each component in the array will be rendered with equal width as far as possible.

Overview of components

fieldType Data type Properties Example
checkList [str, str,…]

name (str) Unique name of the component

label (str) Friendly label for the component

value ([str, str,…]) List of selected options

options ([str, str,…]) List of all options

https://openflexure.gitlab.io/assets/plugin-form-components/checkList.png
htmlBlock N/A

name (str) Unique name of the component

label (str) Friendly label for the component

content (str) HTML string to be rendered

https://openflexure.gitlab.io/assets/plugin-form-components/htmlBlock.png
keyvalList dict

name (str) Unique name of the component

value (dict) Dictionary of key-value pairs

https://openflexure.gitlab.io/assets/plugin-form-components/keyvalList.png
labelInput str

name (str) Unique name of the component

label (str) Friendly label for the component

value (str) Value of the editable label text

https://openflexure.gitlab.io/assets/plugin-form-components/labelInput.png
numberInput int

name (str) Unique name of the component

label (str) Friendly label for the component

value (int) Value of the input

placeholder (int) Placeholder value

https://openflexure.gitlab.io/assets/plugin-form-components/numberInput.png
radioList String

name (str) Unique name of the component

label (str) Friendly label for the component

value (str) Currently selected option

options ([str, str,…]) List of all options

https://openflexure.gitlab.io/assets/plugin-form-components/radioList.png
selectList str

name (str) Unique name of the component

label (str) Friendly label for the component

value (str) Currently selected option

options ([str, str,…]) List of all options

https://openflexure.gitlab.io/assets/plugin-form-components/selectList.png
tagList [str, str,…]

name (str) Unique name of the component

value ([str, str,…]) List of tag strings

https://openflexure.gitlab.io/assets/plugin-form-components/tagList.png
textInput str

name (str) Unique name of the component

label (str) Friendly label for the component

value (int) Value of the input

placeholder (str) Placeholder value

https://openflexure.gitlab.io/assets/plugin-form-components/textInput.png

Note: Basic input types (textInput, numberInput) can also include additional attributes for HTML input elements inputs (e.g. placeholder, required, min, max). These additional attributes will be forwarded to the rendered HTML elements.

Building the GUI

Once you have a dictionary describing your GUI, use the openflexure_microscope.api.utilities.gui.build_gui() function to fill in and expand any information required to have it properly function. This function expands your route values to include your extensions full URI, and handles returning dynamic GUIs.

For example:

my_gui = {...}

# Create your extension object
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")

...

my_extension.add_meta("gui", build_gui(my_gui, my_extension))

Dynamic GUIs

Instead of passing a static dictionary to openflexure_microscope.api.utilities.gui.build_gui(), you can instead pass a callable function which returns a dictionary. This function is then called every time a client requests a description of active extensions.

Using a callable has the advantage of allowing your extensions GUI to be updated as it is used. This could be as simple as changing value parameters of components (to show up-to-date default form values), but could be used to entirely change the GUI form as it is used, for example dynamically changing options in select boxes.

For example, this could take the form:

def create_dynamic_form():
    ...
    generated_form_dict = {...}
    return generated_form_dict

# Create your extension object
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")

...

my_extension.add_meta("gui", build_gui(create_dynamic_form, my_extension))

Complete example

Adding a GUI to our previous timelapse example extension becomes:

import time  # Used in our timelapse function

from labthings import current_action, fields, find_component, update_action_progress
from labthings.extensions import BaseExtension
from labthings.views import ActionView

# Used to convert our GUI dictionary into a complete eV extension GUI
from openflexure_microscope.api.utilities.gui import build_gui

# Used in our timelapse function
from openflexure_microscope.captures.capture_manager import generate_basename


# Create the extension class
class TimelapseExtension(BaseExtension):
    def __init__(self):
        # Superclass init function
        super().__init__("org.openflexure.timelapse-extension", version="0.0.0")

        # Add our API views
        self.add_view(TimelapseAPIView, "/timelapse")

        # Add our GUI description
        gui_description = {
            "icon": "timelapse",  # Name of an icon from https://material.io/resources/icons/
            "forms": [  # List of forms. Each form is a collapsible accordion panel
                {
                    "name": "Start a timelapse",  # Form title
                    "route": "/timelapse",  # The URL rule (as given by "add_view") of your submission view
                    "isTask": True,  # This forms submission starts a background task
                    "isCollapsible": False,  # This form cannot be collapsed into an accordion
                    "submitLabel": "Start",  # Label for the form submit button
                    "schema": [  # List of dictionaries. Each element is a form component.
                        {
                            "fieldType": "numberInput",
                            "name": "n_images",  # Name of the view arg this value corresponds to
                            "label": "Number of images",
                            "min": 1,  # HTML number input attribute
                            "default": 5,  # HTML number input attribute
                        },
                        {
                            "fieldType": "numberInput",
                            "name": "t_between",
                            "label": "Time (seconds) between images",
                            "min": 0.1,  # HTML number input attribute
                            "step": 0.1,  # HTML number input attribute
                            "default": 1,  # HTML number input attribute
                        },
                    ],
                }
            ],
        }
        self.add_meta("gui", build_gui(gui_description, self))

    def timelapse(self, microscope, n_images, t_between):
        """
        Save a set of images in a timelapse

        Args:
            microscope: Microscope object
            n_images (int): Number of images to take
            t_between (int/float): Time, in seconds, between sequential captures
        """
        base_file_name = generate_basename()
        folder = "TIMELAPSE_{}".format(base_file_name)

        # Take exclusive control over both the camera and stage
        with microscope.camera.lock, microscope.stage.lock:
            for n in range(n_images):
                # Elegantly handle action cancellation
                if current_action() and current_action().stopped:
                    return
                # Generate a filename
                filename = f"{base_file_name}_image{n}"
                # Create a file to save the image to
                output = microscope.camera.new_image(
                    filename=filename, folder=folder, temporary=False
                )

                # Capture
                microscope.camera.capture(output)

                # Add system metadata
                output.put_metadata(microscope.metadata, system=True)

                # Update task progress (only does anyting if the function is running in a LabThings task)
                progress_pct = ((n + 1) / n_images) * 100  # Progress, in percent
                update_action_progress(progress_pct)

                # Wait for the specified time
                time.sleep(t_between)


## Extension views
class TimelapseAPIView(ActionView):
    """
    Take a series of images in a timelapse
    """

    args = {
        "n_images": fields.Integer(
            required=True, metadata={"example": 5, "description": "Number of images"}
        ),
        "t_between": fields.Number(
            load_default=1,
            metadata={"example": 1, "description": "Time (seconds) between images"},
        ),
    }

    def post(self, args):
        # Find our microscope component
        microscope = find_component("org.openflexure.microscope")

        # Start "timelapse"
        return self.extension.timelapse(
            microscope, args.get("n_images"), args.get("t_between")
        )


LABTHINGS_EXTENSIONS = (TimelapseExtension,)