Skip to content

Menumux

control_pv(obj, loc_declarations)

The address a menu mux writes its selected index to, or None without a controlPv.

The parser has already turned a LOC\ controlPv into a loc:// address. The screen connects its menus after the embedded .ui, whose widgets therefore configure a variable they declare, so the menu takes that declaration and keeps its own only when nothing else on the screen configures the variable. (EDM connects menu muxes first, so there a menu's own configuration wins; screens that configure a variable differently in two places are rare.)

Source code in pydmconverter/edm/menumux.py
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def control_pv(obj: EDMObject, loc_declarations: dict[str, str]) -> str | None:
    """The address a menu mux writes its selected index to, or None without a controlPv.

    The parser has already turned a LOC\\ controlPv into a loc:// address. The
    screen connects its menus after the embedded .ui, whose widgets therefore
    configure a variable they declare, so the menu takes that declaration and
    keeps its own only when nothing else on the screen configures the
    variable. (EDM connects menu muxes first, so there a menu's own
    configuration wins; screens that configure a variable differently in two
    places are rare.)
    """
    address = obj.properties.get("controlPv")
    if not isinstance(address, str) or not address.strip() or address.strip().startswith("#"):
        return None
    address = address.strip()
    if address.startswith(("LOC\\", "CALC\\")):
        logger.warning(f"Menu mux controlPv {address} was not translated; the menu will not use it")
        return None
    if address.startswith("loc://"):
        return loc_declarations.get(address.split("?", 1)[0], address)
    return address

edm_array(value)

An EDM array property as a list indexed by item: the parser gives a list for a { ... } block (an item EDM left out, e.g. an empty first value, is "") and a str for a single value.

Source code in pydmconverter/edm/menumux.py
372
373
374
375
376
377
378
def edm_array(value) -> list[str]:
    """An EDM array property as a list indexed by item: the parser gives a list
    for a { ... } block (an item EDM left out, e.g. an empty first value, is "")
    and a str for a single value."""
    if isinstance(value, list):
        return block_list(value)
    return [value] if isinstance(value, str) else []

generate_menumux_file(menumux_buttons, output_path, loc_declarations=None)

Write the PyDM screen that embeds the converted .ui under the menus.

loc_declarations maps a bare local variable address (loc://name) to the address that configures it in the converted .ui, so a menu whose controlPv names the variable declares it the same way.

Source code in pydmconverter/edm/menumux.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def generate_menumux_file(
    menumux_buttons: list[EDMObject], output_path: str | Path, loc_declarations: dict[str, str] | None = None
):
    """Write the PyDM screen that embeds the converted .ui under the menus.

    loc_declarations maps a bare local variable address (loc://name) to the
    address that configures it in the converted .ui, so a menu whose controlPv
    names the variable declares it the same way.
    """
    output_path = Path(output_path)
    file_path = output_path.with_suffix(".py")
    file_path.parent.mkdir(parents=True, exist_ok=True)

    # The screen gets its menus as plain literals, so it runs with only qtpy and
    # pydm installed: position, item labels, each item's (macro, value) pairs,
    # the EDM initialState and the controlPv address.
    menus = []
    for obj in menumux_buttons:
        count = menu_item_count(obj)
        # $(!W) in a tag, a value or the controlPv means this window's, as in the .ui.
        macros = [[(name, window_macro(value)) for name, value in pairs] for pairs in menu_macros(obj, count)]
        address = control_pv(obj, loc_declarations or {})
        menus.append(
            {
                "x": obj.x,
                "y": obj.y,
                "width": obj.width,
                "height": obj.height,
                "items": [window_macro(item) for item in menu_items(obj, count, macros)],
                "macros": macros,
                "initialState": str(obj.properties.get("initialState", "0")),
                "controlPv": None if address is None else window_macro(address),
            }
        )
    menus_literal = textwrap.indent(pprint.pformat(menus, width=100, sort_dicts=False), " " * 8).lstrip()

    # The .ui sits next to the generated .py, and PyDM resolves an embedded
    # display's relative filename against the .py's directory.
    code = f"""from qtpy.QtWidgets import (
    QVBoxLayout, QComboBox, QWidget, QStackedLayout
)
from qtpy.QtCore import QCoreApplication, QObject, Signal
from pydm import Display
from pydm.widgets import PyDMEmbeddedDisplay
from pydm.widgets.channel import PyDMChannel
from pydm.widgets.rules import unregister_widget_rules
from functools import partial
from urllib.parse import unquote
import json
import re


class ControlPvWriter(QObject):
    # A menu's controlPv channel writes each index emitted here to the PV.
    send_value_signal = Signal(int)


def disconnect_channels(channels, *args):
    # The channels call back into the screen, so they go with it.
    for channel in channels:
        channel.disconnect()


class MenuMuxScreen(Display):
    def __init__(self, parent=None, args=None, macros=None):
        # EDM gives each window its own $(!W) (its address). The .ui names it
        # ${{{WINDOW_MACRO}}} and its embedded displays build theirs on
        # {ROOT_MACRO}, so each open copy of this screen keeps its own local
        # variables; a menu change keeps the window, and so the id.
        window_id = format(id(self), "x")
        macros = dict(macros or {{}}, {WINDOW_MACRO}=window_id, {ROOT_MACRO}=window_id)
        super().__init__(parent=parent, args=args, macros=macros)

        self.muxes = []
        self.menus = {menus_literal}
        self.current_macros = dict()  # The menu macros passed to the embedded display
        self.control_addresses = []  # Each entry: the menu's controlPv address, or None without one
        self.control_writers = []  # Each entry: the menu's ControlPvWriter, or None without a controlPv
        self.control_channels = []
        self.control_connections = []  # Each entry: whether the menu's controlPv is connected
        self.controls_connected = False
        self.showing_control_value = False

        self.container = QWidget()
        self.stack_layout = QStackedLayout(self.container)

        self.embedded = PyDMEmbeddedDisplay()
        self.embedded.loadWhenShown = True
        self.embedded.filename = {output_path.name!r}
        self.stack_layout.addWidget(self.embedded)

        for i, menu in enumerate(self.menus):
            combo = QComboBox(self.container)
            combo.setFixedHeight(menu["height"])
            combo.setFixedWidth(menu["width"])
            combo.move(menu["x"], menu["y"])
            combo.raise_()

            combo.addItems(menu["items"])
            start = self.initial_index(menu["initialState"], combo.count())
            address = self.control_address(menu["controlPv"], start)
            if address is not None and address.startswith("loc://"):
                # Start where the variable starts, so the menu, its macros and
                # the variable agree before the channel's first value arrives.
                # (A PV's menu shows initialState until then.)
                init = re.search("[?&]init=([^&]*)", address)
                start = self.menu_index(unquote(init.group(1)) if init else "0", combo.count(), start)
            combo.setCurrentIndex(start)
            combo.currentIndexChanged.connect(
                lambda selected_index, combo_index=i: self.menu_selected(combo_index, selected_index)
            )

            self.muxes.append(combo)
            self.control_addresses.append(address)
            self.control_writers.append(None if address is None else ControlPvWriter())
            self.control_connections.append(False)

        self.destroyed.connect(partial(disconnect_channels, self.control_channels))

        # No margin: EDM draws the menus and the .ui at their screen
        # coordinates, also when the screen is embedded in another.
        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self.container)

        # Start every menu at its initial item, then load once
        self.update_display()

    def expand(self, text):
        # The screen's macros, which the converter writes as ${{name}}; unknown ones stay.
        macros = self.macros()
        return re.sub(
            "[$][{{(]([A-Za-z0-9_]+)[}})]",
            lambda m: str(macros.get(m.group(1), m.group(0))),
            text,
        )

    def initial_index(self, state, count):
        # EDM's initialState: the index of the first item shown. It may use
        # the screen's macros ("${{initDev}}") and is read like strtol, so
        # "3+1" is 3; anything unusable starts at 0. Past the items EDM sets
        # the last item's macros (getMacros in menu_mux.cc clamps the index),
        # so the menu starts there.
        return self.menu_index(self.expand(state), count, 0)

    def control_address(self, address, start):
        # With a controlPv, EDM ignores initialState: the menu shows the PV's
        # value. A loc:// variable nothing on the screen configures has no
        # value in EDM until another screen defines it; here the menu
        # declares it as an int starting at initialState, the only start
        # the screen gives.
        if address is None:
            return None
        address = self.expand(address)
        if address.startswith("loc://") and "?" not in address:
            address += "?type=int&init=" + str(start)
        return address

    def menu_index(self, value, count, default=None):
        # EDM reads the controlPv as an integer (a string like strtol, so
        # "1.5" is 1 and "OFF" is 0) and clamps it to the menu's items.
        if isinstance(value, str):
            match = re.match("[ ]*([-+]?[0-9]+)", value)
            value = match.group(1) if match else 0
        try:
            index = int(value)
        except (TypeError, ValueError, OverflowError):
            return default
        if count < 1:
            return default
        return min(max(index, 0), count - 1)

    def showEvent(self, event):
        super().showEvent(event)
        # The menus connect once the embedded .ui has loaded, which it does
        # when first shown. Its widgets then configure their loc:// variables
        # as they would on their own, and a variable only a menu configures
        # reaches every rule already listening to it: PyDM's rules skip a
        # value that arrives before the channel reports it is connected.
        if not self.controls_connected:
            self.controls_connected = True
            for i, address in enumerate(self.control_addresses):
                if address is not None:
                    self.connect_control(i, address)

    def connect_control(self, combo_index, address):
        channel = PyDMChannel(
            address=address,
            connection_slot=partial(self.control_connected, combo_index),
            value_slot=partial(self.control_changed, combo_index),
            value_signal=self.control_writers[combo_index].send_value_signal,
        )
        channel.connect()
        self.control_channels.append(channel)

    def control_connected(self, combo_index, connected):
        # Having a connection slot matters more than its use: when a listener
        # with one joins a loc:// variable, PyDM tells every listener it is
        # connected, which the rules of the widget that created it need.
        self.control_connections[combo_index] = connected

    def menu_selected(self, combo_index, selected_index):
        writer = self.control_writers[combo_index]
        if writer is None:
            self.update_display()
        elif not self.showing_control_value:
            # As in EDM, choosing an item only writes its index to the
            # controlPv; the menu and its macros follow the value it reports.
            writer.send_value_signal.emit(selected_index)

    def control_changed(self, combo_index, value):
        combo = self.muxes[combo_index]
        index = self.menu_index(value, combo.count())
        if index is None:
            return
        # Show the PV's item without writing it back.
        self.showing_control_value = True
        try:
            combo.setCurrentIndex(index)
        finally:
            self.showing_control_value = False
        self.update_display()

    def menu_macros(self):
        # As in EDM: an item sets each of its (macro, value) pairs whose name
        # and value are both non-empty. EDM expands a widget afresh on every
        # change, from the screen's macros and then each menu's current item
        # in turn, so the first menu to set a macro wins and a macro that no
        # current item sets stays unexpanded, as ${{name}} here. (In EDM the
        # screen's own macros win over the menus'; PyDM's embedded display
        # lets the menus' win.)
        macros = dict()
        for menu, combo in zip(self.menus, self.muxes):
            index = combo.currentIndex()
            for name, value in menu["macros"][index] if index >= 0 else []:
                if name and value:
                    macros.setdefault(name, value)
        return macros

    def update_display(self):
        macros = self.menu_macros()
        # When no menu sets anything EDM leaves every widget as it was
        # expanded before, so keep the previous macros.
        if macros:
            self.current_macros = macros
        self.load()

    def load(self):
        old = self.embedded.embedded_widget
        reloading = old is not None and json.dumps(self.current_macros) != self.embedded.macros
        if reloading:
            # PyDM keeps a loc:// variable, with its first init, while anything
            # listens to it, and connects the new display before releasing the
            # old one (slaclab/pydm#1354). Release the old display's channels
            # and rules first so variables initialised from these macros are
            # rebuilt with the new values.
            self.embedded.disconnect()
            unregister_widget_rules(old)
            QCoreApplication.sendPostedEvents()
        # Posted events can include a controlPv value that changes the macros.
        self.embedded.set_macros_and_filename(self.embedded.filename, json.dumps(self.current_macros))
        if reloading and self.controls_connected and self.embedded.embedded_widget is not old:
            # The menus' loc:// variables outlived the reload, so the new .ui's
            # rules joined them with no fresh value: PyDM sends a joining
            # listener the value before marking it connected, and its rules
            # skip a value from an unconnected channel. Join once more so the
            # value reaches them. (While the screen is hidden the .ui reloads
            # only when shown, which this does not cover.)
            for address in self.control_addresses:
                if address is not None and address.startswith("loc://"):
                    channel = PyDMChannel(address=address, connection_slot=lambda connected: None)
                    channel.connect()
                    channel.disconnect()
"""

    with open(file_path, "w") as f:
        f.write(code)

    logger.info(f"Generated: {file_path}")

initial_state(obj)

The item a menu mux starts on, read from its initialState as the screen reads it (MenuMuxScreen.initial_index), or None when that depends on the macros the screen is opened with.

Source code in pydmconverter/edm/menumux.py
336
337
338
339
340
341
342
343
344
345
346
def initial_state(obj: EDMObject) -> int | None:
    """The item a menu mux starts on, read from its initialState as the screen
    reads it (MenuMuxScreen.initial_index), or None when that depends on the
    macros the screen is opened with."""
    state = str(obj.properties.get("initialState", "0"))
    if "${" in state:
        return None
    match = re.match(r" *([-+]?[0-9]+)", state)
    index = int(match.group(1)) if match else 0
    # Clamped to the items, and 0 without any.
    return max(0, min(index, menu_item_count(obj) - 1))

menu_item_count(obj)

A menu mux's number of items: numItems, or failing that the length of symbolTag, or of its longest value array.

Source code in pydmconverter/edm/menumux.py
293
294
295
296
297
298
299
300
301
302
303
304
305
def menu_item_count(obj: EDMObject) -> int:
    """A menu mux's number of items: numItems, or failing that the length of
    symbolTag, or of its longest value array."""
    num_items = obj.properties.get("numItems")
    if isinstance(num_items, str) and num_items.strip().isdecimal():
        return int(num_items)
    tags = edm_array(obj.properties.get("symbolTag"))
    if tags:
        return len(tags)
    return max(
        (len(edm_array(value)) for key, value in obj.properties.items() if re.fullmatch("value[0-9]+", key)),
        default=0,
    )

menu_items(obj, count, macros)

A menu mux's item labels. Without symbolTag EDM shows blank items, so label each item by its first macro's value instead.

Source code in pydmconverter/edm/menumux.py
327
328
329
330
331
332
333
def menu_items(obj: EDMObject, count: int, macros: list[list[tuple[str, str]]]) -> list[str]:
    """A menu mux's item labels. Without symbolTag EDM shows blank items, so
    label each item by its first macro's value instead."""
    tags = edm_array(obj.properties.get("symbolTag"))
    if tags:
        return padded(tags, count)
    return [pairs[0][1] if pairs else "" for pairs in macros]

menu_macros(obj, count)

A menu mux's (macro, value) pairs for each item, one per symbol{i}: choosing item n sets symbol{i}[n] to value{i}[n], and an item can name a different macro than the others. EDM leaves trailing empty strings out of symbol{i} and value{i}, and leaves value{i} out altogether when all of its values are empty, so pad each to count.

Source code in pydmconverter/edm/menumux.py
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def menu_macros(obj: EDMObject, count: int) -> list[list[tuple[str, str]]]:
    """A menu mux's (macro, value) pairs for each item, one per symbol{i}:
    choosing item n sets symbol{i}[n] to value{i}[n], and an item can name
    a different macro than the others. EDM leaves trailing empty strings out
    of symbol{i} and value{i}, and leaves value{i} out altogether when all
    of its values are empty, so pad each to count."""
    symbols = sorted(
        (key for key in obj.properties if re.fullmatch("symbol[0-9]+", key)),
        key=lambda key: int(key.removeprefix("symbol")),
    )
    columns = []
    for key in symbols:
        names = padded(edm_array(obj.properties[key]), count)
        if any(names):
            values = edm_array(obj.properties.get("value" + key.removeprefix("symbol")))
            columns.append(list(zip(names, padded(values, count))))
    return [[column[n] for column in columns] for n in range(count)]

padded(values, count)

values cut to count entries, or padded to it with the trailing empty strings EDM leaves out.

Source code in pydmconverter/edm/menumux.py
381
382
383
384
def padded(values: list[str], count: int) -> list[str]:
    """values cut to count entries, or padded to it with the trailing empty
    strings EDM leaves out."""
    return values[:count] + [""] * (count - len(values))