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}")
|