How to Write a Site Specific Formatter#

This guide explains how you can create a pvi formatter to generate screens for your own use cases.

Overview#

The formatters role is to take a Device - defined either in code or in a pvi.device.yaml file - and turn this into a screen file that can be used by the display software. The Device has a list of components that specify its name, a widget type and any additional properties that can be assigned to that widget (such as a pv name). During formatting, Component objects of the Device are translated into widgets to be written to a UI file.

class Component(Named):
    """These make up a Device"""

    label: Annotated[str | None, Field(description="Label for component")] = None
    description: Annotated[
        str | None, Field(description="Description for label tooltip")
    ] = None

    def get_label(self):
        return self.label or to_title_case(self.name)

    def __eq__(self, other: object) -> bool:
        return isinstance(other, Component) and self.name == other.name

There are various types of Component. The simplest is a read-only signal.

class SignalR(Signal):
    """Read-only `Signal` backed by a single PV."""

    _access_mode = "r"

    read_pv: Annotated[str, Field(description="PV to use for readback")]
    read_widget: Annotated[
        ReadWidgetUnion | None,
        Field(
            description="Widget to use for display. `TextRead` will be used if unset.",
            validate_default=True,
        ),
    ] = TextRead()

To add structure, there is a Group component, which itself has a list of Components.

class Group(Component):
    """Group of child components in a Layout"""

    layout: Annotated[
        LayoutUnion, Field(description="How to layout children on screen")
    ]
    children: Annotated[Tree, Field(description="Child Components")]

To make a screen from this, we need a template UI file. This contains a blank representation of each supported widget for each of the supported file formats (bob, edl etc…). Below is an example of a textentry widget for a .bob file.

  <widget type="textentry" version="3.0.0">
    <name>TextEntry</name>
    <pv_name>TextWrite</pv_name>
    <x>18</x>
    <y>78</y>
    <width>120</width>
    <height>30</height>
    <horizontal_alignment>1</horizontal_alignment>
  </widget>

By extracting and altering the template widgets with the information provided by the components, we can create a screen file.

Create a Formatter subclass#

To start, we will need to create our own formatter class. These inherit from an abstract Formatter class that is defined in base.py. Inside, we need to define one mandatory format function, which will be used to create our screen file:

    def format(self, device: Device, path: Path) -> None:
        """To be implemented by child classes to define how to format specific UIs.

        Args:
            device: Device to populate UI from
            path: Output file path to write UI to

        """
        raise NotImplementedError(self)

With a formatter defined, we now can start to populate this by defining the screen dependencies.

Define the ScreenLayout properties#

Each screen requires a number of layout properties that allow you to customise the size and placement of widgets. These are stored within ScreenLayout dataclass with the following configurable parameters:

@dataclass
class ScreenLayout:
    spacing: int
    title_height: int
    max_height: int
    group_label_height: int
    label_width: int
    widget_width: int
    widget_height: int
    group_widget_indent: int
    group_width_offset: int

When defining these in our formatter, we have the option of deciding which properties should be configurable inside of the formatter.yaml. Properties defined as member variables of the formatter class (and then referenced by the layout properties in the screen format function) will be available to adjust inside of the formatter.yaml. Anything else, should be considered as defaults for the formatter:

        screen_layout = ScreenLayout(
            spacing=self.spacing,
            title_height=self.title_height,
            max_height=self.max_height,
            group_label_height=26,
            label_width=self.label_width,
            widget_width=self.widget_width,
            widget_height=self.widget_height,
            group_widget_indent=18,
            group_width_offset=26,
        )

In the example above, everything has been made adjustable from the formatter.yaml except the properties relating to groups. This is because they are more dependant on the file format used rather than the users personal preference.

For clarity, the example below shows how the formatter.yaml can be used to set the layout properties. Note that these are optional as each property is defined with a default value.

# yaml-language-server: $schema=../schemas/pvi.formatter.schema.json
type: DLSFormatter
# Remove screen property to use default value
spacing: 4
title_height: 26
max_height: 900
label_width: 120
widget_width: 120
widget_height: 20

Assign a template file#

As previously stated, a template file provides the formatter with a base model of all of the supported widgets that it can then overwrite with component data. Currently, pvi supports templates for edl, adl and bob files, which can be referenced from the _format directory with the filename ‘dls’ + the file formats suffix (eg. dls.bob).

Inside of the format function, we need to provide a reference to the template file that can then be used to identify what each widget should look like.

template = BobTemplate(str(Path(__file__).parent / "dls.bob"))

Divide the template into widgets#

With a template defined, we now need to assign each part of it to a supported widget formatter. This is achieved by instantiating a WidgetFormatterFactory composed of WidgetFormatters created from the UI template. WidgetFormatters are created by searching the UI template for the given search term and a set of properties in the template to replace with widget fields.

Note

The WidgetFormatters are generic types that must be parameterised depending on the specific UI. Commonly this would use str for formatting text to a file directly. In this case we use _Element, which will serialised to text with the lxml library.

        widget_formatter_factory: WidgetFormatterFactory[_Element] = (
            WidgetFormatterFactory(
                header_formatter_cls=LabelWidgetFormatter[_Element].from_template(
                    template,
                    search="Heading",
                    property_map={"text": "text"},
                ),
                label_formatter_cls=LabelWidgetFormatter[_Element].from_template(
                    template,
                    search="Label",
                    property_map={"text": "text", "tooltip": "description"},
                ),
                led_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="LED",
                    sized=Bounds.square,
                    property_map={"pv_name": "pv"},
                ),
                progress_bar_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="ProgressBar",
                    property_map={"pv_name": "pv"},
                ),
                bitfield_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="BitField",
                    property_map={"pv_name": "pv"},
                ),
                button_panel_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="ButtonPanel",
                    property_map={"pv_name": "pv"},
                ),
                array_trace_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="ArrayTrace",
                    property_map={"y_pv": "pv"},
                ),
                image_read_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="ImageRead",
                    property_map={"pv_name": "pv"},
                ),
                text_read_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="TextUpdate",
                    property_map={"pv_name": "pv"},
                ),
                check_box_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="ChoiceButton",
                    property_map={"pv_name": "pv"},
                ),
                toggle_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="ToggleButton",
                    property_map={"pv_name": "pv"},
                ),
                combo_box_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="ComboBox",
                    property_map={"pv_name": "pv"},
                ),
                text_write_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="TextEntry",
                    property_map={"pv_name": "pv"},
                ),
                table_formatter_cls=PVWidgetFormatter[_Element].from_template(
                    template,
                    search="Table",
                    property_map={"pv_name": "pv"},
                ),
                action_formatter_cls=ActionWidgetFormatter[_Element].from_template(
                    template,
                    search="WritePV",
                    property_map={
                        "text": "label",
                        "pv_name": "pv",
                        "value": "value",
                        "tooltip": "tooltip",
                    },
                ),
                sub_screen_formatter_cls=SubScreenWidgetFormatter[
                    _Element
                ].from_template(
                    template,
                    search="OpenDisplay",
                    property_map={
                        "file": "file_name",
                        "text": "label",
                        "macros": "macros",
                    },
                ),
            )
        )

Warning

This function uses a unique search term to locate and extract a widget from the template. As such, the search term MUST be unique to avoid extracting multiple or irrelevant widgets from the template.

Define screen and group widget functions#

Additionally, formatters for the title and a group on a screen must be defined along with functions to create multiple components, for example a rectangle with a label on top. In this example, we provide two arguments: The bounds, to set the widgets size and position, and the title to populate the label with.

        screen_title_cls = LabelWidgetFormatter[_Element].from_template(
            template,
            search="Title",
            property_map={"text": "text"},
        )
        group_title_cls = LabelWidgetFormatter[_Element].from_template(
            template,
            search="Group",
            property_map={"name": "text"},
        )

        def create_group_object_formatter(
            bounds: Bounds, title: str
        ) -> list[WidgetFormatter[_Element]]:
            return [
                group_title_cls(
                    bounds=Bounds(x=bounds.x, y=bounds.y, w=bounds.w, h=bounds.h),
                    text=f"{title}",
                )
            ]

        def create_screen_title_formatter(
            bounds: Bounds, title: str
        ) -> list[WidgetFormatter[_Element]]:
            return [
                screen_title_cls(
                    bounds=Bounds(x=0, y=0, w=bounds.w, h=screen_layout.title_height),
                    text=title,
                )
            ]

Construct a ScreenFormatter#

These formatters can be used to define a ScreenFormatterFactory

        formatter_factory: ScreenFormatterFactory[_Element] = ScreenFormatterFactory(
            screen_formatter_cls=GroupFormatter[_Element].from_template(
                template,
                search=GroupType.SCREEN,
                sized=with_title(screen_layout.spacing, screen_layout.title_height),
                widget_formatter_hook=create_screen_title_formatter,
            ),
            group_formatter_cls=GroupFormatter[_Element].from_template(
                template,
                search=GroupType.GROUP,
                sized=with_title(
                    screen_layout.spacing, screen_layout.group_label_height
                ),
                widget_formatter_hook=create_group_object_formatter,
            ),
            widget_formatter_factory=widget_formatter_factory,
            layout=screen_layout,
            base_file_name=path.stem,
        )

which can be used to instantiate a ScreenFormatter by passing a set of Components and a title. This can then create WidgetFormatters for each Component for the specific UI type the factory was parameterised with.

        title = f"{device.label}"

        screen_formatter, sub_screens = formatter_factory.create_screen_formatter(
            device.children, title
        )

        write_bob(screen_formatter, path)
        for sub_screen_name, sub_screen_formatter in sub_screens:
            sub_screen_path = Path(path.parent / f"{sub_screen_name}{path.suffix}")
            write_bob(sub_screen_formatter, sub_screen_path)


In this case the write_bob function calls into the lxml library to format the _Element instances to text. For str formatters this would call pathlib.Path.write_file.