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 WidgetFormatter
s 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
.