diff --git a/schemas/pvi.device.schema.json b/schemas/pvi.device.schema.json index e380fdb1..dabbd282 100644 --- a/schemas/pvi.device.schema.json +++ b/schemas/pvi.device.schema.json @@ -285,6 +285,9 @@ { "$ref": "#/definitions/ComboBox" }, + { + "$ref": "#/definitions/ButtonPanel" + }, { "$ref": "#/definitions/TextWrite" }, @@ -326,6 +329,23 @@ }, "additionalProperties": false }, + "ButtonPanel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ButtonPanel", + "default": "ButtonPanel" + }, + "actions": { + "type": "object", + "default": { + "go": 1 + } + } + }, + "additionalProperties": false + }, "TextWrite": { "type": "object", "properties": { diff --git a/src/pvi/_format/aps.py b/src/pvi/_format/aps.py index 6acf441b..bf9681ee 100644 --- a/src/pvi/_format/aps.py +++ b/src/pvi/_format/aps.py @@ -49,7 +49,7 @@ def format(self, device: Device, prefix: str, path: Path): group_width_offset=0, ) widget_formatter_factory = WidgetFormatterFactory( - heading_formatter_cls=LabelWidgetFormatter.from_template( + header_formatter_cls=LabelWidgetFormatter.from_template( template, search='"Heading"', property_map=dict(textix="text"), diff --git a/src/pvi/_format/dls.py b/src/pvi/_format/dls.py index 1367f046..467d52cc 100644 --- a/src/pvi/_format/dls.py +++ b/src/pvi/_format/dls.py @@ -59,7 +59,7 @@ def format_edl(self, device: Device, prefix: str, path: Path): group_width_offset=0, ) widget_formatter_factory = WidgetFormatterFactory( - heading_formatter_cls=LabelWidgetFormatter.from_template( + header_formatter_cls=LabelWidgetFormatter.from_template( template, search='"Heading"', property_map=dict(value="text"), @@ -206,7 +206,7 @@ def format_bob(self, device: Device, prefix: str, path: Path): ) # SW DOCS REF: Extract widget types from template file widget_formatter_factory = WidgetFormatterFactory( - heading_formatter_cls=LabelWidgetFormatter.from_template( + header_formatter_cls=LabelWidgetFormatter.from_template( template, search="Heading", property_map=dict(text="text"), diff --git a/src/pvi/_format/screen.py b/src/pvi/_format/screen.py index d4a5ffa4..a8beb70b 100644 --- a/src/pvi/_format/screen.py +++ b/src/pvi/_format/screen.py @@ -1,12 +1,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, Iterator, List, Tuple, Type, TypeVar, Union +from typing import Dict, Iterator, List, Sequence, Tuple, Type, TypeVar, Union from typing_extensions import Annotated from pvi._format.bob import is_table -from pvi._format.utils import Bounds, indent_widget +from pvi._format.utils import Bounds from pvi._format.widget import ( GroupFormatter, SubScreenWidgetFormatter, @@ -19,10 +19,12 @@ ) from pvi._schema_utils import desc from pvi.device import ( + ButtonPanel, Component, Generic, Grid, Group, + ReadSignalType, Row, SignalR, SignalRef, @@ -32,6 +34,7 @@ SubScreen, TableWidgetTypes, Tree, + WriteSignalType, ) T = TypeVar("T") @@ -117,10 +120,11 @@ def create_screen_formatter( screen_widgets.extend( self.create_component_widget_formatters( c, - column_bounds=last_column_bounds, parent_bounds=screen_bounds, + column_bounds=last_column_bounds, next_column_bounds=next_column_bounds, - group_widget_indent=self.layout.group_widget_indent, + # Indent top-level widgets to align with Group widgets + indent=True, ) ) @@ -214,16 +218,15 @@ def create_group_formatters( # embedding the components within a Group widget return self.create_component_widget_formatters( Group(c.name, SubScreen(), c.children), - column_bounds=column_bounds, parent_bounds=screen_bounds, + column_bounds=column_bounds, next_column_bounds=next_column_bounds, - group_widget_indent=self.layout.group_widget_indent, + indent=True, add_label=True, ) group_formatter = self.create_group_formatter( - c, - bounds=Bounds(column_bounds.x, column_bounds.y, h=screen_bounds.h), + c, bounds=Bounds(column_bounds.x, column_bounds.y, h=screen_bounds.h) ) if group_formatter.bounds.h + group_formatter.bounds.y <= screen_bounds.h: @@ -298,8 +301,8 @@ def create_group_formatter( widget_factories.extend( self.create_component_widget_formatters( component, - column_bounds=column_bounds, parent_bounds=bounds, + column_bounds=column_bounds, next_column_bounds=next_column_bounds, add_label=group.layout.labelled, ) @@ -315,11 +318,11 @@ def create_group_formatter( def create_component_widget_formatters( self, c: Union[Group[Component], Component], - column_bounds: Bounds, parent_bounds: Bounds, + column_bounds: Bounds, next_column_bounds: Bounds, + indent=False, add_label=True, - group_widget_indent: int = 0, ) -> List[WidgetFormatter[T]]: """Generate widgets from component data and position them in a grid format @@ -331,7 +334,8 @@ def create_component_widget_formatters( height limits add_label: Whether the widget should have an associated label. Defaults to True. - group_widget_indent: The x offset of widgets within groups. + indent: Shift the resulting widgets to the group ident level + Used for top-level widgets that are not inside a group. Returns: A collection of widgets representing the component @@ -341,21 +345,26 @@ def create_component_widget_formatters( added widget """ + # Take copies so we don't modify the originals until we're done + tmp_column_bounds = column_bounds.copy() + tmp_next_column_bounds = next_column_bounds.copy() + + if indent: + tmp_column_bounds.indent(self.layout.group_widget_indent) + tmp_next_column_bounds.indent(self.layout.group_widget_indent) + widgets = list( - self.generate_component_formatters( - c, column_bounds, group_widget_indent, add_label - ) + self.generate_component_formatters(c, tmp_column_bounds, add_label) ) - if max_y(widgets) > parent_bounds.h: - # Add to next column + if max_y(widgets) <= parent_bounds.h: + # Current column still fits on screen + column_bounds.y = next_y(widgets, self.layout.spacing) + else: + # Widget makes current column too tall. Repeat in next column. widgets = list( - self.generate_component_formatters( - c, next_column_bounds, group_widget_indent, add_label - ) + self.generate_component_formatters(c, tmp_next_column_bounds, add_label) ) next_column_bounds.y = next_y(widgets, self.layout.spacing) - else: - column_bounds.y = next_y(widgets, self.layout.spacing) return widgets @@ -363,7 +372,6 @@ def generate_component_formatters( self, c: Union[Group[Component], Component], bounds: Bounds, - group_widget_indent: int, add_label=True, ) -> Iterator[WidgetFormatter[T]]: """Convert a component into its WidgetFormatter equivalents @@ -385,17 +393,17 @@ def generate_component_formatters( component_bounds = bounds.copy() if isinstance(c, Group) and isinstance(c.layout, Row): - # This Group should be formatted as a table - check if headers are required + # This Group should be formatted as a table if c.layout.header is not None: + # Create table header assert len(c.layout.header) == len( c.children ), "Header length does not match number of elements" # Create column headers for column_header in c.layout.header: - yield self.widget_formatter_factory.heading_formatter_cls( - indent_widget(component_bounds, group_widget_indent), - column_header, + yield self.widget_formatter_factory.header_formatter_cls( + component_bounds.copy(), column_header ) component_bounds.x += component_bounds.w + self.layout.spacing @@ -404,91 +412,96 @@ def generate_component_formatters( component_bounds.y += self.layout.widget_height + self.layout.spacing add_label = False # Don't add a row label - sub_components = c.children # Create a widget for each row of Group + row_components = c.children # Create a widget for each row of Group + # Allow given component width for each column, plus spacing + component_bounds = component_bounds.tile( + horizontal=len(c.children), spacing=self.layout.spacing + ) + elif ( + isinstance(c, (SignalW, SignalRW)) + and hasattr(c, "widget") + and isinstance(c.widget, ButtonPanel) + ): + # Convert W of Signal(R)W into SignalX for each button + row_components = [ + SignalX(label, c.pv, value) for label, value in c.widget.actions.items() + ] + if isinstance(c, SignalRW): + row_components += [SignalR(c.label, c.read_pv, c.read_widget)] else: - sub_components = [c] # Create one widget for Group/Component + row_components = [c] # Create one widget for row + if hasattr(c, "widget") and isinstance(c.widget, TableWidgetTypes): add_label = False # Do not add row labels for Tables + component_bounds.w = 100 * len(c.widget.widgets) + component_bounds.h *= 10 # TODO: How do we know the number of rows? if add_label: # Insert label and reduce width for widget - left, row_bounds = component_bounds.split( + left, row_bounds = component_bounds.split_left( self.layout.label_width, self.layout.spacing ) - yield self.widget_formatter_factory.label_formatter_cls( - indent_widget(left, group_widget_indent), c.get_label() - ) + yield self.widget_formatter_factory.label_formatter_cls(left, c.get_label()) else: # Allow full width for widget row_bounds = component_bounds - # Actual widgets - sub_components = ( - c.children if isinstance(c, Group) and isinstance(c.layout, Row) else [c] + if isinstance(c, SignalRef): + yield from self.generate_component_formatters( + self.components[c.name], row_bounds, add_label + ) + return + + yield from self.generate_row_component_formatters(row_components, row_bounds) + + def generate_row_component_formatters( + self, + row_components: Sequence[Union[Group[Component], Component]], + row_bounds: Bounds, + ) -> Iterator[WidgetFormatter[T]]: + + row_component_bounds = row_bounds.copy().split_into( + len(row_components), self.layout.spacing ) - for sc in sub_components: - if isinstance(sc, SignalX): + for rc_bounds, rc in zip(row_component_bounds, row_components): + if isinstance(rc, SignalRW): + left, right = rc_bounds.split_into(2, self.layout.spacing) + yield from self.generate_write_widget(rc, left) + yield from self.generate_read_widget(rc, right) + elif isinstance(rc, SignalW): + yield from self.generate_write_widget(rc, rc_bounds) + elif isinstance(rc, SignalR): + yield from self.generate_read_widget(rc, rc_bounds) + elif isinstance(rc, SignalX): yield self.widget_formatter_factory.action_formatter_cls( - indent_widget(row_bounds, group_widget_indent), - sc.get_label(), - self.prefix + sc.pv, - sc.value, - ) - elif isinstance(sc, SignalR) and sc.widget: - if ( - isinstance(sc.widget, TableWidgetTypes) - and len(sc.widget.widgets) > 0 - ): - widget_bounds = row_bounds.copy() - widget_bounds.w = 100 * len(sc.widget.widgets) - widget_bounds.h *= 10 # TODO: How do we know the number of rows? - else: - widget_bounds = row_bounds - - yield self.widget_formatter_factory.pv_widget_formatter( - sc.widget, - indent_widget(widget_bounds, group_widget_indent), - sc.pv, - self.prefix, - ) - elif ( - isinstance(sc, SignalRW) and sc.read_pv and sc.read_widget and sc.widget - ): - left, right = row_bounds.split( - int((row_bounds.w - self.layout.spacing) / 2), self.layout.spacing + rc_bounds, + rc.get_label(), + self.prefix + rc.pv, + rc.value, ) - yield self.widget_formatter_factory.pv_widget_formatter( - sc.widget, - indent_widget(left, group_widget_indent), - sc.pv, - self.prefix, - ) - yield self.widget_formatter_factory.pv_widget_formatter( - sc.read_widget, - indent_widget(right, group_widget_indent), - sc.read_pv, - self.prefix, - ) - elif isinstance(sc, (SignalW, SignalRW)) and sc.widget: - yield self.widget_formatter_factory.pv_widget_formatter( - sc.widget, - indent_widget(row_bounds, group_widget_indent), - sc.pv, - self.prefix, - ) - elif isinstance(sc, SignalRef): - yield from self.generate_component_formatters( - self.components[sc.name], - indent_widget(row_bounds, group_widget_indent), - add_label, - ) - elif isinstance(sc, Group) and isinstance(sc.layout, SubScreen): + elif isinstance(rc, Group) and isinstance(rc.layout, SubScreen): yield self.widget_formatter_factory.sub_screen_formatter_cls( - indent_widget(row_bounds, group_widget_indent), - f"{self.base_file_name}_{sc.name}", - sc, + rc_bounds, + f"{self.base_file_name}_{rc.name}", + rc, ) - # TODO: Need to handle DeviceRef + # TODO: Need to handle DeviceRef - # Shift bounds along row for next widget - row_bounds.x += row_bounds.w + self.layout.spacing + def generate_read_widget(self, signal: ReadSignalType, bounds: Bounds): + if isinstance(signal, SignalRW): + widget = signal.read_widget + pv = signal.read_pv + else: + widget = signal.widget + pv = signal.pv + + if widget is not None: + yield self.widget_formatter_factory.pv_widget_formatter( + widget, bounds, pv, self.prefix + ) + + def generate_write_widget(self, signal: WriteSignalType, bounds: Bounds): + if signal.widget is not None: + yield self.widget_formatter_factory.pv_widget_formatter( + signal.widget, bounds, signal.pv, self.prefix + ) diff --git a/src/pvi/_format/utils.py b/src/pvi/_format/utils.py index c6c5a63e..e6c76257 100644 --- a/src/pvi/_format/utils.py +++ b/src/pvi/_format/utils.py @@ -15,14 +15,33 @@ class Bounds: def copy(self) -> Bounds: return Bounds(self.x, self.y, self.w, self.h) - def split(self, width: int, spacing: int) -> Tuple[Bounds, Bounds]: - """Split horizontally""" + def split_left(self, width: int, spacing: int) -> Tuple[Bounds, Bounds]: + """Split horizontally by width of first element""" to_split = width + spacing assert to_split < self.w, f"Can't split off {to_split} from {self.w}" left = Bounds(self.x, self.y, width, self.h) right = Bounds(self.x + to_split, self.y, self.w - to_split, self.h) return left, right + def split_by_ratio( + self, ratio: Tuple[float, ...], spacing: int + ) -> Tuple[Bounds, ...]: + """Split horizontally by ratio of widths, separated by spacing""" + splits = len(ratio) - 1 + widget_space = self.w - splits * spacing + widget_widths = tuple(int(widget_space * r) for r in ratio) + widget_xs = tuple( + self.x + sum(widget_widths[:i]) + spacing * i for i in range(splits + 1) + ) + + return tuple( + Bounds(x, self.y, w, self.h) for x, w in zip(widget_xs, widget_widths) + ) + + def split_into(self, count: int, spacing: int) -> Tuple[Bounds, ...]: + """Split horizontally into count equal widths, separated by spacing""" + return self.split_by_ratio((1 / count,) * count, spacing) + def square(self) -> Bounds: """Return the largest square that will fit in self""" size = min(self.w, self.h) @@ -41,6 +60,20 @@ def added_to(self, bounds: Bounds) -> Bounds: h=self.h + bounds.h, ) + def tile( + self, *, horizontal: int = 1, vertical: int = 1, spacing: int = 0 + ) -> Bounds: + """Expand by tiling self `horizontal`/`vertical` times, plus spacing""" + return Bounds( + x=self.x, + y=self.y, + w=self.w * horizontal + spacing * (horizontal - 1), + h=self.h * vertical + spacing * (vertical - 1), + ) + + def indent(self, indentation: int) -> None: + self.x += indentation + class GroupType(Enum): GROUP = "GROUP" @@ -62,9 +95,3 @@ def with_title(spacing, title_height: int) -> Callable[[Bounds], Bounds]: return Bounds( spacing, spacing + title_height, 2 * spacing, 2 * spacing + title_height ).added_to - - -def indent_widget(bounds: Bounds, indentation: int) -> Bounds: - """Shifts the x position of a widget. Used on top level widgets to align - them with group indentation""" - return Bounds(bounds.x + indentation, bounds.y, bounds.w, bounds.h) diff --git a/src/pvi/_format/widget.py b/src/pvi/_format/widget.py index b3f27cee..4ada7f9a 100644 --- a/src/pvi/_format/widget.py +++ b/src/pvi/_format/widget.py @@ -201,7 +201,7 @@ def from_template( search: Search expression for the widget section to find sized: Function to pad or restrict the `Bounds` of the formatted widget widget_formatter_hook: Callable to format widgets for the group itself, such - as a heading or a box around the group components. + as a label or a box around the group components. property_map: Map of template macro string to `WidgetFormatter` property. Instances of the macro will be replaced with the value of the property. """ @@ -256,7 +256,7 @@ def _post_init(self): @dataclass class WidgetFormatterFactory(Generic[T]): - heading_formatter_cls: Type[LabelWidgetFormatter[T]] + header_formatter_cls: Type[LabelWidgetFormatter[T]] label_formatter_cls: Type[LabelWidgetFormatter[T]] led_formatter_cls: Type[PVWidgetFormatter[T]] progress_bar_formatter_cls: Type[PVWidgetFormatter[T]] diff --git a/src/pvi/device.py b/src/pvi/device.py index bbee477e..2a12800d 100644 --- a/src/pvi/device.py +++ b/src/pvi/device.py @@ -7,6 +7,7 @@ from typing import ( Any, Callable, + Dict, Generic, Iterator, List, @@ -123,6 +124,18 @@ class ComboBox(WriteWidget): ) +@dataclass +class ButtonPanel(WriteWidget): + """One-or-more buttons that poke a PV with a value + + Args: + actions: Dict of button label to value the button sends + + """ + + actions: Dict[str, Any] = field(default_factory=lambda: dict(go=1)) + + @dataclass class TextWrite(WriteWidget): """Text control of any PV""" @@ -258,6 +271,11 @@ class SignalRW(Component): ] = None +SignalTypes = (SignalR, SignalW, SignalRW) +ReadSignalType = Union[SignalR, SignalRW] +WriteSignalType = Union[SignalW, SignalRW] + + @dataclass class SignalX(Component): """Executable that puts a fixed value to a PV.""" diff --git a/tests/format/output/button.bob b/tests/format/output/button.bob new file mode 100644 index 00000000..99e4c4c8 --- /dev/null +++ b/tests/format/output/button.bob @@ -0,0 +1,150 @@ + + Display + 0 + 0 + 274 + 102 + 4 + 4 + + Title + TITLE + Simple Device - $(P) + 0 + 0 + 274 + 26 + + + + + + + + + true + 1 + + + Label + Acquire Time + 22 + 30 + 120 + 20 + + + TextEntry + $(P)AcquireTime + 146 + 30 + 60 + 20 + 1 + + + TextUpdate + $(P)AcquireTime_RBV + 210 + 30 + 60 + 20 + + + + + 1 + + + Label + Acquire + 22 + 54 + 120 + 20 + + + ActionButton + $(P)Acquire + + + $(pv_name) + 1 + WritePV + + + Start + 146 + 54 + 60 + 20 + $(actions) + + + ActionButton + $(P)Acquire + + + $(pv_name) + 0 + WritePV + + + Stop + 210 + 54 + 60 + 20 + $(actions) + + + Label + Acquire With RBV + 22 + 78 + 120 + 20 + + + ActionButton + $(P)Acquire + + + $(pv_name) + 1 + WritePV + + + Start + 146 + 78 + 38 + 20 + $(actions) + + + ActionButton + $(P)Acquire + + + $(pv_name) + 0 + WritePV + + + Stop + 188 + 78 + 38 + 20 + $(actions) + + + LED + $(P)Acquire_RBV + 239 + 78 + 20 + 20 + + diff --git a/tests/test_api.py b/tests/test_api.py index fb14731b..86be171d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,9 +4,12 @@ from pvi._yaml_utils import deserialize_yaml from pvi.device import ( LED, + ButtonPanel, ComboBox, Device, SignalR, + SignalRW, + SignalW, TableRead, TableWrite, TextRead, @@ -16,6 +19,38 @@ HERE = Path(__file__).parent +def test_button(tmp_path, helper): + formatter_yaml = HERE / "format" / "input" / "dls.bob.pvi.formatter.yaml" + formatter = deserialize_yaml(Formatter, formatter_yaml) + + acquire_time = SignalRW( + "AcquireTime", + pv="AcquireTime", + widget=TextWrite(), + read_pv="AcquireTime_RBV", + read_widget=TextRead(), + ) + acquire = SignalW( + "Acquire", + pv="Acquire", + widget=ButtonPanel(actions=dict(Start=1, Stop=0)), + ) + acquire_w_rbv = SignalRW( + "AcquireWithRBV", + pv="Acquire", + widget=ButtonPanel(actions=dict(Start=1, Stop=0)), + read_pv="Acquire_RBV", + read_widget=LED(), + ) + device = Device("Simple Device", children=[acquire_time, acquire, acquire_w_rbv]) + + expected_bob = HERE / "format" / "output" / "button.bob" + output_bob = tmp_path / "button.bob" + formatter.format(device, "$(P)", output_bob) + + helper.assert_output_matches(expected_bob, output_bob) + + def test_pva_table(tmp_path, helper): formatter_yaml = HERE / "format" / "input" / "dls.bob.pvi.formatter.yaml" formatter = deserialize_yaml(Formatter, formatter_yaml) diff --git a/tests/test_cli.py b/tests/test_cli.py index c823a799..877acd39 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,7 +19,6 @@ def test_cli_version(): "filename", [ "pvi.device.schema.json", - "pvi.producer.schema.json", "pvi.formatter.schema.json", ], )