import os
import json
import copy
import logging
from qtpy.QtWidgets import QFrame, QApplication, QLabel, QVBoxLayout, QHBoxLayout, QWidget, QStyle, QSizePolicy, QLayout
from qtpy.QtCore import Qt, QSize, QRect, Property, QPoint, Q_ENUMS
from .base import PyDMPrimitiveWidget
from pydm.utilities import is_qt_designer
import pydm.data_plugins
from ..utilities import find_file
from ..display import load_file
logger = logging.getLogger(__name__)
class FlowLayout(QLayout):
def __init__(self, parent=None, margin=-1, h_spacing=-1, v_spacing=-1):
QLayout.__init__(self, parent)
self.setContentsMargins(margin, margin, margin, margin)
self.m_h_space = h_spacing
self.m_v_space = v_spacing
self.item_list = []
def addItem(self, item):
self.item_list.append(item)
def horizontalSpacing(self):
if self.m_h_space >= 0:
return self.m_h_space
else:
return self.smart_spacing(QStyle.PM_LayoutHorizontalSpacing)
def verticalSpacing(self):
if self.m_v_space >= 0:
return self.m_v_space
else:
return self.smart_spacing(QStyle.PM_LayoutVerticalSpacing)
def count(self):
return len(self.item_list)
def itemAt(self, index):
if index >= 0 and index < len(self.item_list):
return self.item_list[index]
else:
return None
def takeAt(self, index):
if index >= 0 and index < len(self.item_list):
return self.item_list.pop(index)
else:
return None
def expandingDirections(self):
return Qt.Orientations(0)
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
return self.do_layout(QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super(FlowLayout, self).setGeometry(rect)
self.do_layout(rect, False)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QSize()
for item in self.item_list:
size = size.expandedTo(item.minimumSize())
# size += QSize(2*self.margin(), 2*self.margin())
size += QSize(2 * 8, 2 * 8)
return size
def do_layout(self, rect, test_only):
(left, top, right, bottom) = self.getContentsMargins()
effective_rect = rect.adjusted(left, top, -right, -bottom)
x = effective_rect.x()
y = effective_rect.y()
line_height = 0
for item in self.item_list:
wid = item.widget()
space_x = self.horizontalSpacing()
if space_x == -1:
space_x = wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal)
space_y = self.verticalSpacing()
if space_y == -1:
space_y = wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical)
next_x = x + item.sizeHint().width() + space_x
if next_x - space_x > effective_rect.right() and line_height > 0:
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom
def smart_spacing(self, pm):
parent = self.parent()
if not parent:
return -1
elif parent.isWidgetType():
return parent.style().pixelMetric(pm, None, parent)
else:
return parent.spacing()
class LayoutType(object):
Vertical = 0
Horizontal = 1
Flow = 2
layout_class_for_type = (QVBoxLayout, QHBoxLayout, FlowLayout)
[docs]class PyDMTemplateRepeater(QFrame, PyDMPrimitiveWidget, LayoutType):
"""
PyDMTemplateRepeater takes a .ui file with macro variables as a template, and a JSON
file (or a list of dictionaries) with a list of values to use to fill in
the macro variables, then creates a layout with one instance of the
template for each item in the list.
It can be very convenient if you have displays that repeat the same set of
widgets over and over - for instance, if you have a standard set of
controls for a magnet, and want to build a display with a list of controls
for every magnet, the Template Repeater lets you do that with a minimum
amount of work: just build a template for a single magnet, and a JSON list
with the data that describes all of the magnets.
Parameters
----------
parent : optional
The parent of this widget.
"""
Q_ENUMS(LayoutType)
LayoutType = LayoutType
def __init__(self, parent=None):
pydm.data_plugins.initialize_plugins_if_needed()
QFrame.__init__(self, parent)
PyDMPrimitiveWidget.__init__(self)
self._template_filename = ""
self._count_shown_in_designer = 1
self._data_source = ""
self._data = []
self._cached_template = None
self._parent_macros = None
self._layout_type = LayoutType.Vertical
self._temp_layout_spacing = 4
self.app = QApplication.instance()
self.rebuild()
@Property(LayoutType)
def layoutType(self):
"""
The layout type to use.
Returns
-------
LayoutType
"""
return self._layout_type
@layoutType.setter
def layoutType(self, new_type):
"""
The layout type to use.
Options are:
- **Vertical**: Instances of the template are laid out vertically, in rows.
- **Horizontal**: Instances of the template are laid out horizontally, in columns.
- **Flow**: Instances of the template are laid out horizontally until they reach the edge of the template,
at which point they "wrap" into a new row.
Parameters
----------
new_type : LayoutType
"""
if new_type != self._layout_type:
self._layout_type = new_type
self.rebuild()
@Property(int)
def layoutSpacing(self):
if self.layout():
return self.layout().spacing()
return self._temp_layout_spacing
@layoutSpacing.setter
def layoutSpacing(self, new_spacing):
self._temp_layout_spacing = new_spacing
if self.layout():
self.layout().setSpacing(new_spacing)
@Property(int)
def countShownInDesigner(self):
"""
The number of instances to show in Qt Designer. This property has no
effect outside of Designer.
Returns
-------
int
"""
return self._count_shown_in_designer
@countShownInDesigner.setter
def countShownInDesigner(self, new_count):
"""
The number of instances to show in Qt Designer. This property has no
effect outside of Designer.
Parameters
----------
new_count : int
"""
if not is_qt_designer():
return
try:
new_count = int(new_count)
except ValueError:
logger.exception("Couldn't convert {} to integer.".format(new_count))
return
new_count = max(new_count, 0)
if new_count != self._count_shown_in_designer:
self._count_shown_in_designer = new_count
self.rebuild()
@Property(str)
def templateFilename(self):
"""
The path to the .ui file to use as a template.
Returns
-------
str
"""
return self._template_filename
@templateFilename.setter
def templateFilename(self, new_filename):
"""
The path to the .ui file to use as a template.
Parameters
----------
new_filename : str
"""
if new_filename != self._template_filename:
self._template_filename = new_filename
self._cached_template = None
if self._template_filename:
self.rebuild()
else:
self.clear()
def _is_json(self, source):
"""
Validate if the string source is a valid json.
Parameters
----------
source : str
Returns
-------
tuple (bool, obj)
True if a valid json or False otherwise.
Obj will either be the dictionary data or the exception while trying
to load the JSON string.
"""
try:
data = json.loads(source)
return True, data
except Exception as ex:
return False, ex
@Property(str)
def dataSource(self):
"""
The path to the JSON file or a valid JSON string to fill in each
instance of the template.
Returns
-------
str
"""
return self._data_source
@dataSource.setter
def dataSource(self, data_source):
"""
Sets the path to the JSON file or a valid JSON string to fill in each
instance of the template.
For example, if you build a template that contains two macro variables,
${NAME} and ${UNIT}, your JSON file should be a list of dictionaries,
each with keys for NAME and UNIT, like this:
[{"NAME": "First Device", "UNIT": 1}, {"NAME": "Second Device", "UNIT": 2}]
Parameters
-------
data_source : str
"""
if data_source != self._data_source:
self._data_source = data_source
if self._data_source:
is_json, data = self._is_json(data_source)
if is_json:
logger.debug("TemplateRepeater dataSource is a valid JSON.")
self.data = data
else:
logger.debug("TemplateRepeater dataSource is not a valid JSON. Assuming it is a file path.")
try:
parent_display = self.find_parent_display()
base_path = None
if parent_display:
base_path = os.path.dirname(parent_display.loaded_file())
fname = find_file(self._data_source, base_path=base_path, raise_if_not_found=True)
if not fname:
if not is_qt_designer():
logger.error(
"Cannot locate data source file {} for PyDMTemplateRepeater.".format(
self._data_source
)
)
self.data = []
else:
with open(fname) as f:
try:
self.data = json.load(f)
except ValueError:
logger.error(
"Failed to parse data source file {} for PyDMTemplateRepeater.".format(fname)
)
self.data = []
except IOError:
self.data = []
else:
self.clear()
[docs] def open_template_file(self, variables=None):
"""
Opens the widget specified in the templateFilename property.
Parameters
----------
variables : dict
A dictionary of macro variables to apply when loading, in addition
to all the macros specified on the template repeater widget.
Returns
-------
display : QWidget
"""
if not variables:
variables = {}
parent_display = self.find_parent_display()
base_path = None
if parent_display:
base_path = os.path.dirname(parent_display.loaded_file())
fname = find_file(self.templateFilename, base_path=base_path, raise_if_not_found=True)
if self._parent_macros is None:
self._parent_macros = {}
if parent_display:
self._parent_macros = parent_display.macros()
parent_macros = copy.copy(self._parent_macros)
parent_macros.update(variables)
try:
w = load_file(fname, macros=parent_macros, target=None)
except Exception as ex:
w = QLabel("Error: could not load template: " + str(ex))
return w
[docs] def rebuild(self):
"""Clear out all existing widgets, and populate the list using the
template file and data source."""
self.clear()
if (not self.templateFilename) or (not self.data):
return
self.setUpdatesEnabled(False)
layout_class = layout_class_for_type[self.layoutType]
if type(self.layout()) != layout_class:
if self.layout() is not None:
# Trick to remove the existing layout by re-parenting it in an empty widget.
QWidget().setLayout(self.layout())
currLayoutClass = layout_class(self)
self.setLayout(currLayoutClass)
self.layout().setSpacing(self._temp_layout_spacing)
try:
with pydm.data_plugins.connection_queue(defer_connections=True):
for i, variables in enumerate(self.data):
if is_qt_designer() and i > self.countShownInDesigner - 1:
break
w = self.open_template_file(variables)
if w is None:
w = QLabel()
w.setText("No Template Loaded. Data: {}".format(variables))
w.setParent(self)
self.layout().addWidget(w)
except Exception:
logger.exception("Template repeater failed to rebuild.")
finally:
# If issues happen during the rebuild we should still enable
# updates and establish connection for the widgets added.
# Moreover, if we dont call establish_queued_connections
# the queue will never be emptied and new connections will be
# staled.
self.setUpdatesEnabled(True)
pydm.data_plugins.establish_queued_connections()
[docs] def clear(self):
"""Clear out any existing instances of the template inside
the widget."""
if not self.layout():
return
while self.layout().count() > 0:
item = self.layout().takeAt(0)
item.widget().deleteLater()
del item
def count(self):
if not self.layout():
return 0
return self.layout().count()
@property
def data(self):
"""
The dictionary used by the widget to fill in each instance of the template.
This property will be overwritten if the user changes the dataSource
property.
"""
return self._data
@data.setter
def data(self, new_data):
"""
Sets the dictionary used by the widget to fill in each instance of
the template. This property will be overwritten if the user changes
the dataSource property. After setting this property, `rebuild`
is automatically called to refresh the widget.
"""
self._data = new_data
self.rebuild()